Skip to content

docs: migrate remaining docs.claude.com URLs to code.claude.com - #1567

Merged
kyle-sexton merged 1 commit into
mainfrom
docs/migrate-claude-docs-domain
Jul 26, 2026
Merged

docs: migrate remaining docs.claude.com URLs to code.claude.com#1567
kyle-sexton merged 1 commit into
mainfrom
docs/migrate-claude-docs-domain

Conversation

@kyle-sexton

@kyle-sexton kyle-sexton commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Closes #1564

Summary

Anthropic moved the Claude Code documentation from docs.claude.com/en/docs/claude-code/<slug> to
code.claude.com/docs/en/<slug>. The old URLs still 301-redirect, so this migrates the repo
while the redirect works rather than after it is retired.

A repo-wide sweep (every file type, not just markdown) found 369 code.claude.com references
against 5 stale docs.claude.com ones — the bulk migration was already done. Four are
rewritten here; the fifth is deliberately left alone.

docs/OFFICIAL-DOCS.md, this repo's canonical index of official pages, was checked and is
already fully migrated (51 code.claude.com hits, zero stale).

What changed

file old new
plugins/claude-ops/skills/known-issues/context/issue-templates.md docs.claude.com/en/docs/claude-code code.claude.com/docs/en/overview
…/claude-code/quickstart code.claude.com/docs/en/quickstart
…/claude-code/troubleshooting code.claude.com/docs/en/troubleshooting
plugins/playbooks/skills/boris/reference/autonomy.md …/claude-code/settings code.claude.com/docs/en/settings

Version bumps + changelog entries: claude-ops 0.21.4 → 0.21.5, playbooks 0.5.1 → 0.5.2.

Verification

The path shape changes (/en/docs/claude-code/X/docs/en/X), so a mechanical string swap is
not safe. Every rewritten URL was verified by fetching the old URL, observing where it landed, then
fetching the proposed target and confirming its topic:

  • /settings — old 301s to code.claude.com/docs/en/settings; target is "Claude Code settings".
  • /quickstart — old 301s to code.claude.com/docs/en/quickstart; target is "Quickstart".
  • /troubleshooting — old 301s to code.claude.com/docs/en/troubleshooting; target is
    "Troubleshooting".
  • The bare docs root does not behave like the others: docs.claude.com/en/docs/claude-code
    302s to platform.claude.com/docs/en/claude-code, then 307s to code.claude.com/docs — a
    different host from the other three. code.claude.com/docs/en/overview is therefore an
    inferred target, fetched and confirmed independently to be the real Overview page, chosen
    because it matches this repo's dominant /docs/en/<slug> convention rather than the bare root.

Deliberately not changed

plugins/playbooks/skills/boris/vendor/SKILL.md:1129 carries the same stale URL. Its sibling
skills/boris/SKILL.md declares it the "verbatim upstream baseline… for drift detection only", so
it is byte-compared against upstream and any edit here would manufacture false drift.

Deferred (not in this PR)

lychee.toml link-checks every markdown file including that vendored baseline, and its exclude
list has no claude.com entry. When Anthropic retires the 301, the weekly advisory link-check lane
will start filing a tracking issue for a URL that cannot be fixed by editing the file. The remedy
is a lychee.toml exclude entry (or an exclude_path for the boris vendor/ directory) — but
lychee.toml is a tool config materialized from melodic-software/standards, so per AGENTS.md
that fix belongs upstream rather than as a local patch. Recorded on #1564.

Related

Anthropic moved the Claude Code documentation from
docs.claude.com/en/docs/claude-code/<slug> to code.claude.com/docs/en/<slug>.
The old URLs still 301-redirect, so this migrates while the redirect works
rather than after it is retired.

A repo-wide sweep found 369 code.claude.com references against 5 stale
docs.claude.com ones — the bulk migration was already done. docs/OFFICIAL-DOCS.md,
this repo's canonical index, was checked and is already fully migrated.

Four of the five stragglers are rewritten here. The path shape changes, so each
target was verified by fetching the old URL, observing the redirect, and
confirming the destination page's topic — /settings, /quickstart and
/troubleshooting each 301 cleanly. The bare docs root does not: it 302s to
platform.claude.com then 307s to code.claude.com/docs, so it now points at the
Overview page, matching this repo's dominant /docs/en/<slug> convention.

The fifth, plugins/playbooks/skills/boris/vendor/SKILL.md, is deliberately left
alone — it is the verbatim upstream baseline kept for drift detection, and any
edit would manufacture false drift.

Refs #1564

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@claude

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 1m 0s —— View job


PR Review

  • Gather context and read changed files
  • Review URL migrations for correctness
  • Check version bumps and changelogs
  • Verify alignment with CLAUDE.md guidelines
  • Post final review findings

This PR is clean and correct. Four URL rewrites, two patch version bumps, two matching changelog entries. No issues found.

URL migrations

All four rewritten URLs correctly apply the domain change (docs.claude.com/en/docs/claude-code/<slug>code.claude.com/docs/en/<slug>):

File Old New
issue-templates.md L198 …/claude-code (bare root) …/docs/en/overview
issue-templates.md L199 …/claude-code/quickstart …/docs/en/quickstart
issue-templates.md L200 …/claude-code/troubleshooting …/docs/en/troubleshooting
autonomy.md L78 …/claude-code/settings …/docs/en/settings

Bare-root substitution rationale is sound. The bare docs.claude.com/en/docs/claude-code redirect chains through platform.claude.com before landing at code.claude.com/docs — a different landing point from the other three. Choosing …/docs/en/overview as a specific, independently-confirmed page (matching the repo's URL convention) is the right call.

Vendor baseline

plugins/playbooks/skills/boris/vendor/SKILL.md:1129 still holds the stale URL. Confirmed intentional — the file is the verbatim upstream baseline for drift detection and editing it would produce false drift. The changelog correctly documents this decision.

Version bumps

Both are patch bumps (0.21.4 → 0.21.5, 0.5.1 → 0.5.2), appropriate for a documentation-only fix with no behavioral change.

Changelog entries

Both changelog entries are placed under ### Fixed, describe the old and new path patterns, note the verification method, and explain the bare-root special case. Format is consistent with existing entries in both files.

Remaining stale references

A post-merge sweep confirms only three docs.claude.com occurrences will remain in the repo:

  • Two changelog prose lines (backtick text, not live links) describing the old pattern — correct.
  • The vendor baseline line — intentionally unchanged as documented.

No action needed on any of them.


No findings. LGTM.

@claude

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 1m 10s —— View job


Security Review

  • Gather context (PR diff, changed files)
  • Analyze content files for security issues
  • Analyze plugin.json changes
  • Analyze CHANGELOG changes
  • Post findings

No security issues found.

Scope reviewed: 6 files — two plugin.json version bumps, two CHANGELOG.md prose additions, and two markdown reference files with URL migrations.

Attack surface assessment:

The changed files are documentation reference content. The URLs in issue-templates.md and autonomy.md are static display links for human readers — they are not interpolated into shell commands, passed to curl/fetch, or used in any executable context at runtime. No injection surface exists.

Both the old domain (docs.claude.com) and the new domain (code.claude.com) are Anthropic-controlled. No trust boundary crosses into attacker-reachable territory. The URL scheme on every changed link is https://, with no javascript: or other dangerous scheme.

The plugin.json changes are version-string-only bumps (0.21.4→0.21.5, 0.5.1→0.5.2). No new permissions, no new userConfig keys, no new egress targets, no structural changes to the manifest.

The CHANGELOG additions are plain prose — no executable content.

@kyle-sexton
kyle-sexton merged commit 279ad68 into main Jul 26, 2026
32 of 33 checks passed
@kyle-sexton
kyle-sexton deleted the docs/migrate-claude-docs-domain branch July 26, 2026 18:02
kyle-sexton added a commit that referenced this pull request Jul 26, 2026
Resolved plugins/claude-ops/CHANGELOG.md: both sides added a section
under the same heading and both belong. #1567 landed a Fixed entry for
the docs.claude.com migration and took 0.21.5 with it, so this branch's
Changed entry moves to a new 0.21.6 and plugin.json follows — otherwise
two unrelated changes ship under one version.

The merge touches no file under scripts/, so the gate's own behaviour is
unchanged by it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uj3rsit5cxxuJuCXd1Hz2E
kyle-sexton added a commit that referenced this pull request Jul 26, 2026
…ze guidance (#1586)

Closes #1575
Closes #1576
Closes #1577

## The problem

The `playwright` skill's canonical video example is a bare
`playwright-cli video-start demo.webm`. That deterministically produces
an **800x450** file. An
operator following the skill verbatim — including setting a large
viewport with `resize` — gets
800x450 recordings every time, and nothing in the skill explains why or
how to change it. The
downstream report was nine E2E screen recordings, all 800x450, after a
1920x1200 `resize`.

## Which fix is correct, and why (stated explicitly)

The task framing offers two valid resolutions: make the guarantee true,
or stop claiming it.

**`--size` does exist upstream** — verified in `help.json` extracted
from `@playwright/cli@0.1.17`,
the exact version this skill's frontmatter pins:

```text
Options:
  --size    video frame size, e.g. "800x600". if not specified, the size of the recorded video will fit 800x800.
```

So the guarantee is deliverable, and this PR makes it true rather than
retracting it. Worth being
explicit about what "behavior" means for a skill: **this skill's
behavior *is* the commands it
instructs an agent to run.** Changing the canonical example to pass
`--size` is therefore the
behavior fix, not a prose paper-over of a broken one. There is no
separate code path left unfixed.

The one place I *did* choose retraction over repair is the cost figure —
see defect 3.

## Verdicts on the three reported defects

### 1. `--size` undocumented — REAL (#1575)

Confirmed three ways: the installed CLI's `--help`, `help.json` from the
pinned 0.1.17, and an
ffprobe-measured repro. The string `--size` appeared nowhere in the
skill.

### 2. "Defaults (accept, don't override)" steers wrong — REAL, but
NARROWER than reported

The report framed this as the section contradicting the fix. On
inspection it is more precisely an
*omission*, and two sub-claims do not survive:

- The `| Viewport | 1280x720 |` row is **factually correct** — that is
the CLI's default viewport, per
the `@playwright/cli@0.1.17` README (`PLAYWRIGHT_MCP_VIEWPORT_SIZE` —
"specify browser viewport size
in pixels, for example \"1280x720\""). It is not a defect and it stays.
- The section says "don't add `PLAYWRIGHT_MCP_*` env vars **to project
settings**". The video fix is a
per-command env prefix on `open`, which that sentence does not forbid.
Not a contradiction.

What was genuinely missing is a documented exception: an agent reading
"accept, don't override" next
to a 1280x720 row reasonably concludes the viewport is not a knob to
touch. So the fix here is one
added carve-out paragraph with a cross-link — not the section rework the
report suggested. Demolishing
the section would be overreach the evidence does not support.

### 3. "1280x720 WebM is ~5 MB/minute" — REAL

Two errors in one line. The CLI never emits 1280x720 by default, *and*
the `~5 MB/minute` figure is
unsourced — it appears in no upstream skill, no `@playwright/cli`
README, and no official Playwright
doc. **Deliberately removed rather than re-anchored to 800x450**:
correcting only the resolution
would relocate the fabrication instead of fixing it, and short clips of
a static page cannot ground a
replacement number. Replaced with a qualitative statement (size scales
with frame area and on-screen
motion).

## Evidence

Empirical repro, ffprobe on the resulting `.webm`, `@playwright/cli`
0.1.14 on Windows:

| Scenario | Measured |
|---|---|
| `open`, bare `video-start` | `vp8, 800, 450` |
| `open`, `resize 1920 1200`, bare `video-start` | `vp8, 800, 450` —
`resize` does not move it |
| `PLAYWRIGHT_MCP_VIEWPORT_SIZE=1920x1200 open`, bare `video-start` |
`vp8, 800, 500` |
| `open`, `video-start --size "1920x1200"` | `vp8, 1920, 1200` — but a
frame extracted at n=20 shows the 1280x720 render in the **top-left
corner**, rest padded grey |
| both levers, matched | `vp8, 1920, 1200`, correct |

That last row refines the original report, which described it as an
"upscaled 1280-wide render". It is
not upscaled — it is letterboxed top-left, exactly as
<https://playwright.dev/docs/videos> describes: *"The video of the
viewport is placed in the top-left
corner of the output video, scaled down to fit if necessary."*

Sources, all fetched or executed this session:

- `help.json` from `@playwright/cli@0.1.17` (npm tarball, the pinned
version) — `--size` present,
  identical text to 0.1.14; `flags: { size: "string" }`
- `@playwright/cli@0.1.17` README — `PLAYWRIGHT_MCP_VIEWPORT_SIZE` env
var and format; the
`.playwright/cli.config.json` schema showing `browser.contextOptions`
and a top-level
  `saveVideo: { width, height }`
- <https://playwright.dev/docs/api/class-browser#browser-new-context> —
`recordVideo.size`: "If not
specified the size will be equal to `viewport` scaled down to fit into
800x800. If `viewport` is not
  configured explicitly the video size defaults to 800x450."
- <https://playwright.dev/docs/videos> — "You may need to set the
viewport size to match your desired
  video size."

## Repo-convention compliance

- **Version**: `0.4.0` → `0.5.0`. Precedent in this plugin's own
CHANGELOG: 0.3.1/0.3.2 were pure doc
corrections → patch; 0.4.0 folded in newly-surfaced upstream commands
and flags → minor. Surfacing
  `--size` matches the 0.4.0 pattern.
- **Frontmatter untouched.** No listing description or trigger-keyword
change was needed, so none was
made. `check-skill.sh` confirms *"all 10 base-ref trigger phrase(s)
preserved"*.
- **`vendor/` untouched.** It is a verbatim upstream baseline for drift
detection; editing it would
make the next `update --check` report false drift. Upstream's own
shipped skill omits `--size` too —
  that is an upstream gap, noted as reportable, not patched here.
- **Repo-agnostic.** The reported fix used `1920x1200`, the reporting
operator's personal preference.
Not baked in — the docs use a neutral illustrative `1440x900` and teach
the *pattern* (set viewport
at `open` AND pass `--size`, matched), since the number is the
consumer's call.
- **Fresh-docs mandate.** Scope is contract surfaces; this change is
confined to skill prose bodies
with frontmatter untouched, so it falls outside that scope. The
load-bearing claims are grounded in
the pinned package's own `help.json` and README plus playwright.dev, all
obtained this session and
cited above. No stale `docs.claude.com` URLs exist under
`plugins/playwright`.

## Checks run locally

- `check-skill.sh playwright` — **PASS**, 0 errors (1 pre-existing
advisory warning: no Gotchas surface)
- `validate-plugins.sh` — all manifests + catalog pass
- `check-changelog-parity.sh --check` and `--check-bump origin/main` —
pass
- `check-skill-portability.sh origin/main`,
`check-contract-slice-prune.sh --check-diff origin/main`,
`check-shell-portability.sh origin/main`, `generate-catalog.mjs --check`
— pass

## Deliberately deferred

- **A dedicated `## Gotchas` section.** The report suggested one for
"resize does not affect video
frame size". That fact now lives in two places a reader actually hits —
the SKILL.md Defaults
exception (the exact spot that previously steered wrong) and the
measured-outcomes table. A separate
section would duplicate it to silence an advisory warning that predates
this PR.
- **The `.playwright/cli.config.json` route.** The README schema shows
`browser.contextOptions`
(accepts `viewport`) and a top-level `saveVideo: { width, height }`, and
confirms `saveVideo` governs
whole-session auto-save — a different mechanism from on-demand
`video-start`. Documented as such and
  explicitly flagged unverified, since I did not measure it.
- **Reporting the `--size` omission upstream** to
`microsoft/playwright-cli`.

## Not verified

Official Playwright docs do **not** anywhere state that video frame size
is fixed at context creation
and immune to a later resize. I searched `docs/src/videos.md` and the
`Page.setViewportSize` section of
`docs/src/api/class-page.md` in full. The claim is therefore attributed
to measurement in this repo's
wording ("`resize` does not change the video frame size" — what ffprobe
showed), never asserted as
documented upstream behavior.

## Related

- #1567 — migrated remaining `docs.claude.com` URLs to `code.claude.com`
repo-wide. Merged before
this branch was cut, so there was no collision. Verified independently
that no stale
`docs.claude.com` URL remains under `plugins/playwright`; this PR adds
none.
- Upstream `microsoft/playwright-cli` — its own shipped skill
(`vendor/references/video-recording.md`) omits `--size` too. Not closed
by this PR and not patched
here, since `vendor/` is a verbatim drift-detection baseline; reportable
upstream as follow-up.
- No ADR or decision-log entry applies — this is a
documentation-correctness fix inside one plugin,
  with no architecture or contract-surface decision attached.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
kyle-sexton added a commit that referenced this pull request Jul 26, 2026
…ission baseline (#1599)

Closes #1598.

## What was wrong

`plugins/claude-config/skills/audit/reference/required-permissions.md`
recommended a
`sensitive-file-deny` baseline (`Read(./.env)`, `Read(./secrets/**)`,
`Read(./.claude/settings.local.json)`, key/PEM/SSH patterns) as the
remedy for protecting secrets,
without ever saying what a `Read` deny reaches. A reader who followed it
believed the file was
protected. This is a security-documentation defect: the doc created
confidence the mechanism does
not deliver.

## What I verified — and what I refuted

Fetched this session:

[permissions](https://code.claude.com/docs/en/permissions#read-and-edit),
[sandboxing](https://code.claude.com/docs/en/sandboxing),
[tools
reference](https://code.claude.com/docs/en/tools-reference#powershell-tool).

**Refuted, in part.** The originating report framed this as `Read(...)`
and `Bash(...)` being
separate matcher namespaces, so that a `Read` deny leaves
`cat`/`grep`/`head`/`sed` open. Upstream
says otherwise: Read and Edit deny rules "apply to Claude's built-in
file tools **and to file
commands Claude Code recognizes in Bash, such as `cat`, `head`, `tail`,
and `sed`**." The obvious
`cat` fallback is blocked.

**Confirmed.** They "don't apply to arbitrary subprocesses that read or
write files indirectly, like
a Python or Node script that opens files itself." That is the real gap
and it matches the reported
observation (an interpreter one-liner) exactly.

**Also refuted: the report's own suggested fix.** It proposed a
`PreToolUse` hook on
`Bash|PowerShell` inspecting the command string as "the durable remedy".
Upstream documents
argument-constraining Bash patterns as fragile, and a hook reading the
same command string inherits
the same evasion surface. Shipping that as the answer would have
relocated the false confidence
rather than removing it, so the hook is ranked below the sandbox and
explicitly called a speed bump,
not a boundary.

## What landed

- **New "Scope of a Read deny" subsection** in
`required-permissions.md`: covered vs not covered;
  `Bash(cat *)`-style enumeration named a non-remedy; the sandbox
(`sandbox.filesystem.denyRead`, `sandbox.credentials.files` `"mode":
"deny"`) as the documented
OS-level path **with its platform limit** — it does not run on native
Windows, so it is unavailable
in exactly the environment that produced the observation; a `PreToolUse`
hook as best-effort; and
the residual risk stated plainly — a deny glob cannot keep a secret from
a session that has shell
execution, so the durable control is keeping the secret out of the
session's reach.
- **Two facts flagged unverified rather than asserted**: whether
PowerShell-tool reads
(`Get-Content`, `type`) are covered at all — upstream scopes the
coverage to commands "in Bash" and
the tools reference lists `Read(...)` as applying to "Read, Grep, Glob,
LSP", neither addressing
PowerShell; and the full membership of the recognized-command set, which
upstream gives with "such
as", leaving `grep`, `jq`, `strings`, and redirects unconfirmed in both
directions.
- **`SKILL.md`**: Category B reports the baseline with that scope in
both directions — a present
  baseline is not reported as proof the file is unreachable.
- **`context/procedures.md`**: the skill's own `settings.local.json`
recipes no longer imply they
escape the recommended deny. The safety is in what gets emitted, not
what gets opened —
`check-structure.sh` reads from a subprocess and is safe because it
emits counts only, while the
supplemental `cat … | jq` recipes are blocked in a compliant project,
correctly so. Routing around
that with an interpreter one-liner is prohibited; the audit reports the
file as not inspectable
  under the project's own rule.
- **Eval 7** (`read-deny-scope-not-overstated`) covering the new
reporting behavior.
- Version `0.13.0` → `0.14.0` with a matching CHANGELOG entry.

**The pattern table is unchanged.** It is the Category B presence check;
changing rows would change
what the audit flags.

## Deliberately not done

- **No new hook in `claude-config`.** `guardrails` owns `PreToolUse` on
`Bash|PowerShell`; its
`secret-pattern-detection.sh` matches `Write|Edit|NotebookEdit` (it
blocks *writing* secrets), and
its shell-matched hooks are git/commit-scoped, so nothing there covers
credential reads today.
Whether `guardrails` should grow a credential-read matcher is a product
call for that plugin, and
per the analysis above a command-string hook would be a speed bump
regardless — it does not belong
  in `claude-config`.
- **No repo-wide sweep.** Only `required-permissions.md` states this
recommendation; no other plugin
  and no `docs/**` page does.

## Related

- #1567 — the repo-wide `docs.claude.com` → `code.claude.com` URL
migration whose destination
  domain every citation added here uses.
- Not closed by this PR: whether `guardrails` should grow a
credential-read `PreToolUse` matcher.
That is a product call for `guardrails`, argued against as a *boundary*
in this PR's analysis; no
  issue filed.
- Not closed by this PR: `audit-checklist.md` B.4 flags `:*`
permission-rule syntax as "deprecated",
but the current permissions page documents `Bash(ls:*)` as an equivalent
trailing-wildcard form.
Noticed while verifying this item, out of its scope, left for a separate
change.

## Verification

`check-changed-skills.sh` PASS (0 errors), `check-changelog-parity.sh
--check-bump` pass,
`generate-catalog.mjs --check` in sync, `validate-plugin-contracts.mjs`
pass, `validate-plugins.sh`
pass, markdownlint 0 issues, evals JSON parses.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
kyle-sexton added a commit that referenced this pull request Jul 26, 2026
…blocked (#1587)

Closes #1563

## Summary

`hook::buffer_stdin` read the hook payload with `IFS= read -r -d '' -t
"$read_timeout"`. On a pipe
— which is exactly how Claude Code delivers hook stdin — bash consumes
`read -d ''` one byte at a
time, measured at roughly **32 KB/s** on Git Bash. The
`stdin_read_timeout` bound (default 2s) was
therefore a **~64 KB throughput ceiling**, not the stall detector it was
written to be.

Past that ceiling the read returned a truncated payload and rc 2:

- **Seven fail-closed guardrails guards** — `hardcoded-path-check`,
`secret-pattern-detection`,
`block-no-verify`, `block-dangerous-git`, `block-hook-bypass`,
`block-noncanonical-commit`,
`block-convention-violation` — map rc 2 to `exit 2`, so a legitimate
large write was **blocked**,
with its content never scanned. Reported from the field: a full-file
`Write` of an 844-line
(~50 KB) document blocked repeatedly, forcing the author to write it in
five chunks.
- **Every other caller** uses `|| exit 0`, so on a large payload the
formatter / audit / advisory
  hook **silently did not run at all**, with no diagnostic.

## Reproduction (before the fix)

`hook::buffer_stdin` in isolation, file redirect vs. pipe:

| payload | file redirect | pipe |
| --- | --- | --- |
| 1 KB | 176 ms | 221 ms |
| 10 KB | 240 ms | 461 ms |
| 50 KB | 428 ms | **1564 ms** — 78% of the bound |
| 100 KB | 666 ms | **rc 2 — BLOCKED** |
| 200 KB | 1396 ms | **rc 2 — BLOCKED** |

End-to-end, a benign `Write` payload (nothing in the content is a
violation) piped into the real
`plugins/guardrails/hooks/hardcoded-path-check.sh` with
`CLAUDE_PROJECT_DIR` set:

```
payload=20504    rc=0  elapsed_ms=1810
payload=50939    rc=0  elapsed_ms=2857
payload=101664   rc=2  elapsed_ms=2853   BLOCKED: hook stdin timed out before a complete JSON payload arrived.
payload=203113   rc=2  elapsed_ms=3006   BLOCKED: hook stdin timed out before a complete JSON payload arrived.
```

The block is purely a function of payload size. A 50 KB source file
JSON-escapes to well over 50 KB
of payload, which is why the 50 KB field report and this 100 KB
reproduction are the same defect —
the threshold sits in the 50–100 KB range and moves with machine load.

## Fix

Two changes together, because either alone leaves the bound meaning the
wrong thing.

**1. Chunk the read.** `read -N 65536` lets bash satisfy the read in
blocks rather than
byte-at-a-time. Measured: 50 KB drops from ~2100 ms to **~20 ms**, 200
KB from ~6800 ms to
**~85 ms**. All four end-to-end payloads up to 200 KB now return rc 0.

**2. Measure inactivity, not the read.** `read -t` is a deadline for the
*whole* requested read, not
an inactivity timer, so a producer making steady progress but slower
than one chunk per window would
still trip it. `read` assigns whatever it received even when it times
out, so any byte counts as
progress: the partial chunk is kept and the read continues. Only the
absence of bytes for a whole
`stdin_read_timeout` is a stall.

**2b. Read the bound in four slices.** `read -t` reports only that its
window expired, never *when*
inside it the last byte arrived — so armed as one window, a stall is
declared anywhere between one
and **two** bounds after the pipe actually went quiet. Slicing caps that
overshoot at a quarter-bound.
That residual quarter is the honest limit of the mechanism, and it errs
toward waiting, never toward
declaring a live producer dead. Measured back to back at a 1.2 s bound:
a partial-then-silent producer
is declared stalled at **2012 ms** sliced vs **2728 ms** unsliced. A
shell whose `read -t` rejects the
fractional slice degrades to a count of 1 — exactly the unsliced
behavior — rather than failing.

**3. Stop reading once the buffer is already whole.** Otherwise the
Win32 late-EOF case — payload
complete, pipe simply never closed — waits out the rest of the bound for
an EOF that is not coming.
The loop now checks completeness before continuing. Some wait is the
floor, since a held-open pipe is
indistinguishable from a slow producer until a window expires. Measured
at a 1.2 s bound: **719 ms**
with the early stop vs **1909 ms** without.

The stall verdict now comes from `read`'s own exit status — EOF returns
1, an exceeded `-t` returns
>128 — instead of elapsed-time arithmetic, which deletes the
`EPOCHREALTIME`/`awk` heuristic and two
`awk` subprocesses per hook invocation.

The trade this makes, stated in the comment: a producer trickling bytes
indefinitely is never cut off
here. That is deliberate — Claude Code's own default timeout for a
`command` hook is **600 seconds**
([hooks reference](https://code.claude.com/docs/en/hooks), fetched this
session), so the harness is
the outer bound, and blocking a live producer is exactly the failure
this function had.

**4. Validate the configured timeout instead of passing it straight to
`read`.** `stdin_read_timeout`
is consumer-configurable and reached `read -t` directly. An unusable
spec is a **silent disable**:
`read` rejects it with rc 1, the loop reads rc 1 as EOF, the payload
comes back empty, every caller
skips, and a usage error prints on every hook invocation. Worse,
`stdin_read_timeout=0` made the
chunked loop **spin** — `read -t 0` returns success having consumed
nothing. `hook::resolve_read_timeout`
now falls back to the default, deciding acceptance by **probing the
running shell** rather than a
Bash version table (the upstream changelog does not date the
introduction of fractional timeouts, so
a version check would be a guess). Loop termination is additionally made
structural: a successful
read that consumed nothing breaks rather than continues.

**5. Never feed a hook payload to jq through a here-string.** `jq -e .
<<< "$buf"` delivers the
string through a pipe that **bash fills itself** before exec'ing jq, so
a payload at or above the
pipe capacity — 65536 bytes here, exactly one read chunk — **blocks the
shell forever**. Traced:
65536 hung indefinitely, 65000 returned immediately. Every such call now
goes through `printf | jq`:
`hook::json_complete`, `buffer_stdin`'s final check,
**`hook::jq_field`**, and claude-ops'
`skill-usage-expansion-audit`. `hook::jq_field` is the load-bearing one
— it takes the whole buffered
payload and is called by most hooks in the fleet, and the old throughput
ceiling is precisely what
kept payloads that large from ever reaching it. Removing the ceiling
made this deadlock reachable, so
this PR had to fix it.

**Bash 3.2.** `read -N` is Bash 4.1+, and nine plugin READMEs document
Bash 3.2+ support (macOS
system bash), so the pre-4.1 path falls back to the delimiter read
inside the same re-arming loop —
the same guard and rationale as
`plugins/context-guard/scripts/statusline-tee.sh`. The guard is split
into its own predicate purely so that path stays reachable in tests:
`BASH_VERSINFO` is readonly and
cannot be shadowed. It is not a consumer seam; nothing reads it from the
environment.

## The fail-closed posture is unchanged

This was the constraint the change had to respect — a guard that
silently permits an unscanned write
is strictly worse than one that is annoying. Verified three ways against
the real hook:

```
1. small + violation      : rc=2  Hardcoded machine-specific path(s) in …/docs/probe.md:
2. 200KB + trailing viol. : rc=2  Hardcoded machine-specific path(s) in …/docs/probe.md:
3. stalled pipe           : rc=2  BLOCKED: hook stdin timed out before a complete JSON payload arrived.
```

Case 2 is the improvement that matters most: a violation sitting at the
very **end** of a 200 KB
payload is now genuinely *caught*, where before the whole write was
swept into a content-blind
block. Case 3 is the posture proof — a truncated payload on a pipe held
open still exits 2.

The jq completeness check is kept as the backstop, so the **Win32
late-EOF** case this function
exists for — a complete payload on a pipe that never closes — still
succeeds rather than blocking.

## Tests

`lib/hook-utils.test.sh` gains five cases alongside the existing timeout
test:

- **18b** — a *complete* JSON payload on a pipe held open past the
timeout returns rc 0 with the
payload (the Win32 late-EOF contract), **and** settles in one window
rather than two. That second
assertion is by comparison, not against a wall-clock constant: it times
the real function and a
variant with the completeness predicate overridden to always fail
(reproducing the pre-fix
behavior) back to back on the same host, so runner load cancels out. It
fails loudly if the harness
  returns no measurement on a host that has `EPOCHREALTIME`.
- **18c** — a 256 KB payload on a real pipe at the **default** timeout
returns rc 0 with the content
intact, asserted on content rather than wall-clock so a loaded runner
cannot flake it. If the
  byte-at-a-time read ever returns, this fails outright.
- **18d** — a payload trickled one character per 100 ms against a 300 ms
timeout (far too slow to
fill a chunk in any window) returns rc 0 with the whole payload. This is
the idle-bound regression
  test; against the first commit on this branch it returned **rc 2**.
- **18d'** — that same trickle then going *silent* mid-payload still
returns rc 2, so re-arming on
  progress did not quietly become "never time out".
- **18e** — first asserts the guard override actually flips to the
pre-4.1 branch (otherwise the
cases would be vacuous — the first attempt at this test shadowed
`BASH_VERSINFO`, which is
readonly, and silently tested the modern path), then that the
delimiter-read fallback buffers a
  128 KB payload and still fails closed on a stalled pipe.

- **18g** — a stall is declared near **one** bound, not two, asserted
against a variant with the
slice count forced to 1, and gated on a precondition check that the
override actually engages.
- **18f** — five unusable `stdin_read_timeout` values (`abc`, `0`, `-1`,
`1e3`, empty) must each fall
back to the default, still deliver the payload, and emit **nothing** on
stderr; plus a positive case
proving a valid non-default value is still honored rather than
collapsed. The `0` case doubles as
the loop-termination test — before the guard it hung, so a regression
shows up as the suite never
  finishing.

Existing Test 18 (stalled pipe → rc 2 + `BLOCKED:`) is unchanged and
still passes.

- **18h** — a 65536-byte held-open payload (exactly one read chunk)
costs no more than a
non-boundary one, preceded by an assertion that the fixtures are
*exactly* 65536 and 65000 bytes.
This is also the here-string-deadlock regression test: before the fix it
hung outright.

Local runs: `lib/hook-utils.test.sh` 114/114 (66 s),
`hardcoded-path-check.test.sh` 72/72,
`secret-pattern-detection.test.sh` 42/42, `markdown-format.test.sh`
112/112,
`typos-format.test.sh` 75/75, `block-hook-bypass.test.sh` 203/203,
`stale-path-verify.test.sh` 87/87, `lane-stop-gate.test.sh` 26/26.
`shellcheck` clean,
`check-shell-portability.sh` clean.
`sync-hook-utils.sh --check` / `--check-bump`,
`check-changelog-parity.sh --check` / `--check-bump`
all pass.

**Not run: a real Bash 3.2.** No 3.2 host was available here, so the
fallback is verified by forcing
the branch on a modern bash, not by executing under 3.2 itself.

**Four tests on this branch were silently vacuous before being caught**
— one shadowed the readonly
`BASH_VERSINFO` and tested the modern path while claiming to test the
3.2 fallback; one bracketed a
whole pipeline and measured the producer's `sleep`; one had a missing
`;` in its override string, so
it exercised the unmodified function; one generated a 59578-byte
"65536-byte" fixture because it
stripped newlines after truncating. Every timing, branch-forcing, and
exact-size case now asserts its
own precondition first, so a broken harness fails rather than passes.

## Portability lint

Two `portability-ok:` annotations were added for **pre-existing** false
positives that only surfaced
because this change puts all 14 synced copies into the changed set: a
bash glob bracket class
`[\<\>]` matching literal `<`/`>`, and a literal `a\b` path fixture —
neither is a GNU `\<`/`\b`
regex construct. Annotated with the gate's own documented hatch rather
than touching the synced
linter.

## Secondary: `stdin_read_timeout` in guardrails' userConfig

Guardrails' hooks already read `CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT`
through the shared library
but never declared the option, so a consumer had no supported way to set
it. `actionlint` and
`claude-ops` both declare it, and `actionlint`'s setup skill records the
convention explicitly
("hook plugins reusing the shared lib should declare it too"). Declaring
it exposes the same knob
here, documented in the guardrails README.

Deliberately **not** claimed: that the harness materializes the
`default` into the env var. The
plugins reference describes `default` as "value used when the user
provides nothing" and says all
values "are exported to hook processes as `CLAUDE_PLUGIN_OPTION_<KEY>`
environment variables"

([plugins-reference](https://code.claude.com/docs/en/plugins-reference),
fetched this session), but
that specific default-materialization path is not something this PR
verified end-to-end. The
effective default with nothing configured remains the shell-level `:-2`
fallback inside
`hook-utils.sh`, which is code-verifiable here.

## Scope

`lib/hook-utils.sh` is synced into all 14 carrying plugins by
`scripts/sync-hook-utils.sh`; the
coupled `sync-hook-utils` / `changelog-parity` CI gates require every
carrying plugin to bump and add
a changelog entry, so all 14 are bumped (guardrails to a minor,
`0.18.0`, for the new userConfig
option; the rest patch).

The supported Bash floor is **unchanged at 3.2**. (An earlier revision
of this PR claimed the library
already required 4.0+ because of `${var^}` in `hook::normalize_path` —
that was wrong: those case
operators sit behind an `OSTYPE` msys/cygwin branch that never executes
on macOS. The 4.1-only
`read -N` is now guarded rather than unconditional.)

## Related

- Refs #1564 / #1567 — the other half of the same handoff item (docs
domain migration), landed
separately. This branch is rebased on top of it, which is why
`claude-ops` lands on 0.21.6.
- Explicitly **not** related to the previously-closed
`hardcoded-path-check` fail-**open** scope
item: this is the contrasting fail-**closed** observation, not
confirmation of that one.

## Deliberately out of scope

`hook::buffer_stdin` returns 1 (skip) when stdin hits **EOF** with a
truncated payload — a genuinely
short write rather than a stall. Arguably that should fail closed too,
but it has not been observed
firing and changing it is a separate behavior change. Left as-is.

Worth knowing when reading the new control flow: a truncated payload
that goes quiet and *then*
closes reaches that EOF branch (rc 1) rather than the stall branch, if
the close lands before the
silence exceeds one full timeout window. The stall branch requires a
window that delivers nothing —
which is the Win32 late-EOF shape this guard is actually aimed at, and
what Test 18 and the
end-to-end stall case both exercise.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

docs: migrate remaining docs.claude.com URLs to code.claude.com

1 participant