571 Commits

Author SHA1 Message Date
Tom Boucher
bcd99696d3 chore(#4591): add platform-conformance-tier classifier + gate CI on it (#4598) 2026-09-10 08:36:13 -04:00
Tom Boucher
615b74ff45 fix(#4460): correct two stale changesets left by an admin-merge race (#4572)
* fix: address orthogonal-review findings on the new work in this PR

Isolated code-review + security-review of everything added to this PR
since its original review (hono override, check-env.cjs rewrite/revert,
new lib file, its test, installer enumeration). Security review: clean,
no findings. Code review found:

- BLOCKER: .changeset/silly-hens-relax.md described a hono override
  this PR no longer actually makes -- PR #4560 landed the identical fix
  on next first, and this branch's own hono commit became a genuine
  no-op the moment it was rebased onto that updated next (git diff
  origin/next -- package.json package-lock.json is empty). Deleted the
  orphaned changeset; next already carries #4560's equivalent one
  (.changeset/zesty-seals-click.md).
- HIGH: .changeset/tame-hens-jump.md's body still described the
  execNpm-routing approach that was tried and reverted -- stale text
  from before that revert, would have shipped a release note for code
  that isn't actually in the diff. Rewritten to describe what actually
  shipped (self-contained spawnSync, 15s timeout, accurate ENOENT vs.
  timeout vs. non-zero-exit diagnosis).
- LOW: no comment explaining why the spawnSync call has no try/catch
  (safe -- its documented contract routes failures through the returned
  result, never a throw -- but worth stating given this file's whole
  purpose is graceful degradation). Added one.
- nit: exitCode 0 + empty stdout fell through to "npm binary not found
  on PATH", misdescribing a real npm binary that simply printed
  nothing. Gave it its own message; updated the corresponding test.

Manually re-verified describeNpmVersionCheckFailure's branches and the
real check:env success path before re-running gsd-test, since this
repo blocks local node --test.

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

* fix: rest of the orthogonal-review fixes (previous commit only caught the deletion)

Tooling mistake in the previous commit: a git add with the already-staged
deleted changeset mixed into the same pathspec list errored out and
silently skipped staging the other four files, so only the changeset
deletion actually committed. This commit carries the rest of that same
change: tame-hens-jump.md's rewritten body, check-env.cjs's no-try/catch
comment, npm-version-check-diagnosis.cjs's exitCode-0-empty-stdout fix,
and the corresponding test update.

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

* docs(#4460): fix changeset pr field to point at this PR, not the original

.changeset/tame-hens-jump.md's pr field still said 4552 (the PR its
original text was authored under), but this PR (#4572) is what's
actually landing the corrected body -- changeset-lint's own
DEFECT.CHANGESET-PR-FIELD-DRIFT check caught it: "pr: 4552, expected
pr: 4572".

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

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 16:26:25 -04:00
Tom Boucher
37b965c0d1 enhance(#4139): Phase 7 — the agent-skill seam picks the payload in code (#4553)
* enhance(#4139): Phase 7 — the agent-skill seam picks the payload in code

ADR-4139 stream 2. The non-Claude `#2454` persona fallback in cmdAgentSkills
(src/init.cts) now selects between a canonical agents/<name>.md and a
token-minimized agents/<name>.compact.md sibling based on
workflow.compact_content, resolved in code (a real function call with a real
exit code) rather than a prose config-get gate — the same precedent stream 1's
spine/detail split established for a load-bearing seam, applied here because
this seam already runs through TypeScript instead of an eager @-include.

A missing compact sibling falls back to the canonical persona and discloses
the fallback in the served payload itself (a leading HTML-comment provenance
line), so the Done-when contract — compact when on, canonical when off, never
silent or empty — holds even for an agent nobody has compacted yet.

Authored a .compact.md sibling for all 35 shipped agents (agents/gsd-*.md),
each an independent, complete rewrite (not an extraction — nothing is "moved"
the way spine/detail moves text) that preserves frontmatter, every @-include,
every output-format contract, and every guardrail verbatim while cutting
restatement and verbose framing. Verified mechanically: every pair registers
(a canonical sibling exists), every compact file is strictly smaller, and the
full @-include set matches canonical's — including which references are
standalone eager-load lines versus inline prose mentions, since demoting one
to inline changes what the host actually substitutes.

Traced the install path before writing any code (.gsd/phase/.../40-design.md):
stageAgentsForRuntimeWithConverter glob-copies every agents/*.md file with no
stem filtering under the default full profile, so the new .compact.md files
install for free with zero installer changes — matching issue #4407's stated
scope. A tiered agent profile that doesn't stage a compact sibling degrades
through the same fallback-with-provenance path already required for an
unauthored one, so no installer change is needed there either.

Extends tests/helpers/compact-content-variant.cjs with an AGENTS_ROOT export
(deliberately not folded into DEFAULT_VARIANT_ROOTS, since agent variants are
reached by a generic code construction rather than a literal path in prose,
and checkReachability's markdown-search shape has nothing to find there).
Reachability is instead proven behaviorally: tests/agent-skills.test.cjs's new
"#4407 compact payload selection" describe block spawns gsd_run agent-skills
against real compact/canonical fixture pairs and asserts on the served
payload, which can only pass if the seam genuinely wires through.

Fixed a pre-existing test whose agents/*.md glob incidentally matched the new
.compact.md siblings (tests/agent-skills.test.cjs's Skill-frontmatter drift
guard) and added the 35 new agents/*.compact.md entries to docs/INVENTORY.md's
roster, both real, unrelated-to-content defects the new files' mere existence
surfaced.

Regenerated: install-tree fixtures (19 runtimes now ship 35 more agent files
under the full profile), INVENTORY-MANIFEST.json, and the variant-swap token
benchmark baseline (npm run benchmark:compact-content-variants --write).

Closes #4407.

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

* fix(#4407): apply orthogonal review findings from the compact-payload seam

Standards axis of /code-review: extracted readNonEmptyFileOrNull(filePath)
to collapse the duplicated read-and-empty-check shape between the compact
and canonical branches in cmdAgentSkills, and updated the adjacent comment
enumerating flat JSON extras to name agent_payload_variant alongside
source/degraded (added by the prior commit, comment left stale).

Security review and the Spec axis found no defects requiring a code change;
their non-blocking observations (a pre-existing, unmodified path-construction
pattern; the reasoned, documented substitution of a behavioral test for the
literal reachability check) are recorded in
.gsd/phase/enhance-4407-agent-skill-seam/60-review.json.

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

* fix(#4407): repo-wide roster/cap fixes surfaced by shipping .compact.md agents

Root-caused via a real gsd-test run (93 failures) rather than guessing which
tests glob agents/ naively. Two classes of defect, both genuine:

1. Identity-roster confusion (11 files/areas): many tests and one production
   script derive "the set of GSD agents" from `readdirSync(agentsDir).filter(f
   => f.endsWith('.md'))`, which incidentally matched the new .compact.md
   variant siblings too — a compact file is a rendering of an EXISTING agent
   identity, not a new one. Fixed at the shared root
   (tests/helpers/agent-roster.cjs's listAgentFiles, which several tests
   already consolidated on) and at each independent glob that didn't use it:
   agent-size-budget.test.cjs (tier-cap lookup now strips the .compact suffix
   before checking XL/LARGE membership, so a compact file inherits its
   canonical sibling's tier instead of silently falling through to DEFAULT),
   agent-skills-bootstrap.test.cjs, check-contract-drift.test.cjs (the actual
   script, not just its test), codex-config.test.cjs (confirmed directly
   against generateCodexAgentToml that a compact role's derived sandbox_mode
   is byte-identical to its canonical sibling's before excluding it — not
   assumed), and copilot-install.test.cjs (two counts that legitimately DO
   need both files — an installed-file count and a full-conversion smoke test
   — fixed to expect 70, not stay pinned to 35).

   no-bare-gsd-tools-command-position.test.cjs needed the opposite kind of fix:
   two compact files reproduce descriptive prose already allowlisted at their
   canonical file's line number; added matching entries at the compact files'
   own line numbers rather than excluding them from the scan (a genuine bare
   gsd-tools command-position bug in a compact file would be as real a defect
   as in canonical).

2. A hard, non-ackable cap (found via emitted-attribution.test.cjs's real-tree
   run): six agents' compact renditions (gsd-debugger, gsd-executor,
   gsd-phase-researcher, gsd-plan-checker, gsd-planner, gsd-verifier) exceed
   the 32,768-byte NEW_FILE_CAP (ADR-1610) even after aggressive compaction —
   confirmed structural, not a compaction-quality gap: each is dominated by
   content this phase's own rules require verbatim (the ~2.6 KB gsd_run
   bootstrap preamble runtime-launcher-parity.test.cjs requires inlined in
   every agent that calls gsd_run, output-format contracts, guardrails).
   ADR-4139's prescribed remedy (spine + lazily-read parts) has no landing
   spot in cmdAgentSkills's single-file synchronous read. Removed these 6
   compact files rather than ship an over-cap file or invent a multi-part
   read mechanism out of scope for this phase; recorded by name with the
   reason in .gsd/phase/enhance-4407-agent-skill-seam/40-design.md and
   50-test-matrix.md, per #4407's own "or explicitly recorded as not worth
   covering" allowance. Their canonical personas are served correctly today
   via the fallback-with-disclosed-provenance path this phase's own Done-when
   #2 already requires — 29 of 35 agents now have a compact variant.

Also fixes an unrelated, genuinely pre-existing defect this gsd-test run
surfaced: gsd-core/workflows/execute-plan.md sat 21 bytes over its own
DEFAULT-tier hard cap (40,960 bytes) at the branch point, before any change in
this PR touched it — confirmed via `git show <merge-base>:...execute-plan.md
| wc -c`. Per CLAUDE.md's no-deferral rule, fixed inline rather than filed:
two meaning-preserving trims in the <success_criteria> block (a repeated
parenthetical replaced with a same-exception reference; one redundant
qualifier dropped) bring it to 40,940 bytes.

Regenerated install-tree fixtures, INVENTORY-MANIFEST.json, and the variant
benchmark baseline to reflect the 6 removed files. Docs/INVENTORY.md's 6
now-orphaned roster rows removed alongside them.

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

* fix(#4407): make .compact.md-aware roster checks resilient to partial coverage

Round 2 of the gsd-test-driven roster fixes: two checks assumed every agent
has a compact sibling (true for 29 of 35 after the NEW_FILE_CAP exception),
breaking once 6 stems legitimately have none.

- tests/agent-classification-parity.test.cjs: the INVENTORY.md parser was
  picking up the "### Compact Payload Variants" subsection's rows as
  phantom/uncounted entries in the primary/advanced/inventory-only
  classification this test validates — a compact row documents an existing
  agent's alternate rendition and never gets its own AGENTS.md heading, so it
  was never meant to participate in that classification. Excluded at the
  parser, not per-assertion.
- tests/copilot-install.test.cjs: the derived expected-file-list generator
  assumed every listAgentFiles() stem has a .compact.md source sibling;
  checks disk per stem now instead.

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

* docs(#4407): backfill changeset PR number

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

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 12:38:59 -04:00
Tom Boucher
6bd5c22f68 ci: auto-refresh vendored js-yaml/re2js on Dependabot PRs (#4573) (#4576)
lint-vendored-deps.cjs gates gsd-core/bin/lib/vendor/{js-yaml.cjs,re2js.cjs}
for byte-freshness against node_modules and requires package.json's pin to
literally match the installed version. Dependabot regularly opens
lockfile-only PRs bumping these packages within the existing semver range,
which it can never satisfy (it has no awareness of the vendor copy or the
pin), so every such PR sits permanently red until a human manually runs the
refresh and pushes a fixup commit. PR #4565 was the latest instance.

Adds `--fix` to lint-vendored-deps.cjs (fixRow): mechanically re-copies the
upstream build artifact over the vendored .cjs/.d.cts twins and bumps the
manifest pin, preserving its range-operator style. It never touches a
hand-authored twin's declared type surface (js-yaml.d.cts) — a remaining
finding there means a real upstream API break, and --fix leaves it failing
rather than mask it.

Adds .github/workflows/dependabot-vendor-refresh.yml: on a same-repo
dependabot[bot] PR touching package.json/package-lock.json (same
defense-in-depth identity check as dependabot-auto-merge.yml), runs
`--fix` and, only on a clean result, commits and pushes the refresh back
to the PR branch via GSD_BOT_PR_TOKEN (falling back to GITHUB_TOKEN, same
pattern as auto-backmerge.yml) so the push re-triggers `synchronize` and
the real lint-vendored-deps check in test.yml genuinely re-passes. A
non-clean --fix result (real incompatibility) makes no commit, leaving
the actual failure visible for a human — this fixes the check's own
complaint, it does not bypass or weaken the check.

Closes #4573

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 11:46:12 -04:00
sim
8731a90be6 fix: revert execNpm import in check-env.cjs, keep the diagnosis improvement
The execNpm-routing redesign (previous commit) broke every real CI job:
check-env.cjs runs as its own standalone "Environment check" step BEFORE
`npm ci` / `npm run build:lib` -- a deliberate pre-flight, run before
there is even a node_modules to build with. Its require of
../gsd-core/bin/lib/shell-command-projection.cjs (a tsc-compiled artifact
that plain does not exist at that point in the pipeline) crashed with
MODULE_NOT_FOUND on every platform, immediately, confirmed via the real
CI log. My own local gsd-test run never caught this because it doesn't
replicate that exact pre-build step ordering.

Reverted the cross-module require entirely; check-env.cjs is back to a
self-contained spawnSync(npmCmd, ...) call, no requires reaching into
gsd-core/bin/lib. Kept the two things actually worth keeping from that
detour:
  - the 15_000ms timeout (matches execNpm's own default elsewhere in this
    repo -- not invented, an existing precedent -- vs. the original 10s
    that failed twice under real Windows CI contention);
  - computing `timedOut` via `error.code === 'ETIMEDOUT'` inline (the
    same canonical, cross-platform-correct predicate that seam uses),
    rather than the earlier signal === 'SIGTERM' check, which that seam's
    own docstring documents as platform-fragile with a Windows-specific
    false-negative risk.

Manually verified check-env.cjs runs correctly with gsd-core/bin/lib
temporarily removed entirely (simulating the real pre-npm-ci CI
ordering) before re-running gsd-test, since this repo blocks local
node --test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 08:07:34 -04:00
sim
1cd17cc3d6 fix: route check-env.cjs's npm-version check through the canonical execNpm seam
Per /research + /diagnose direction: the timeout fix landed earlier this
session correctly diagnosed the failure (a real spawnSync timeout under
Windows CI contention, not npm being absent) but it recurred on the very
next push -- same chunk, same ~51-file load. Rather than raise the
hand-rolled 10s timeout myself (CLAUDE.md's own rule: fix the cost, not
the tolerance, and never touch a timeout without explicit instruction),
investigated the repo's own precedent first.

Found: this repo already has a canonical OS-shell-projection seam for
exactly this (src/shell-command-projection.cts's execNpm), already used
by dozens of other scripts/*.cjs files (require('../gsd-core/bin/lib/...')
is an extremely well-established pattern), with:
  - the same npm.cmd/shell:true Windows handling check-env.cjs was
    hand-rolling, but centralized;
  - a 15s default timeout (vs. check-env.cjs's 10s) -- not invented here,
    an EXISTING value already governing npm subprocess calls elsewhere;
  - isSpawnTimeout / result.timedOut, the canonical cross-platform timeout
    predicate (error.code === 'ETIMEDOUT'), whose own docstring explicitly
    warns that checking signal === 'SIGTERM' (what my first fix did) is
    "platform-fragile" with a Windows-specific false-negative risk -- the
    exact platform this bug lives on.

check-env.cjs's npm-version check now calls execNpm(['--version']) instead
of hand-rolling spawnSync + npmCmd + shell:true, and
describeNpmVersionCheckFailure now operates on execNpm's SpawnResultOutput
shape (using timedOut, not signal) rather than a raw spawnSync result.
This is a genuine architectural fix, not just a bigger number: it removes
a duplicate, slightly-divergent re-implementation of an existing seam and
inherits whatever that seam's timeout/handling becomes in the future.

Manually verified end-to-end (npm run check:env against the real
environment) and re-verified describeNpmVersionCheckFailure's branches
directly against execNpm's actual return shape before wiring the test
file, since this repo blocks local node --test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 08:07:34 -04:00
sim
0aeafc6425 fix: report the real cause when check-env.cjs's npm-version check fails
Discovered blocking this PR's own Windows CI (unrelated to this PR's
actual diff, fixed inline per this repo's no-defer policy): PR #4552's
"full test (windows-latest, 24, shard 2/3)" job failed
tests/check-env.test.cjs's npm-version subtest with "npm binary not
found on PATH" under chunk 5/9's heavy load (51 concurrent files,
5+ minutes). Root-caused via the CI log: the check's spawnSync call
used a 10s timeout, and every failure mode -- ENOENT, a signal-killed
timeout, a non-zero exit, a thrown spawn error -- collapsed into that
one message (only `res.status === 0 && res.stdout` was checked), so a
genuine npm.cmd cold-start timeout under contention was indistinguishable
from npm actually being absent.

Extracted the reason-selection into describeNpmVersionCheckFailure, a
pure function in the new scripts/lib/npm-version-check-diagnosis.cjs
(kept out of check-env.cjs itself, which runs its CLI unconditionally
on require with no `require.main === module` guard, so the pure logic
can be unit-tested without triggering a real environment check).
Reports ENOENT, signal-kill, non-zero-exit, and thrown-error cases
distinctly. Does NOT raise the 10s timeout itself -- a slow subprocess
under contention is a cost to reduce, not a tolerance to widen.

Also fixed a stale tsconfig.build.tsbuildinfo incremental-build cache
discovered while verifying this change: npm run build:lib was silently
omitting gsd-core/bin/lib/markdown-table.cjs (a real, needed compiled
module -- src/state-document.cts requires it), which only surfaced via
npm run lint:generated-sync's gen-health-docs check failing with
Cannot find module. Deleting the cache and rebuilding fresh restored
it; docs/INVENTORY-MANIFEST.json needed no net change once the build
was genuinely complete.

Manually verified describeNpmVersionCheckFailure's five branches
directly (ENOENT, signal-kill, non-zero exit, thrown error, defensive
default) before wiring the test file, since this repo blocks local
node --test. Re-ran node scripts/check-env.cjs directly to confirm the
real success path is unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 08:07:34 -04:00
Tom Boucher
a27cb6b2fa enhance(#4139): Phase 6 — the lazily-read remainder and the artifact templates (#4540)
* enhance(#4406): the lazily-read remainder and the artifact templates

ADR-4139 Decision 3, Phase 6 of the #4139 Compact Content epic. Covers stream 1b
(gsd-core/workflows/<name>/{modes,steps,templates}/*.md) and stream 4
(gsd-core/templates/**) with a variant-swap mechanism, confirmed with the user:
two independent, complete files per covered path (canonical + .compact.md
sibling), with the gate picking which one gets Read at the call site. This is
a different shape from Phase 5's spine+detail partition, and is safe here
specifically because these files are already reached only by a runtime Read —
a missed Read already means zero overlay content today, with or without
workflow.compact_content, so selecting between two independently-complete
files introduces no new failure mode (documented in
gsd-core/references/compact-content-gate.md's new "Streams 1b and 4" section).

Disposition, after inspecting every candidate rather than trusting a byte-size
threshold (same rigor Phase 5 applied to review.md):

- Stream 1b: 1 of 78 files compacted (help/modes/full.md, a user-facing
  reference doc emitted verbatim, not orchestrator instruction). The other 9
  size-threshold candidates are dominated by fail-closed guards, exact CLI
  invocations, or output-format contracts (AskUserQuestion blocks) — recorded
  not-worth-compacting, same reasoning as Phase 5's review.md.
- Stream 4: a ground-truth reachability audit replaced the initial size-only
  candidate list. Two files (summary.md, user-setup.md) got compact variants;
  a third (spec.md) was drafted, then dropped after discovering its only two
  call sites are eager @-includes, not a runtime Read — stream-1 material
  hiding under gsd-core/templates/, not stream-4's actual mechanism. summary.md
  itself has 3 eager call sites and only 1 genuine runtime-Read call site
  (execute-plan.md); only that one was wired, so the compact variant's savings
  apply to the sequential single-plan execution path only.
- Discovered while auditing reachability: 12 gsd-core/templates/** files with
  zero references anywhere in workflow/agent/command prose, compiled source,
  or tests — dead scaffolding predating this phase. Deleted in this same PR
  per this repo's no-defer policy, after re-verifying against a computed
  path.join(...) pattern (not just a plain-string search) that nearly caused
  two genuinely load-bearing templates (user-profile.md, dev-preferences.md)
  to be misclassified as dead.

New checker (tests/helpers/compact-content-variant.cjs): registration,
reachability, protected-content-preserved, size-smaller — replacing Phase
3/5's disjointness/completeness checks, which assume a partition rather than
two deliberately-overlapping documents. The reachability check's own
"unprefixed match" guard had a real bug (rejected the repo's own
`~/.claude/gsd-core/...` convention), caught by running it against the
already-wired help/modes/full.compact.md pair rather than only synthetic
fixtures — fixed to anchor on the nearest `gsd-core` path segment instead.

Template consumer parity (tests/compact-content-template-variant-parity.test.cjs):
proves each compact variant's `## File Template` fenced block — the actual
output-format contract a generated SUMMARY.md/USER-SETUP.md is parsed
against — is byte-identical to the canonical file, then runs the one real
deterministic consumer (gsd-core/bin/lib/coverage.cjs's classifyContent,
backing `gsd-tools uat classify-coverage`) against content built from that
shared contract.

Added a sibling benchmark script (scripts/benchmark-compact-content-variants.cjs)
rather than extending the existing spine/detail one — different data shape,
and the existing script's own contract deliberately isolates it from a
test-only helper's shape changing.

Emitted-drift acknowledgement: not needed. Every changed/added path in this
diff is hand-authored and present in the diff itself, so diffEmitted's
attribution loop resolves `via` to the path's own source before reaching the
ack-lookup branch (same reasoning Phase 5 verified for its own diff).

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

* enhance(#4406): address code-review findings on the variant-swap gate

- docs/CONFIGURATION.md and gsd-core/references/planning-config.md's
  workflow.compact_content entries described only the spine+detail mechanism
  (Phase 5) and were missing this phase's variant-swap mechanism and its
  benchmark:compact-content-variants script entirely — required since this
  PR's changeset is type Added (CLAUDE.md's "Missing Docs for Changesets"
  rule). Both now describe both mechanisms and which call sites are wired.
- Added the missing RED^-1/no-op fixture for checkProtectedContentPreserved:
  a canonical file with zero <!-- gsd:protected --> blocks must be a
  no-op, not a violation — the only branch of that function the existing
  fixtures didn't exercise.
- Collapsed findCompactFiles/findMarkdownFiles in
  tests/helpers/compact-content-variant.cjs into one findFilesWithSuffix
  helper — the two were identical recursive walks differing only in the
  extension predicate (minor Duplicated-Code finding).

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

* fix(#4406): restore copilot-instructions.md, a false-positive dead-template classification

gsd-test caught this, not static analysis: 10 real failures in
tests/copilot-install.test.cjs, tests/installer-migration-install.integration.test.cjs,
and tests/repo-layout.test.cjs — all downstream of bin/install.js's Copilot install
path, which does
fs.readFileSync(path.join(targetDir, 'gsd-core', 'templates', 'copilot-instructions.md'))
after copying gsd-core/templates/** into the target project, then merges it into both
.github/copilot-instructions.md and (local installs) AGENTS.md. The reachability audit
that flagged this file as dead checked src/*.cts and gsd-core/bin/*.cjs but never the
repo-root bin/install.js — a separately maintained installer bundle outside the
src/-to-gsd-core/bin/lib/ compiled-output convention. The fs.existsSync guard around
that read degrades to a silent skip rather than a crash when the template is missing,
which is why this surfaced only once the real E2E install test ran, not from any
static check.

Re-verified the remaining 11 deleted filenames against bin/install.js specifically
(plain substring and quoted-filename search) before trusting that list — all 11 have
zero hits there, confirmed dead by the same standard this one file failed.

Regenerated the installer emitted-tree goldens (tests/fixtures/install-tree/*.json) to
reflect the restored file, and corrected the "Removed" changeset (jolly-lynx-sprint.md)
and the phase design doc from 12 to 11 deleted files.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Emitted-Drift-Ack-Growth: execute-plan.md — call-site wiring for the summary.md and user-setup.md .compact.md variants
Emitted-Drift-Ack-Growth: help.md — call-site wiring for full.compact.md, same variant-resolution rule

* docs(#4406): backfill changeset PR numbers

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

* fix(#4406): resolve removed-but-needed lint findings on the dead-template deletion

CI's own full-test matrix (not gsd-test's matrix, which does not run this
check) caught 4 more false-positive dead-template classifications via
tests/removed-but-needed-lint.test.cjs / scripts/lint-removed-but-needed.cjs
— a literal, word-boundary basename check across .github/workflows/,
gsd-core/, and docs/ (excluding docs/adr/** and docs/research/**) for every
file a PR deletes. It has no semantic awareness, so a deleted template's
basename colliding with something else entirely still fires:

- claude-md.md: gsd-core/templates/README.md had a stale table row claiming
  /gsd-profile reads this template to generate CLAUDE.md. Verified false (no
  code reads it anywhere, same search that already covered bin/install.js) —
  fixed the row to *(inline)*, matching every other command-generated
  artifact in that table. File stays deleted.
- codebase/testing.md: collided with docs/guides/testing.md, an illustrative
  example row in docs-update.md's sample output table (an unrelated real
  generated-docs path). Swapped the example topic to "contributing" — the
  row is illustrative, any topic works. File stays deleted.
- codebase/architecture.md, codebase/stack.md: collided with docs/reference/
  planning-artifacts.md's directory listing of a user's own generated
  .planning/codebase/architecture.md and stack.md output — the same
  semantic mismatch already investigated and dismissed as unrelated earlier
  in this phase's audit, now caught by a gate instead of judgment. That
  listing repeats across 5 locale copies of the doc.
- continue-here.md: collided with the real .continue-here.md pause-work
  artifact, referenced across 15+ locale and workflow files.

For the last two, the lint's own error message offers "restore the file or
update every consumer in the same commit." Rewording 15+ files across
languages I cannot verify translation quality for, to shave 2 already-tiny
templates that were merely presumed dead, is disproportionate to this PR's
actual scope — restored codebase/architecture.md, codebase/stack.md, and
continue-here.md instead, and corrected docs/ARCHITECTURE.md's Templates
section accordingly.

Final confirmed-dead set: claude-md.md, codebase/concerns.md,
codebase/conventions.md, codebase/integrations.md, codebase/structure.md,
codebase/testing.md, debug-subagent-prompt.md, discovery.md — 8 files, down
from the original 12. Verified locally: GSD_REMOVED_BUT_NEEDED_BASE=next
node scripts/lint-removed-but-needed.cjs now passes clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Emitted-Drift-Ack-Growth: docs-update.md — swapped an illustrative example-table topic (testing -> contributing) to avoid a removed-but-needed basename collision with the deleted codebase/testing.md template; net +10 bytes

* fix(#4406): split codex-config.test.cjs to fix a genuine Windows CI timeout

Root cause of the `full test (windows-latest, 24, shard 2/3)` failure the
user asked to be actually fixed, not just re-run past: PR #4497 (landed
2026-09-07, one day before this PR's CI run) isolated
tests/codex-config.test.cjs into its own dedicated chunk because its
measured weight (17.87, ~45% of the post-cut Windows budget) made it unsafe
to share a chunk with any other file. That isolation was necessary but not
sufficient — even alone, with zero companion-file contention, the file's
real Windows execution time sits right at the 600s per-chunk ceiling. Two
independent CI runs on two unrelated PRs (this one and #4154) were both
killed within ~1.4s of the identical 600000ms mark — not random contention,
a deterministic near-miss the isolation fix couldn't address because it
never reduced the file's own cost, only removed the risk of a companion
file's cost stacking on top of it (which the PR #4497 comment explicitly
anticipated: "if a future profiling pass genuinely speeds up
codex-config.test.cjs itself, this isolation can be revisited").

The file itself explains why it's this heavy: 11,262 lines / 433 tests / 79
describe blocks, accumulated over dozens of bug-fix PRs (#2695, #2760,
#3245, #3285, #3346, #3426, #3427, #3562, #3566, #3582, #3808, and more),
several of which are explicitly documented as "folded" in from separate
files that were never actually split back out ("Verified non-duplicate
against both the pre-existing target and the other three folded sources").

Split into 4 files by top-level AST statement boundaries (never a naive
column-0 regex — an early attempt at that overcounted 79 apparent
"describe(" matches when only 21 are genuinely top-level; the rest are
nested inside a handful of large folded-in blocks, which a regex can't tell
apart from real top-level statements). Verified lossless twice: the split
script asserts byte-for-byte reconstruction of every source character, and
independently, total test()/describe() call counts match exactly between
the original file and the sum across all 4 new files (433/79 both sides).
Each new file carries the complete original shared header (imports/helpers)
for safety; per-file unused-import warnings from that duplication are
resolved via ESLint-precise alias renames (`{ foo: _foo }`, the standard
form for an intentionally-unused destructured binding — never a bare `{
_foo }`, which would destructure a different, nonexistent property).

No change needed to scripts/run-tests.cjs's ISOLATED_HEAVY_FILES or its
pinned test in tests/run-tests-harness.test.cjs: the file that keeps the
original name (tests/codex-config.test.cjs) is now only ~28% of the
original's size and safely isolated in its own chunk as before; the other
three new files re-enter normal weight-balanced packing, none individually
close to disproportionate. Confirmed no other file hardcodes the hardcoded
filename anywhere that would silently stop these tests from running (the
CI test-selection scripts determine scope algorithmically, not by literal
filename).

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

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 14:31:04 -04:00
Tom Boucher
147c89a9b8 fix(#4456): forward --ws to every downstream new-milestone.md call (#4545)
* fix(#4456): forward --ws to every downstream new-milestone.md call

new-milestone.md's Step 1 parses --ws <name> into GSD_WS, but each
workflow step's bash fence is a separate shell invocation — GSD_WS set in
Step 1 never survived to Steps 5, 6, or 7. Four call sites never
forwarded it: init.new-milestone (both calls), state.milestone-switch,
and both phases.clear branches. Under GSD_WORKSTREAM env or a stored
session pointer differing from the explicitly requested --ws, every
downstream operation silently operated on the wrong workstream (or root)
instead of the one the caller asked for.

Confirmed --ws is a universally-parsed CLI flag (gsd-core/bin/gsd-tools.cjs:
4867, resolveActiveWorkstream) — stripped from argv and written into
process.env.GSD_WORKSTREAM for the rest of that process, so appending it to
ANY gsd_run query call works uniformly. Fixed by persisting GSD_WS to
.planning/.gsd-ws-arg right after Step 1 parses it (mirroring the
established .gsd-outgoing-milestone round-trip idiom this same file
already uses for the identical cross-fence problem), reading it back in
each later step, and appending it unquoted (matching the ${GSD_WS}
splicing convention documented in workstream-flag.md). Cleaned up after
its last use in Step 7.

Bundled, in-scope fixes found while implementing the above (per this
repo's no-defer policy):

- Step 6's phase-archive `git add .planning/milestones/ .planning/phases/`
  hardcoded literal ROOT paths — both directories are workstream-scoped
  (matching cmdMilestoneComplete's established #1911 precedent), so under
  a workstream this staged nothing real. Added phases_dir/archive_dir
  fields to cmdInitNewMilestone and resolved through them instead.
- Step 6's milestone-start commit hardcoded .planning/STATE.md — also
  workstream-scoped, so it would commit the wrong (or a stale) file under
  a workstream. Resolved through init.new-milestone's existing state_path
  field instead; PROJECT.md correctly stays a literal-shaped-but-resolved
  root path (shared, per the #4455 follow-up already merged).
- cmdInitNewMilestone's config_path field: config.json is ALSO a shared
  file (marked `# Shared` in workstream-flag.md's directory diagram, same
  as PROJECT.md) but was resolved via the workstream-aware planningDir —
  fixed alongside cmdInitNewProject's identical instance of the same bug
  (found via grep, matching the precedent from the #4455 follow-up of
  fixing every occurrence of an identically-evidenced bug uniformly).

Verified: direct CLI invocation confirms phases_dir/archive_dir/state_path
resolve into the workstream while project_path/config_path stay root under
GSD_WORKSTREAM=alpha. Manual bash-fence execution of every modified fence
(Step 1 parse+persist, Step 5 forwarding, both phases.clear branches, the
git add fence, Step 7's forward+cleanup, the commit fence) confirms correct
behavior in both flat and --ws modes, including two flags composing
together (--archive-version + --ws; --reset-phase-numbers + --ws).

Rewrote the pre-existing "step 6: commit stages PROJECT.md" test, which
asserted the literal (buggy) --files string verbatim — it now asserts the
resolved paths via a JSON-returning stub, and gained isolated per-test tmp
dirs (the prior version ran with no explicit cwd, at real risk of writing
a stray .gsd-ws-arg into this repo's own .planning/ once Step 1's fence
started performing a real write).

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

* fix(#4456): Steps 9/10 also commit workstream-scoped files via literal root paths

A fresh isolated code-review pass on the first version of this fix found
the identical bug in two more places, missed in the initial sweep:

- Step 9's requirements commit (`gsd_run query commit ... --files
  .planning/REQUIREMENTS.md`) and Step 10's roadmap commit (`--files
  .planning/ROADMAP.md .planning/STATE.md .planning/REQUIREMENTS.md`)
  both hardcoded literal ROOT paths for files that are workstream-scoped.
- Worse: `.planning/.gsd-ws-arg` was being deleted at the end of Step 7,
  but Steps 9 and 10 run AFTER Step 7 and still needed to re-read it —
  the round-trip mechanism this fix builds was already gone before its
  two remaining consumers ran.

Fixed by moving the `.gsd-ws-arg` cleanup to Step 10 (its true last
consumer, after the roadmap commit) and adding the same
fetch-then-_gsd_field-extract pattern already used in Step 6 to Steps 9
and 10, resolving `requirements_path`/`roadmap_path`/`state_path` through
`init.new-milestone $GSD_WS_ARG` instead of literal paths.

Also fixed (MEDIUM, same review pass): Step 1's `.gsd-ws-arg` write had
no `2>/dev/null || true`, unlike every other round-trip write in this
same file — brought into line with the established idiom.

Verified: reproduced the pre-fix bug directly (Step 9/10 fences echoing
the literal root paths regardless of --ws), confirmed both fences now
resolve the workstream-scoped paths correctly, and confirmed the
round-trip file survives Step 7 and is only removed after Step 10.
Updated the Step 7 test that previously asserted premature cleanup
(inverted to assert the file survives); added new coverage for Steps 9
and 10 in both flat and --ws modes.

Two remaining LOW/pre-existing findings from the same review pass,
deliberately left as-is: `phase_archive_path` (src/init.cts, untouched by
this diff) resolves via the same root-only `getLatestCompletedMilestone`
this fix's earlier commit already declined to touch, for the same
genuine-product-intent-ambiguity reason (workstream-scoped vs
project-pooled "latest completed milestone" is not resolvable from the
code alone). `.planning/research/` staying root-scoped in the #222
self-heal prose is consistent with the existing (unchanged) `research_dir`
field, not a new inconsistency.

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

* fix(#4456): revert wrong config_path change; fix isolated-cwd test env

gsd-test caught two real regressions from this fix's earlier commits:

1. config_path is NOT shared like PROJECT.md. The prior commit's
   grep-and-replace ("fix six more functions with the identical bug")
   also touched cmdInitExecutePhase's config_path (a fourth call site
   beyond the two I'd manually checked) — but tests/init.test.cjs's
   pre-existing, ADR-0006-governed "init handlers honor GSD_WORKSTREAM"
   coverage explicitly asserts config_path IS workstream-scoped for
   execute-phase/plan-phase/phase-op/milestone-op. workstream-flag.md's
   "# Shared" marking for config.json is stale (the same class of
   staleness already found for milestones/ during the #4455 follow-up);
   ADR-0006 plus its real, passing tests is the authoritative source.
   Reverted config_path to the plain workstream-aware planningDir(cwd)
   in all four functions it was wrongly changed in.

2. Isolating cwd to a tmpDir (needed once Step 1's fence started
   performing a real .gsd-ws-arg write) broke the runtime-launcher
   preamble's own gsd-tools.cjs discovery — no git repo at an isolated
   tmpDir, no global gsd_run on the CI bench's PATH. Fixed by passing
   RUNTIME_DIR explicitly in every isolated-cwd test's env, matching
   the preamble's own documented override precedence.

Verified: direct CLI invocation confirms execute-phase's config_path is
workstream-scoped again under GSD_WORKSTREAM=wsx; the RUNTIME_DIR fix
confirmed against a stripped PATH (no global gsd_run), matching the
bench condition that surfaced the original failure.

Emitted-Drift-Ack-Growth: new-milestone.md — #4456 forwards --ws to every downstream gsd_run call across 7 fences (Steps 1/5/6x3/7/9/10), adding a persisted round-trip file plus resolved-path fetches that replace several hardcoded literal paths
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(#4456): backfill changeset PR number and correct final scope

pr: 0 -> pr: 4545, and removed the changeset's claim that config.json
is a shared file -- that was the change this same PR later reverted
after gsd-test caught it contradicting ADR-0006's established,
workstream-scoped config_path contract.

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

* fix(#4456): baseline the 10 new SC2086 findings from --ws forwarding

The lint-tests CI job failed with a hard exit 1. Diagnosis (not assumed):
the log's two `fatal: ambiguous argument 'origin/next...HEAD'` git errors
(lines 244/248) are a red herring — both belong to
lint-removed-but-needed.cjs, which prints its own "could not resolve
origin/next, skipping" message and exits gracefully, exactly like the
already-handled two-dot-form error from lint-fix-has-regression-tests
earlier in the same log. Neither contributes to the actual failure.

The real cause is lint-workflow-shellcheck: this fix's new fences append
$GSD_WS_ARG unquoted to gsd_run calls (deliberately, so it splits into 0
or 2 argv tokens — the same idiom gsd-core/workflows/verify-work.md
already uses for ${GSD_WS} and already has baselined). ShellCheck
correctly flags each as SC2086, and lint-workflow-shellcheck.cjs's
baseline is a deliberate ratchet (#4109) requiring new findings to be
explicitly accepted, not auto-passed. new-milestone.md previously had
zero baselined SC2086 findings, so all 10 new (correct, intentional)
occurrences were reported as new and failed the gate.

Added 10 {file, code, message} entries to
scripts/lint-workflow-shellcheck-baseline.json for
gsd-core/workflows/new-milestone.md's SC2086 findings, matching the
established, already-accepted precedent for the identical pattern in
verify-work.md. No source or workflow file changed.

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

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 09:23:20 -04:00
Tom Boucher
e03921c7d8 enhance(#4405): split the rest of the eager-window workflows worth splitting (#4536) 2026-09-08 00:17:22 -04:00
Dennis Alexis Valin Dittrich
18c899def5 enhance(#4209): optional external source reviewer lanes for /gsd:code-review (#4323)
* test(01-01): define reviewer-support trait contract

Add failing coverage for step.supportsReviewerLanes (#4209 DISP-02):
validator rejects non-boolean values with an exact field path, accepts
missing/true/false, and the real code-review capability.json steps
must declare supportsReviewerLanes: true. Add loop-resolver projection
coverage proving the trait reaches activeHooks verbatim for a
provider-neutral synthetic step (not code-review-specific), and that
omitted/false values stay inert (no key on the active hook).

All 8 new assertions fail today: the validator has no such field, and
loop-resolver has nothing to project. RED before GREEN.

* feat(01-01): declare reviewer-capable steps

Add step.supportsReviewerLanes (#4209 DISP-02): a strict optional
boolean opt-in trait, step-scoped (not capability-wide). Only a
literal true validates and projects; false/omitted stay inert (no
key on the projected active hook), and every non-boolean type fails
capability-validator.cjs with an exact field-path error.

Opt both existing code-review steps (execute:post, execute:wave:post)
into the trait in capabilities/code-review/capability.json. Project
the validated field through src/loop-resolver.cts into activeHooks
so a provider-neutral generic interpreter can read it without any
code-review-specific knowledge. Document the field in
docs/reference/capability-manifest.md and regenerate
gsd-core/bin/lib/capability-registry.cjs via the generator (never
hand-edited).

Makes all 8 RED assertions from the prior commit pass.

* test(01-02): define shared reviewer dispatch

- Add tests/reviewer-step-dispatch.test.cjs covering dispatchReviewerLanes:
  inert when the supportsReviewerLanes trait is off or nothing is selected,
  exactly-once plan/invoke per selected lane, duplicate-alias dedup, the
  bounded metadata-only source-review prompt (repo root, paths+baseSha,
  depth, four fixed prohibitions), and capability-neutral reuse via a
  second synthetic step context.
- RED: module under test (src/reviewer-step-dispatch.cts) does not exist
  yet, so require() fails and every assertion is unreached.

* feat(01-02): dispatch reviewers for opted-in steps

- Add src/reviewer-step-dispatch.cts: dispatchReviewerLanes(input, deps),
  ONE interpreter for a step's supportsReviewerLanes trait. Reuses
  resolveReviewerSelection for selection and resolveLanePlan for planning
  (both already-existing, pure building blocks); invocation is the one
  required, caller-injected seam (deps.invoke) since runLane needs
  OS-aware spawn plumbing this module does not own.
- trait !== true, or a selection resolving to zero lanes, dispatches
  nothing (zero plan/invoke calls). Each selected lane is planned and
  invoked exactly once, in the selector's deduped/sorted order.
- buildSourceReviewPrompt assembles a metadata-only bounded prompt
  (repo root, canonical paths + base SHA, depth, four fixed
  prohibitions) — never file contents — written once per dispatch and
  shared across every invoked lane.
- GREEN: tests/reviewer-step-dispatch.test.cjs now passes.

* test(01-02): define reviewer dispatch failures

- Extend tests/reviewer-step-dispatch.test.cjs with the fail-closed
  matrix: an explicitly requested lane the selector could not resolve
  still lets the OTHER resolved lane run, but the aggregate result must
  never read as a clean success (and 'every explicit lane unavailable'
  must be distinguishable from the plain no-flags-passed inert case);
  request-level validation (path traversal, absolute paths outside
  repoRoot, empty/non-string paths, missing depth/base SHA) halts the
  whole dispatch before any lane is planned or invoked; a per-lane
  prompt-budget overflow hard-fails only that lane before invoke while
  its sibling still runs.
- RED: src/reviewer-step-dispatch.cts does not yet implement any of
  these guards, so 9 of the new assertions fail against the current
  (Task 1) implementation.

* fix(01-02): fail closed in reviewer dispatch

- src/reviewer-step-dispatch.cts: add the fail-closed guards the prior
  commit deliberately left out. An explicitly requested lane the
  selector could not resolve no longer lets the aggregate read as a
  clean success — lanes that DID resolve still run and keep their
  results (never narrow the requested set), but selection.errors now
  flips the aggregate ok to false, and 'every explicit lane
  unavailable' is now distinguishable (SELECTION_FAILED) from the
  plain no-flags-passed inert case (NO_LANES_SELECTED).
- Add request-level validation (validatePaths, depth/baseSha presence)
  that halts the WHOLE dispatch before any lane is planned or invoked:
  path traversal, absolute paths outside repoRoot, empty/non-string
  paths, and missing provenance are all rejected up front.
- Add per-lane prompt-budget enforcement (resolveBudget, mirroring
  gsd-tools.cjs's budgetFor convention including budget 0 = unbounded):
  a lane whose resolved budget the prompt exceeds hard-fails before
  invoke runs for it, without cancelling a sibling lane already
  planned.
- Document the supportsReviewerLanes trait and its dispatch-step
  interpreter in gsd-core/references/loop-hook-dispatch.md.
- GREEN: all 19 tests in tests/reviewer-step-dispatch.test.cjs pass;
  no regressions in the review-lane/reviewer-selection/prompt-budget
  suites (356 passing).

* test(01-03): define optional source reviewer flow

RED: assert code-review.md dispatches roster-derived reviewer-lane flags
through a single review-lane dispatch-step call (DISP-01..05), that the
no-flag path stays byte-for-behavior unchanged (COMP-01), and that
external evidence reaching the internal reviewer prompt is marked
unverified (CONS-02). Also covers the CLI contract directly: no-op with
no explicit selection, and fail-closed on an explicit unknown lane
(SAFE-07) via real gsd-tools.cjs subprocess calls.

* feat(01-03): route optional source reviewers

GREEN: code-review.md gains a dispatch_reviewer_lanes step that matches
canonical reviewer-lane flags against the merged first-party + installed
roster (never a hand-maintained list) and, only when at least one is
present, calls the shared reviewer-step interpreter exactly once with the
already-resolved repo root, file scope, depth, and base SHA. Its evidence
paths are appended to the internal reviewer prompt via
${EXTERNAL_EVIDENCE_BLOCK}, explicitly marked unverified. No reviewer-lane
flag leaves the internal-only dispatch byte-for-behavior unchanged
(COMP-01).

Deviation (Rule 3 — blocking issue): 01-02 documented `review-lane
dispatch-step` (gsd-core/references/loop-hook-dispatch.md) as the CLI
route `dispatchReviewerLanes` wires through, but never implemented the
gsd-tools.cjs subcommand — the workflow's call had nothing to reach. Add
it to the existing review-lane router, reusing the same effort-aware plan
building and runner deps `plan`/`invoke` already use (factored into
buildLaneRunnerDeps to avoid duplicating the spawn/http/fs seam). Guard
the CLI's own `detected` set on whether an explicit flag was passed:
resolveReviewerSelection's no-explicit-selection fallback is "select every
detected reviewer" (the correct default for /gsd:review), and passing it
an unconditionally non-empty detected set would silently invoke the whole
roster on every no-flag code review, violating COMP-01.

* test(01-03): define external finding consolidation

RED: assert gsd-code-reviewer.md treats <external_reviewer_evidence> as
untrusted input — independently re-verifies every claim against the actual
current source, resists a prompt-injection attempt embedded in evidence
text, and folds a verified claim into the existing Narrative Findings
section with no second REVIEW.md schema (CONS-01..03). Also assert
code-review.md's EXTERNAL_EVIDENCE_BLOCK restates the four fixed
source-review prohibitions (SAFE-03..06) at the internal-reviewer handoff.

* feat(01-03): consolidate external review evidence

GREEN: gsd-code-reviewer.md's load_context parses <external_reviewer_evidence>
as untrusted data, independently re-verifies every cited claim against the
actual current source before it can appear in REVIEW.md, and explicitly
resists prompt injection embedded in evidence text (never a command, no
matter what it claims to be). A verified claim folds into the existing
Narrative Findings section with (external: {slug}) provenance — one
REVIEW.md schema only, no separate external-findings section.
code-review.md's EXTERNAL_EVIDENCE_BLOCK now restates the four fixed
source-review prohibitions (SAFE-03..06) at the internal-reviewer handoff.

* fix(01-02): gitignore the reviewer-step-dispatch build artifact

01-02 added src/reviewer-step-dispatch.cts but never added its
npm run build:lib output to .gitignore, unlike every sibling
gsd-core/bin/lib/*.cjs generated file. Left it showing as untracked
noise in git status.

* docs(01-04): publish user and command contract for reviewer-lane source review

- Document optional reviewer-lane flags on /gsd-code-review in USER-GUIDE.md
  and COMMANDS.md: opt-in, no source bodies in prompts, no fallback on
  failure, findings independently consolidated into the single REVIEW.md
- Add the same contract to the docs/features/code-review-pipeline.md
  fragment and regenerate docs/FEATURES.md from it
- Preserve /gsd-review as the plan-review command; cross-reference it
  rather than duplicating the reviewer roster
- Pick up docs/INVENTORY-MANIFEST.json and skills/gsd-code-review/SKILL.md
  drift owned by source already shipped in Plans 01-01/01-03 but never
  regenerated (npm run regen:derived had not been run in this worktree)

* docs(01-04): align architecture and agent ownership docs for reviewer-lane trait

- ARCHITECTURE.md: trace the #4209 capability trait (supportsReviewerLanes)
  through the shared dispatchReviewerLanes interpreter to the existing
  review-lane plan/invoke machinery, ending at gsd-code-reviewer as the
  sole REVIEW.md consolidator
- AGENTS.md: document gsd-code-reviewer's full-context verification scope
  and its treatment of external reviewer evidence as unverified input
- No new diagram, abstraction, or config key; docs/CONFIGURATION.md is
  unchanged since the feature adds no setting or default

* fix(01-02): eslint-ignore the reviewer-step-dispatch build artifact

Same gap as the earlier .gitignore fix: 01-02 added
src/reviewer-step-dispatch.cts but never added its generated
gsd-core/bin/lib/reviewer-step-dispatch.cjs output to
eslint.config.mjs's ignore list like every sibling generated file,
so tsc's emitted __importDefault CommonJS-interop var tripped
no-var.

* fix(01-04): add the reviewer-step-dispatch.cjs roster row to docs/INVENTORY.md

01-04 regenerated docs/INVENTORY-MANIFEST.json (which now lists
cli_modules/reviewer-step-dispatch.cjs) but the hand-written roster
row in docs/INVENTORY.md — required by design, since a role sentence
cannot be generated — was never added.

* fix(01-01): update the code-review capability-step fixture for supportsReviewerLanes

refactor-trigger-cli.test.cjs's preservesCodeReviewHookShapeAlongsideRefactorHook
strict-deep-equals the code-review step's exact shape at execute:post; 01-01 added
supportsReviewerLanes: true to that step and this fixture was not updated.

* chore(01-03): acknowledge emitted-doc growth for code-review.md and gsd-code-reviewer.md

Both files grew as a direct, intended consequence of wiring optional
reviewer lanes into /gsd:code-review (the new dispatch_reviewer_lanes
step and the untrusted-evidence consolidation contract) — not
incidental drift.

Emitted-Drift-Ack-Growth: code-review.md — new dispatch_reviewer_lanes step and EXTERNAL_EVIDENCE_BLOCK wiring for optional reviewer lanes (#4209)
Emitted-Drift-Ack-Growth: gsd-code-reviewer.md — untrusted external-evidence consolidation contract for optional reviewer lanes (#4209)

* test(01-05): define WR-01/WR-02 reliability contract for dispatchReviewerLanes

From internal code review: dispatched must be false when zero lanes
actually reached plan(), and a throwing plan()/invoke() for one lane
must not discard results already collected for a sibling lane —
matching the fail-closed pattern gsd-tools.cjs already uses for the
same resolveLanePlan call (#2494/#2605/#1698/#1936/#2073/#2176/#2589/#2794).

Refs: gsd-core-dks.16, gsd-core-dks.17

* fix(01-05): close WR-01/WR-02/IN-01/IN-02 from internal review

- WR-01: dispatched now tracks whether any lane actually reached
  plan(), not results.length — an unresolvable selected slug no
  longer reports dispatched:true.
- WR-02: plan()/writePromptFile()/invoke() wrapped per-lane so a
  throw for one lane can never discard results already collected
  for a sibling lane, matching the same guard gsd-tools.cjs already
  has around the identical resolveLanePlan call.
- IN-01: documents the intentional budget===0-is-unbounded
  convention (#2797) the caller already relies on.
- IN-02: review-lane dispatch-step no longer blocks indefinitely on
  an un-piped interactive TTY; fails closed to empty paths instead.

Refs: gsd-core-dks.16, gsd-core-dks.17

* docs(01-05): add changeset fragment for PR #17

* fix(01-03): allowlist prompt-injection-scan false positive on the untrusted-evidence contract

agents/gsd-code-reviewer.md's untrusted-evidence section and its
pinning regression test both quote injection phrases as the exact
attack they defend against/detect — same
DEFECT.PROMPT-INJECTION-SCAN-COLLISION class as the existing
allowlist entries, not an actual injection vector.

* test(01-05): extend WR-02 coverage to writePromptFile/invoke throws; DIFF_BASE-empty skip

From CodeRabbit review: WR-02's earlier fix only wrapped plan() —
writePromptFile()/deps.invoke() still ran unguarded, so a throw
there still aborted every later selected lane. Also covers the
dispatch_reviewer_lanes DIFF_BASE-empty-provenance gap (explicit
lanes silently not running when no prior review and no phase-start
commit exist).

* fix(01-05): skip dispatch_reviewer_lanes with a clear warning when DIFF_BASE cannot be resolved

Previously an explicit reviewer-lane request with no prior review and
no resolvable phase-start commit reached dispatch-step with an empty
--base-sha, which fails closed via missing_provenance — correct, but
silent about why explicitly requested lanes didn't run. Now skip
dispatch entirely in that case with a stderr warning naming the
actual cause.

* fix(01-05): wrap writePromptFile/invoke in the same per-lane try/catch as plan()

WR-02's original fix only guarded plan() — a throw from
writePromptFile() or deps.invoke() still aborted the whole dispatch,
discarding results already collected for lanes processed earlier in
the loop. CodeRabbit caught the gap; WR-02b/WR-02c pin it.

* fix(01-05): WR-02b mock must throw only on the first writePromptFile() call

The committed mock threw unconditionally, so codex's retry also threw and
failed for the same reason as claude's — the test could not distinguish
'sibling still runs' from 'sibling also breaks'. Gate the throw to the
first call, matching WR-02/WR-02c's single-failure intent.

* fix(#4209): close review findings from adversarial + critical-code-reviewer pass

Two independent reviews (agy adversarial review, Opus critical-code-reviewer +
ponytail) found 6 Blocking and 7 Required issues in the reviewer-lane dispatch
wiring around dispatchReviewerLanes. All 13 tracked in gsd-core-dks.18-30 and
fixed here:

- dispatch-step's reducer silently swallowed whole-dispatch rejections
  (invalid paths, missing provenance, etc); it now checks parsed.ok/reason.
- spawn_reviewer recomputed its own stale DIFF_BASE, diverging from the
  LAST_REVIEW_COMMIT-aware value dispatch_reviewer_lanes uses on re-review;
  now shares the single compute_file_scope derivation.
- the external reviewer prompt had no actual review request or citation
  requirement, only prohibitions; added both.
- removed the supportsReviewerLanes trait plumbing (capability registry,
  validator, loop-resolver, docs, tests) — it was never consulted by the
  real dispatch path, which gates on explicit CLI flags instead.
- flag-resolution require() was a fragile cwd-relative literal that failed
  silently on non-vendored installs; now resolves via GSD_TOOLS's own
  directory and warns instead of swallowing failure.
- reducer didn't unwrap the @file: overflow protocol for large payloads.
- deduplicated resolveBudget/budgetFor into one resolveLaneBudget.
- lane artifacts now write to a mktemp run dir instead of $PHASE_DIR, so a
  second dispatch can't overwrite prior evidence.
- validatePaths rejects control characters, closing a markdown-injection
  vector into the external prompt via crafted filenames.
- reworded the one line that tripped prompt-injection-scan.sh instead of
  allowlisting the whole production prompt file.
- fixed a stale docstring range and a dispatched-field ordering bug.
- added 3 integration tests executing the actual reducer against synthetic
  dispatch-step JSON, replacing markdown-substring-only assertions.

771/771 tests pass across every touched suite; tsc --noEmit clean.

* fix(#4209): wire supportsReviewerLanes as the maintainer's required reusable trait

The maintainer's approval on issue #4209 explicitly redirected implementation
shape: reviewer-lane dispatch must be a reusable capability/step-dispatch
trait ("supportsReviewerLanes"), not code-review.md hand-wiring the call
itself. My previous commit (e2558326) deleted that trait entirely after
finding it declared-but-never-consulted, which was backwards — the fix was to
wire it, not remove it.

Restores the trait (capability.json, generated registry, validator,
loop-resolver.cts, docs, tests) and wires it for real: dispatch_reviewer_lanes
now resolves its own active hook via `gsd_run loop render-hooks` for the
configured workflow.code_review_point and only proceeds to CLI-flag matching
when supportsReviewerLanes reads true. Explicit flags no longer bypass the
trait; a matching flag with the trait false resolves zero slugs (proven by a
new integration test executing the real fence with both trait states).

Emitted-Drift-Ack-Growth: gsd-core/workflows/code-review.md — the
dispatch_reviewer_lanes step grows a trait-resolution fence (#4209 maintainer
redirect requires the capability layer, not the workflow, own the opt-in
decision).

* fix(#4209): dispatch-step self-verifies the reviewer-lane trait via --cap-id/--point

Both an agy adversarial review and an Opus critical-code-reviewer pass
independently found the same gap in my previous commit (9b2c3773d): the trait
check I wired into code-review.md only protected code-review's OWN
invocation — gsd-tools.cjs's dispatch-step handler still hardcoded
`trait: true` unconditionally, so a second capability declaring
supportsReviewerLanes would get zero enforcement from the shared CLI unless
it correctly re-implemented the ~15-line render-hooks scrape itself. That is
exactly the "each workflow.md hand-wiring the call" the maintainer's redirect
said to eliminate.

Moves the trait check into dispatch-step itself: given --cap-id/--point, it
self-invokes `loop render-hooks <point>` (relocating the one subprocess
code-review.md used to spawn for this, not adding a new one) and derives the
real trait from that capId's active hook, rather than trusting a
caller-passed boolean. code-review.md now only passes
--cap-id code-review --point "$CODE_REVIEW_POINT" and no longer resolves or
gates on the trait itself — the ~20-line scrape it previously carried is
gone. Any other capability opts into the identical enforcement by declaring
the trait and passing the same two flags.

Replaced the two tests that stipulated SUPPORTS_REVIEWER_LANES as an input
variable (they proved a bash branch honors a variable, not that the variable
reflects the real capability manifest) with three integration tests that
invoke the real dispatch-step CLI against the real first-party capability
registry: the real code-review trait resolves true, an unknown --cap-id
resolves false (trait_not_enabled, fail-closed), and omitting
--cap-id/--point entirely resolves false (no context means no opt-in).

Also: reject \x7f/U+2028/U+2029 in validatePaths' control-character check
(agy-F1 was incomplete), and delete the promptWritten per-lane coupling
flag — the prompt write is idempotent, so writing it once per lane instead
of gating on "did any lane write it yet" removes a latent bug where a
deps.plan override that ever varies promptPath per lane would silently skip
writing for a later lane.

Emitted-Drift-Ack-Growth: gsd-core/workflows/code-review.md — net line count
drops (the trait scrape moved into dispatch-step), but the file still grew
this session across multiple commits; acknowledging per the growth-tracking
convention.

* fix(#4209): remove per-run token waste from the shipped prompts

Runtime prompt content, not session tokens: two real, per-invocation token
costs in the code that ships.

1. agents/gsd-code-reviewer.md's critical_rules restated nearly all of
   load_context step 5's ~180-word untrusted-evidence contract in ~90 more
   words, breaking this section's own established terse one-liner style
   (every other rule here is 1-2 sentences). This prompt loads fresh on
   every /gsd:code-review invocation. Shrunk to a one-line cross-reference,
   matching how write_review's own reference to step 5 already does it.

2. buildSourceReviewPrompt repeated the base SHA on every single file line
   even though it is identical for every file and already stated once at
   the top of the prompt — O(files) wasted tokens on every dispatched lane
   for a 50-file review, for zero information gain. File lines are now bare
   paths.

* fix(#4209): resolve reviewer-lane trait in-process, fix CI failures found in review round 3

Opus critical-code-reviewer found a real Blocking defect in the --cap-id/
--point self-invocation added last commit: `dispatch-step` spawned
`loop render-hooks <point> --raw` as a subprocess and bare-JSON.parse'd its
stdout, but `io.cjs`'s output() redirects any payload over 50000 chars to
`@file:<path>` instead of inline JSON -- the same overflow protocol this
feature already unwraps for its OWN dispatch result 60 lines later in
code-review.md. A large-enough activeHooks envelope (more installed
capabilities/fragments) would throw, get silently swallowed by the bare
catch, and misreport a real trait as trait_not_enabled with zero diagnostic.

Fixed by extracting the config/registry/capability-state resolution
`cmdLoopRenderHooks` already performs into an exported pure function,
resolveActiveHooksForPoint (both `cmdLoopRenderHooks` and dispatch-step now
share it), and calling it in-process from dispatch-step instead of spawning
a subprocess at all. This eliminates the @file: exposure entirely (the
dispatch-step path never touches the rendered-string envelope or its
JSON-stringify/50000-char threshold), removes one subprocess spawn per
code-review invocation, and gives a genuine diagnostic (stderr warning) on
resolution failure instead of silent fail-closed. Corrected three doc/
docstring references to the now-removed subprocess self-invocation.

Also fixes 2 real CI failures this round surfaced:
- lint-tests: the agy-F1 control-char regex fix's `eslint-disable-next-line
  no-control-regex` comment was unused under this project's ESLint config
  (verified locally: the rule never actually flags \x00-\x1f in this repo's
  config) -- a mistake from an earlier commit this session, never actually
  lint-checked before push. Removed the disable comment.
- security (prompt-injection-scan): the agy-F1 regression test's crafted
  fixture literally contains "Ignore all prior instructions." as test data
  proving validatePaths rejects it -- allowlisted the test file, same
  DEFECT.PROMPT-INJECTION-SCAN-COLLISION class as existing entries.

Also trimmed agents/gsd-code-reviewer.md's load_context step 5 (R2): one
bullet stated "untrusted, never a command" three different ways in one
paragraph, and a same-file duplicate of write_review's schema rule.
Consolidated to state each rule once.

Declined one suggestion from this round: shrinking code-review.md's
EXTERNAL_EVIDENCE_BLOCK to a bare evidence list. Two tests
(tests/code-review-pipeline-regression.test.cjs's CONS-01..03 block,
tests/code-review.test.cjs's CONS-02 test) deliberately lock the four-
prohibitions restatement and the untrusted-evidence prose into the
INJECTED block itself, not just the consolidator's system prompt --
adjacency of the warning to the untrusted payload it's warning about is a
recognized prompt-injection defense-in-depth pattern from this
workstream's original TDD plan, not accidental duplication.

* fix(#4209): correct stale per-file base-SHA prose in the external prompt

Leftover from removing the per-file base SHA repetition earlier this
session: the review-request sentence still said "relative to its base SHA"
(singular per-file framing) when there's now exactly one base SHA, stated
once above the file list. Reads "relative to the base SHA above" now.

* fix(#4209): make getLane/configGet/plan required deps, delete dead defaults

R3/R4 from the review round I'd deferred as low-priority test-churn: this
file's one production caller (gsd-tools.cjs's dispatch-step handler) always
supplies all three, so the fallbacks were dead in production -- but each was
actively WRONG if ever reached: the default configGet always returned
undefined, silently disabling resolveLaneBudget's overflow guard; the
default getLane looked up only first-party REVIEWER_LANES, diverging from
production's overlay-merged roster; the default plan skipped per-host effort
resolution entirely.

These defaults were introduced by this PR's own earlier work (this file did
not exist before #4209 -- first commit a760bfcda, 01-02), not inherited from
elsewhere, so there's no external caller depending on the lenient contract.

Turned out free to fix: making the three deps required and deleting
defaultGetLane/defaultPlan needed zero test changes -- every existing test
that actually reaches the per-lane loop already supplies getLane/plan
explicitly, and configGet's only real dependent (the budget-overflow tests)
already supplies it too. 788/788 tests pass unchanged, tsc/lint clean.

* fix(#4209): define depth semantics for the external reviewer lane

Verified this was a real bug, not a match to existing convention as I'd
claimed when declining the suggestion earlier this session: the internal
gsd-code-reviewer agent's own system prompt carries a full <depth_levels>
block defining what quick/standard/deep mean and do (agents/gsd-code-
reviewer.md:68-99). The external reviewer lane has no access to that
persona at all -- it only ever sees buildSourceReviewPrompt's bounded text,
which sent the bare depth label with zero definition to a third-party CLI
with no other source of truth for what "standard" means.

Added depthMeaning(), condensed from the internal reviewer's own
<depth_levels> definitions so the two stay consistent, and interpolated it
into the review-request sentence. 150/150 tests pass, tsc/lint clean.

* fix(#4209): merge dispatch_reviewer_lanes' split fences into one shell invocation

CR-01 (Opus critical-code-reviewer, confirmed by direct execution): the
roster-matching fence set EXPLICIT_JOINED/EXPLICIT_REVIEWER_SLUGS, and a
SEPARATE later fence read them via ${#EXPLICIT_REVIEWER_SLUGS[@]} to decide
whether to dispatch at all. This file's own documented rule (its
depth-resolution guard, stated explicitly a few hundred lines earlier) is
that a guard and the extraction it protects must run as one shell
control-flow decision, because markdown-fenced blocks do not share shell
state -- this step violated its own file's rule for the entire feature's
gating condition.

Merged the roster-resolution fence and the dispatch-decision fence into one
continuous bash block, removing the intervening prose that split them.
Fixed the stderr-based failure detection in the same edit (RQ-01: checking
whether stderr is non-empty misfires on any benign Node warning; now checks
the actual exit status of the roster-resolution command).

Verified by extracting the merged fence and executing it standalone, driving
both branches: --codex resolves EXPLICIT_JOINED=codex, SLUGS_COUNT=1, and a
real dispatch-step call succeeds; no flags resolves EXPLICIT_JOINED empty,
SLUGS_COUNT=0, dispatch-step never invoked (COMP-01). 141/141 workflow tests
pass, tsc/lint clean.

* fix(#4209): depthMeaning accuracy, injection defense on all embedded fields, hoisted prompt write

Batch of Required/Suggestion fixes from the Opus critical-code-reviewer +
writing-for-agents pass:

- CR-02/CR-03: depthMeaning() dropped real categories from quick (empty catch
  blocks, commented-out code) and deep (error propagation, state mutation
  consistency, circular dependencies) relative to the real <depth_levels>
  block, and had zero test coverage. Restored full accuracy and added tests
  that read the real agents/gsd-code-reviewer.md file directly, so drift
  between the two can't recur silently. Unrecognised depth now normalizes to
  standard's definition, matching that agent's own documented rule, instead
  of rendering an undefined bare label.

- RQ-04: depth/baseSha/repoRoot/runDir land in the same markdown prompt
  `paths` does, but weren't checked for control characters like paths were
  (agy-F1's original finding). Hoisted CONTROL_CHAR to module scope and
  applied it to all four fields at the same provenance-check boundary.
  runDir previously had zero validation at all.

- S1: deleted the dead `identity` parameter on `invoke` -- the one production
  caller already ignores it, no test read it by name.

- S2: hoisted the shared prompt write above the per-lane loop -- promptPath
  is derived from runDir alone (constant across lanes by construction), so
  writing it once is both correct and cheaper than the per-lane write R1
  introduced earlier this session. Discovered and fixed a real regression
  from the naive version of this hoist: an unguarded throw would have
  escaped dispatchReviewerLanes as an uncaught exception instead of a clean
  per-lane failure. Added a new PROMPT_WRITE_FAILED whole-dispatch reason,
  matching the existing validatePaths/MISSING_PROVENANCE halt pattern, with
  a dedicated regression test.

- S3: moved `planned = true` past the budget-overflow gate, so `dispatched`
  only reports true once a lane has cleared BOTH plan and budget checks.

- S5: relayed gsd-code-reviewer.md's own "performance issues are out of
  scope unless also correctness issues" policy into the external-lane
  prompt, which previously had no such guidance and could return findings
  the internal reviewer's own contract excludes.

- RQ-05 (partial): shrunk this file's own header docstring's restatement of
  the trait-reuse architecture to a pointer at
  gsd-core/references/loop-hook-dispatch.md, the canonical home.

234/234 tests pass across the full reviewer-lane test suite, tsc/lint clean.

* fix(#4209): dedupe roster-merge logic, consolidate trait architecture prose, add step completion criterion

RQ-02: added a `review-lane explicit-from-argv` subcommand that reuses the
SAME merged-roster logic (`laneBySlug`) `dispatch-step`/`plan`/`invoke`
already share. code-review.md's ~18-line inline `node -e` reimplementing
`loadRegistry`+`mergeReviewerLanes` (a rename-only copy of the block in
gsd-tools.cjs) is now a single call to this subcommand -- the exact
violation code-review-flags.cjs's own header warns against ("this is the
canonical flag-parsing surface -- do not replicate inline bash parsing").

RQ-03: an empty --cap-id XOR --point now warns distinctly from the
legitimate no-context opt-out (both absent) -- a caller that named a
capability without its point was silently indistinguishable from a correct
opt-out. Also hardened the CODE_REVIEW_POINT config-get fallback: it only
ever fires when the config-get COMMAND ITSELF fails (config-get already
resolves the manifest's own schema default in the normal case), but that
failure was previously silent.

RQ-05/W-01/W-12/W-13: the "supportsReviewerLanes is a reusable trait
resolved inside dispatch-step" explanation was restated in full in 5
places across this session's own review cycles. Consolidated to ONE
canonical statement in gsd-core/references/loop-hook-dispatch.md; the other
4 (this file's own header, gsd-tools.cjs's comment, docs/ARCHITECTURE.md,
code-review.md's step-opening comment) now point at it instead.

W-05/W-06: loop-hook-dispatch.md described "false or non-boolean" as two
inert cases when capability-validator.cjs already rejects non-boolean at
load -- restated as the two cases that actually reach this code. Removed a
"do not hand-roll trait resolution" prohibition whose target no longer
exists once the positive description precedes it.

W-04: deleted a no-op sentence in agents/gsd-code-reviewer.md ("missing
block means proceed as normal") -- an absent optional block already means
proceed as normal without being told.

W-08/W-09: replaced longhand "zero selection/plan/invoke calls" and the
made-up compound "byte-for-behavior [un]changed" with the token this
session's own docs already coined for this concept (inert) and the word
that means what byte-for-behavior was reaching for (unchanged).

W-10: dispatch_reviewer_lanes had no completion criterion -- added one
sentence naming the checkable end state (EXTERNAL_EVIDENCE_BLOCK is set,
either populated or empty). This exact sentence would have caught the
cross-fence bug fixed two commits ago at authoring time.

Declined from this round, with reasoning: W-02/W-03 (trim the
untrusted-evidence restatement in EXTERNAL_EVIDENCE_BLOCK/critical_rules) --
two tests deliberately lock this as intentional adjacency-based
prompt-injection defense-in-depth, not accidental duplication (see this
branch's own earlier commit). S4 (wrap LANE_RUN_DIR in a creation-site
`trap ... EXIT`) -- would fire at the end of the CREATING fence, before
spawn_reviewer's agent ever reads the evidence files, given this file's own
documented fenced-block execution model; the existing named cross-reference
between creation and cleanup already satisfies the co-location concern
without introducing that regression.

853/853 tests pass across the full reviewer-lane test suite, tsc/lint clean.

* fix(#4209): merge CODE_REVIEW_POINT into dispatch_reviewer_lanes' one fence, stop test from spawning real codex

Round-5 review (agy) found the same cross-fence-split bug CR-01 already fixed
for EXPLICIT_JOINED/EXPLICIT_REVIEWER_SLUGS: CODE_REVIEW_POINT's config-get
fallback lived in an earlier, separate fence from the fence that consumes it
via --point, split only by prose (not a guard, per this step's own documented
rule). Merged into the single continuous fence and added a structural test
asserting exactly one bash fence in the step.

The new end-to-end regression test for this used --codex, which drives the
fence's real `review-lane dispatch-step` call and, with the codex binary
present on PATH, spawns the real external CLI — which then blocks on
interactive auth with no stdin (BL-01). Stubbed gsd_run for
`review-lane dispatch-step` only (captures argv instead of executing),
keeping the real config-get/explicit-from-argv calls the test is actually
about.

* fix(#4209): split control-char vs missing provenance reason, realpath-check path escapes, stale comment

Round-5 review (Opus) warning-tier findings:

- WR-04: MISSING_PROVENANCE covered both "field absent" and "field present but
  a control-character injection attempt" — a caller distinguishing a config
  problem from a security event couldn't tell them apart. Split into
  MISSING_PROVENANCE (absent) and INVALID_PROVENANCE (present but invalid).
- WR-05: validatePaths' containment check was lexical only (path.resolve),
  so a symlink whose own path sits inside repoRoot could still point outside
  it. Added an fs.realpathSync check (ENOENT-tolerant — a git-diff path can
  legitimately name a file already deleted in a stale worktree), realpathing
  repoRoot itself too so a symlinked repoRoot (e.g. /tmp on macOS) doesn't
  false-positive-reject its own real children.
- WR-08: a comment in the per-lane loop still said a throwing writePromptFile()
  was caught there — stale since the prompt write was hoisted above the loop
  in an earlier round.

WR-03 (validate depth against the quick/standard/deep enum) was considered
and declined: this dispatcher is deliberately capability-neutral (see the
existing "synthetic step context" test, which passes a non-code-review depth
label on purpose to prove no code-review-specific special-casing exists).
WR-01 (double registry load), WR-02 (trim-vs-hard-fail budget semantics), and
WR-07 (reason omitted on the aggregate return) were verified against source
and are not bugs — see review notes.

* docs(#4209): document LANE_RUN_DIR's early-exit trade-off as accepted, not a gap

Round-5 review (Opus, BL-03) flagged that an early exit between
dispatch_reviewer_lanes and commit_review leaks the run-scoped temp dir. A
trap-based cleanup was considered and rejected: if a step genuinely runs as
a separate process, a trap set at creation time would fire at the end of
that SAME fence, deleting the directory before spawn_reviewer/commit_review
ever read it — worse than the leak it would fix.

review.md's own gather_context/cleanup pair for the identical resource class
(a run-scoped reviewer temp dir) already makes and documents this exact
trade-off: cleanup runs only on a documented success path, and a leftover
$TMPDIR entry is explicitly called cheaper than destroyed evidence. Recording
that precedent here so this isn't re-raised as a live gap in a future review.

* fix(#4209): register the WR-05 symlink-escape test's synthetic docs/ path

reviewer-step-dispatch.test.cjs's "capability-neutral reuse" fixture passes
paths: ['docs/spec.md'] as a synthetic, never-read path proving the
dispatcher has no code-review-specific special-casing. lint-docs-guard-
registration correctly flagged this as an unregistered docs/ path reference —
add the docs-guard-exempt marker and its pinned baseline entry, the same
pattern every other synthetic docs/ literal in this test suite already uses.

* fix(#4209): backfill changeset pr: field with the real upstream PR number

changeset-lint's fail_pr_field_drift caught the fragment still pointing at
the fork PR (17) instead of the upstream one (open-gsd/gsd-core#4323) this
branch is now also open against.

* docs(#4209): amend ADR-2782 for the supportsReviewerLanes step-trait seam

trek-e's review (2026-09-07, gsd-core#4323) found a real ADR gap: every
decision in ADR-2782 (D1-D9) and every prior dated amendment governs the
`role: "reviewer"` capability body and its one consumer, /gsd:review. This
PR's actual new seam - a `supportsReviewerLanes: true` trait on an ordinary
feature capability's `steps[]` entry, projected through loop-resolver.cts
and resolved in-process via resolveActiveHooksForPoint - is a different
capability axis (steps/gates/contributions) that the ADR's own scope note
explicitly places out of reach. Per docs/contributor-standards.md's
"Amending an accepted ADR", an in-place dated section is the established,
lighter-weight path for an addition that stays within the ADR's existing
decisions - used twice already in this same file - so this appends a third
dated entry documenting the new seam, its consumer, and why it reuses the
existing D1-D9-governed plan/invoke machinery rather than adding a second
one. No decision is reversed; no new Amends/Amended-by pair is needed since
the steps/gates/contributions axis already carries reciprocal links to
ADR-857 and ADR-894.

* fix(#4209): close two test-quality gaps trek-e's review found

Minor 1: validatePaths (a path-shape parser guarding the prompt-
injection/path-traversal trust boundary) had only example-based coverage,
violating ADR-456's rule that parsers/budget limits carry at least one
fast-check property test. Adds three: safe-segment paths are never
rejected, a single leading "../" always escapes the one-segment repoRoot,
and a control character anywhere is always rejected - one property per
rejection reason validatePaths owns.

Minor 2: the budget-overflow check (`estimatedTokens > budget`) was only
ever exercised far below budget or at budget:0 (unbounded), never at the
exact threshold crossing where a `>` vs `>=` off-by-one would hide. Adds
three exact-boundary tests using the real estimateTokens/
buildSourceReviewPrompt the module calls internally, so the resolved
token count is exact rather than approximated: budget == estimate (must
pass), budget == estimate - 1 (must fail), budget == estimate + 1 (must
pass).

Also extracts okPlan()'s fixture timeoutMs into a named constant -
local/no-adhoc-timeout-literal (#4446) landed on next after this branch
was authored and flagged the pre-existing literal on rebase; it is fixture
data for a synthetic plan object dispatchReviewerLanes never waits on, a
distinct class from tests/helpers/timeouts.cjs's real subprocess norms.

* fix(#4209): update docs-guard-registration baseline for the new ADR citation

reviewer-step-dispatch.test.cjs's new fast-check property tests cite
docs/adr/456-test-rigor-architecture.md in a justifying comment (never a
real read). lint-docs-guard-registration fingerprints every docs/ path
string an exempted test file mentions and fails on drift so a human
re-confirms the exemption still holds - re-confirmed, and the baseline is
updated to match.

* fix(#4209): point changeset pr: field at the fork PR for CI validation

changeset-lint's fail_pr_field_drift check compares the fragment's pr:
field against the PR the CI run is actually attached to (GITHUB_EVENT_PATH),
not a fixed target. Rehearsing this branch on fork PR
davdittrich/gsd-core#17 needs pr: 17 to pass that check; the prior commit's
pr: 4323 (the real open-gsd upstream PR number) is correct for that PR but
fails here. Backfill to 4323 happens again, as the last commit, immediately
before the approved push to open-gsd#4323 - never leaving pr: 17 on the
branch that ships upstream.

* fix(#4209): reject promptChannel:none lanes from source-review dispatch

CodeRabbit found a real scope mismatch: coderabbit's lane declares
promptChannel: 'none' and reviews the working tree on its own terms,
fed nothing (review.md:367). Silently dispatching it through
dispatchReviewerLanes would ignore the bounded paths/depth/baseSha scope
buildSourceReviewPrompt promises and let the lane review whatever it
independently sees fit, violating this interpreter's own scoped,
metadata-only contract. Reject before plan()/invoke(), same as an
unresolved slug.

* fix(#4209): scope CONS-02 test to the evidence-block line, not the whole file

CodeRabbit found the whole-file match on workflowContent would still
pass if UNVERIFIED and re-open/reopen appeared in two unrelated parts
of this 1000+-line workflow, proving nothing about the actual evidence
block's contract. Line-filtered via splitLines (not a bare-\n regex
spanning readFileSync content) so this stays CRLF-portable and passes
local/no-unbounded-quantifier and local/no-crlf-fragile-split.

* fix(#4209): guard DISPATCH_JSON substitution and capture its stderr

CodeRabbit found the dispatch-step command substitution unguarded: a
non-zero exit could leave DISPATCH_JSON empty (or halt the step under
errexit with no warning), and the downstream reducer would only ever
report the generic unparseable_dispatch_output reason, discarding the
command's own diagnostic. Guarded like the existing CODE_REVIEW_POINT/
EXPLICIT_JOINED calls above it: capture stderr to a temp file, surface
it in a warning on failure, and fall back to a parseable dispatch_
command_failed JSON stub so the reducer's existing reason-reporting
path still fires.

* docs(#4209): fix byte-for-behavior wording and missing colon, regenerate

CodeRabbit found "byte-for-behavior" should read "byte-for-byte" (the
established repo term for output-identical unchanged behavior) and a
missing colon after the bold "Optional external reviewer lanes (#4209)"
lead-in in docs/features/code-review-pipeline.md. Fixed in the two
hand-authored sources (commands/gsd/code-review.md, docs/features/
code-review-pipeline.md) and regenerated the two derived projections
(skills/gsd-code-review/SKILL.md via gen-plugin-skills.cjs, docs/
FEATURES.md via gen-features.cjs) so they stay in sync.

* fix(#4209): drop the fabricated DISPATCH_JSON fallback stub (Windows CI)

The prior fix's fallback `DISPATCH_JSON='{"ok":false,...}'` embeds
double-quoted JSON keys inside a single-quoted shell literal. That
extra quote density, inside an already quote-heavy ~8KB driver string,
passed bash -n and the full local suite on Linux but broke Windows
Git-Bash: `dispatch_reviewer_lanes computes CODE_REVIEW_POINT ... end
to end (#4209 round 5)` failed on two Windows CI shards with `bash -c:
unexpected EOF while looking for matching '''` — a Windows argv-to-
command-line re-quoting edge case, reproducible on rerun, not a flake.
Root-caused via gh api job logs plus a byte-identical local
reconstruction of the test's own driver script.

Fix: drop the fabricated stub. The downstream node -e reducer already
falls back to reason `unparseable_dispatch_output` on any JSON.parse
failure, so an empty/partial DISPATCH_JSON on command failure is still
handled correctly, with zero new quoting risk.

* revert(#4209): drop the DISPATCH_JSON stderr-guard nitpick (Windows CI)

Two materially different mechanisms for the same CodeRabbit Nitpick
("Trivial | Quick win") both broke Windows Git-Bash reproducibly:
a single-quoted JSON-literal fallback ("bash -c: unexpected EOF ...
matching '''") and, after removing that, a plain `head -1 "$VAR"`
inside a nested command substitution ("unexpected EOF ... matching
'"'"). Both passed bash -n and the full local suite on Linux every
time; both failed the SAME test deterministically on Windows CI. Two
attempts at the same class of fix (nested-quote construction near
this exact step) is the retry limit - reverting to the original,
already-shipped, Windows-verified unguarded form rather than
continuing to guess at a third quoting mechanism for a Trivial-
severity nitpick. Logged as bug-221/bug-222 in .wolf/buglog.json for
anyone attempting this again: the fix belongs outside this specific
markdown-fence-driver test harness (e.g., a real .sh helper script)
if it's worth doing at all.

* fix(#4209): backfill changeset pr: field to the real upstream PR before push

Fork validation (davdittrich/gsd-core#17) needed pr: 17 to satisfy
changeset-lint's PR-number check while rehearsing there; this is the
last commit before the approved push to the real upstream PR
(open-gsd/gsd-core#4323), so the field points at that PR number again.

---------

Co-authored-by: Test <test@test.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-07 22:52:33 -04:00
Michel Moreira
733bec3ad1 enhance(#4261): report size-cap headroom on every run, with a reserved margin (#4418)
* enhance(#4261): report size-cap headroom on every run, with a reserved margin

The tier caps are red lines and none of them moves here. What was missing is
everything below the red line: a passing run said nothing, so a contributor
at 99.6% of a cap and one at 60% got identical feedback, and the density that
produces merge-time collisions was invisible to the people creating it.

Two levels, matching the shape execute-phase.md already carries by hand (a
hard ceiling plus a lower margin "so minor future edits don't re-trip the
gate") and which was until now the only capped file with one:

  1. a headroom census printed every run, green included, sorted
     least-headroom-first, and appended to the GitHub job summary
  2. a 95% reserved margin that names the files inside it and REPORTS
     rather than fails

The margin deliberately does not fail. A cap breach is a red line; a file at
96% is not broken, it is a file whose next contributor should extract before
adding. Failing there would create a second red line and force exactly the
+N bumps the policy forbids. Neither level asserts a count, so this adds no
snapshot to regenerate — the per-file size baseline was deleted by #2724 for
conflicting on 7 of 7 PRs that touched it.

Also deletes rather than refreshes the per-tier high-water comments in both
guard files. They were measured once and then diverged from the tree: the
LARGE line still claimed "gsd-executor 42,342 -> ~6.8 KB headroom" while the
real high-water sat at 99.6% of that cap, so the comment documenting the
margin was itself why nobody noticed the margin was gone.

As measured on next by the new census, the pressure has grown since the
issue was filed: gsd-plan-checker.md has 9 bytes of headroom, gsd-verifier.md
21, and plan-phase.md 14.

* chore(#4261): add changeset for the size-cap headroom census

* test(#4261): exercise reserved-margin boundaries

---------

Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-07 22:04:48 -04:00
Tom Boucher
93e141a006 enhance(#4139): Phase 4 — measure the window instead of asserting it (#4502)
* enhance(#4404): add offline token benchmark for compact-content splits

ADR-4139 Decision 2 requires the finite-attention justification for
workflow.compact_content to be measured, not asserted. `npm run
benchmark:compact-content` computes, per registered spine/detail split
discovered under gsd-core/workflows/, the token count with the split
active (spine alone) vs inactive (spine + all detail parts read back
in), using gpt-tokenizer (pinned exact devDependency — Anthropic
publishes no tokenizer for Claude 3+, so every output surface labels
this a PROXY-TOKENIZER comparison: the on/off delta is exact under one
tokenizer applied identically to both sides, the absolute counts are
not Claude's real ones).

Reporting-only by design and verified so: --check diffs the live
recompute against a committed baseline (tests/fixtures/compact-content-benchmark-baseline.json)
and prints drift, but never exits non-zero for a drifted or missing
baseline — the only thing allowed to fail this script is a genuine I/O
error reading a source .md file it's measuring. Not wired into lint:ci
or pretest.

Discovery is deliberately reimplemented rather than importing
tests/helpers/compact-content-split.cjs (Phase 3, #4403), keeping a
scripts/ reporting tool from depending on a test-only module.

tests/fixtures/deny-network.cjs preloads via NODE_OPTIONS=--require to
prove the benchmark makes no network call, monkeypatching http/https/
net/dns/fetch to throw rather than relying on sandboxing.

docs/CONFIGURATION.md documents the new benchmark against the
workflow.compact_content key to satisfy this repo's docs-required gate
for an Added-type changeset.

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

* fix(#4404): address orthogonal review findings on the token benchmark

Standards axis found two hard violations against documented rules:
- CLAUDE.md's Generative Fix Divergence rule requires a parity assertion
  for shared discovery logic maintained in two places. Added a test
  comparing benchmark-compact-content.cjs's own discoverRegisteredSplits
  against tests/helpers/compact-content-split.cjs's version on the real
  repo tree, so the two can never silently drift apart.
- The changeset body closed its bold span with a period and continued
  as a second sentence, instead of the canonical
  `**<phrase>** — <explanation>.` shape CONTRIBUTING.md documents.

Spec axis found the "network disabled + identical output across two
runs" Done-when criterion was verified as two separate properties
(determinism tested without network denial, offline survival tested as
a single run) rather than as one combined property. Added a test that
runs the benchmark twice under the deny-network preload and asserts
byte-identical stdout.

Security axis found tests/fixtures/deny-network.cjs didn't patch
dns.promises (a separate binding from the callback dns API), tls.connect,
or http2.connect — inert today since nothing in the benchmark calls
them, but a silent gap in what the preload's own header claims to
guarantee. Patched all three.

CLAUDE.md's Property-Based Testing rule also requires a fast-check test
for budget-limit arithmetic; added one for computeAggregate's off/on
summation (true sum over N splits, never NaN/Infinity, never exceeds
100% when off >= on for every split).

Standards axis's remaining two findings (a Data Clumps observation on
the {offTokens, onTokens, reductionPct} triple, and mild duplication in
formatDriftReport's three line-formatters) are left as judgement calls:
introducing a named type for a 3-field local tuple, or a formatter
abstraction for three short lines, would be exactly the premature
abstraction CLAUDE.md's engineering guidance warns against for a script
this size.

All changes verified directly (parity logic, the fast-check property,
and the three newly-denied network surfaces actually throwing under the
preload) via node -e before committing; full npm run lint:ci passes
with the eslint cache cleared.

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

* docs(#4404): backfill changeset pr number to 4502

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

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 19:19:03 -04:00
Tom Boucher
a0f8f956c4 enhance(#4139): Phase 3 — partition rules + the five checks (#4497)
* enhance(#4139): Phase 3 — partition rules + the five checks

ADR-4139 Decision 5, epic #4139 Phase 3. Issue #4403's own "Proposed behavior"
section lists four checks; the ADR's Decision 5 and its own phase table ("partition
rules + the five checks") list five — the same four plus "boundary moves are
declared, ongoing". Same issue-vs-ADR drift Phase 2 hit on the detail.md vs
detail/*.md layout: the ADR is the locked, reviewed document, so it wins. This PR
implements all five.

docs/PARTITION-RULES.md (new) is the partition-rules document: the partition rule
itself, the protected-content list and <!-- gsd:protected --> sentinel syntax
(relocated unchanged from gsd-core/references/compact-content-protected-content.md,
now deleted — it was never referenced by any runtime workflow Read, only by the
predecessor test as documentation, so nothing at runtime regresses, and removing it
from gsd-core/references/ also drops it from all 19 installed-project shipped-content
trees for a file nothing ever read), and the five checks explained for a human
reader. Referenced from a new CONTRIBUTING.md subsection under "Editing shipped
content".

tests/helpers/compact-content-split.cjs (new) is the shared mechanics: split
discovery (any gsd-core/workflows/<name>/detail/*.md paired with <name>.md — no
registry file, a pair is registered by existing on disk), line normalization
(carries forward Phase 2's bare-label-line isTrivial fix and the canonical
gsd_run-launcher-preamble exclusion), sentinel extraction, and a
Boundary-Move-Declared commit-trailer reader that is a direct structural port of
tests/helpers/emitted-runtime.cjs's Emitted-Drift-Ack-Hash/-Growth trailer reader
(ADR-3942) — same merge-base range, same fail-closed throw on an uncomputable range,
same dedupe/conflict rules.

tests/compact-content-partition-guard.test.cjs (new) is the actual guard, superseding
tests/plan-phase-compact-split.test.cjs (deleted — its per-pair checks are now the
general guard's job for plan-phase specifically). Checks 2 (disjointness) and 3
(registration + size cap) run unconditionally against every registered split. Checks
1 (completeness, fires once per split on the PR that introduces a new detail/ path),
4 (protected content — no trailer can ever excuse this one, unlike check 5) and 5
(boundary moves declared) are PR-diff-scoped against the resolved base ref and skip
cleanly when there's nothing to compare (a fresh clone, no PR in flight) — a
deliberate asymmetry from check 5's trailer reader, which must throw rather than
silently pass when ITS range is uncomputable, since that function is answering "did
this PR declare its moves" rather than "is there even a diff to look at". Each of the
five checks carries a RED (deliberately broken fixture) / GREEN (fixed) test pair,
built against synthetic temp files or real throwaway git repos, per this repo's rule
that a guard nobody has seen go red is not yet a guard. Building the real fixtures
caught and fixed one real bug before it shipped: check 4's line-presence test was
using the trivial-line-filtered normalizer, so a byte-identical spine falsely
reported its own protected code-fence line as "deleted" — fixed with a
non-filtering membership check.

Extending docs/INVENTORY.md's "Workflow Sub-Files" table for `detail` surfaced a
pre-existing, unrelated gap in the SAME area: gsd-core/workflows/<name>/templates/*.md
is a fourth workflow sub-file kind that already existed on disk and was already known
to lint-response-language-coverage.cjs's FRAGMENT_DIRS, but was invisible to
gen-inventory-manifest.cjs and undocumented in that table. Fixed alongside it, same
pattern, same PR, rather than deferred.

Also, mechanically required by the new fourth sub-file kind:
- scripts/lint-response-language-coverage.cjs: `detail` added to FRAGMENT_DIRS
  alongside modes/steps/templates — a detail/<part>.md inherits its parent's
  response_language coverage through the same per-file proof, not a parallel one.
- tests/workflow-size-budget.test.cjs: explicit regression test locking that
  detail/ files are governed solely by the hard, non-waivable NEW_FILE_CAP
  (tests/helpers/emitted-diff.cjs) and never by the XL/LARGE/DEFAULT spine tiers —
  true by construction (measureWorkflows/listWorkflowStems don't recurse), made
  explicit per the issue's own Done-when item rather than left true-by-omission.
- scripts/gen-inventory-manifest.cjs: `workflow_detail` and `workflow_templates`
  NESTED_FAMILIES entries; docs/INVENTORY-MANIFEST.json regenerated
  (plan-phase/detail/elaboration.md, discuss-phase/templates/*.md now tracked);
  docs/INVENTORY.md's table updated to four kinds.

Verified: `npm run lint:ci` clean with the eslint cache cleared.

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

* fix(#4403): review findings + a real gsd-test failure in the new guard

Two orthogonal review passes (Standards + Spec, isolated sub-agents) plus a
separate security review ran against the prior commit. Fixed everything each
surfaced:

- Security (Low, path-traversal existence oracle): checkRegistration's
  dangling-reference check extracted detail-path-shaped substrings from spine
  PROSE via a regex that permits `.`/`/` freely, then joined them onto repoRoot
  and probed fs.existsSync with no containment check — a spine file containing
  `../../../etc/detail/passwd.md`-shaped text could make the guard test file
  existence outside the repo. Added a path.relative-based containment check
  before the fs.existsSync call; anything that resolves outside repoRoot is now
  reported as a dangling reference directly, never probed on disk.
- Standards (Boundary Coverage): the size-cap fixtures covered NEW_FILE_CAP and
  NEW_FILE_CAP-1 but not NEW_FILE_CAP+1 — added the third boundary-point case
  CLAUDE.md's TEST RULES require (limit-1/limit/limit+1).
- Standards (Property-Based Testing): extractProtectedBlocks (a sentinel
  parser) and the new parseBoundaryMoveTrailerValues (a declare/dedupe/conflict
  parser, bijective-shaped) had no fast-check property test. Added three: a
  render/parse bijectivity property for the trailer parser (mirroring the exact
  ADR-3942 sibling test's alphabet/idiom), a dedupe-is-idempotent property for
  the same parser, and a well-formed-sentinel-round-trips property for
  extractProtectedBlocks.

Then dispatched gsd-test on the resulting commit. It found a real bug the
reviews couldn't have caught (none of them can run inside gsd-test's sandbox):
checks 4/5's real-repo assertion failed against plan-phase's own split,
reporting DISK_PLANS/#3218-comment lines as "undeclared boundary moves" —
content Phase 2 (#4402) legitimately moved into detail/elaboration.md months
before this PR's Boundary-Move-Declared mechanism existed to require a
trailer for it. Root cause: `resolveBase()`'s own doc comment already documents
that no `origin/*` remote-tracking ref exists inside the gsd-test sandbox
container, and its fallback candidate (a bare `next` branch) can resolve to a
point in history that predates an already-merged, already-reviewed split —
making that split look "newly introduced" from the sandbox's vantage point.
Check 1 (completeness) already scopes itself correctly to only genuinely-new
detail paths (git diff status 'A'); checks 4 and 5 did not share that scoping,
so a stale base made them re-litigate a settled split retroactively. Fixed by
having checks 4/5 skip any split name check 1 already counted as newly-split —
their own premise ("did an EXISTING split shed/undeclare something") does not
apply to a split that is, from the resolved base's vantage point, brand new;
that is check 1's domain alone. Verified locally (25/25 tests pass via a
direct `node -e` require, since `node --test` is blocked in this repo) and via
re-reasoning through the exact real-repo scenario the gsd-test failure showed.

Also regenerated all 19 tests/fixtures/install-tree/*.json goldens — the
prior commit's deletion of gsd-core/references/compact-content-protected-content.md
was never reflected there, which is what golden-install-tree.test.cjs's other
19 failures in the same gsd-test run were.

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

* docs(#4403): backfill changeset pr number to 4497

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

* fix(#4403): isolate codex-config.test.cjs into its own chunk, root-causing the Windows CI failure

PR #4497's "full test (windows-latest, 24, shard 2/3)" job failed: run-tests
killed chunk 3/8 at the 600s per-chunk backstop, with codex-config.test.cjs
(weight 17.87, by far the chunk's dominant cost) packed alongside 39 other
files. Traced, not assumed:

- scripts/run-tests.cjs's own timeout-headroom comment for the OUTER
  per-shard timeout documents that "adding one test file reshuffled 115 of
  268 unit files between shards" — shard/chunk composition is architecturally
  known to be unstable to single-file additions, which is exactly what this
  PR's own new tests/compact-content-partition-guard.test.cjs is.
- A second comment, dated 2026-09-06 (one day before this PR, PR #4428's own
  CI), already documents the SAME chunk hitting the SAME 600s backstop with
  the SAME file (codex-config.test.cjs, "a genuinely MEASURED weight of
  17.87 — not a stale-table miss") dominating it — the fix then was cutting
  the Windows per-chunk budget from 60 to 40. That cut clearly was not
  enough: two documented incidents in two days, at two different budget
  settings, both centered on one file that alone consumes ~45% of even the
  reduced Windows budget.
- tests/test-timings.json's own header confirms its source data
  (test-events-linux-node22/24.jsonl) is Linux-only, and run-tests.cjs's own
  chunk-timeout diagnostic already prints "real Windows cost runs ~2.2x the
  recorded figure" — the packer's weight-balancing is working off data that
  is both stale (table last regenerated 2026-08-07) and known to
  underestimate the platform where the failure occurs.

Given codex-config.test.cjs is disproportionately heavy AND every companion
sharing its chunk is decided by a packing algorithm already documented as
reshuffling unpredictably on any new file, tuning the shared budget a third
time only moves the marginal line to wherever the next new file happens to
land — it does not remove the gamble. Isolating codex-config.test.cjs into
its own dedicated single-file chunk, unconditionally and on every platform,
removes it at the source: the file never enters the pool packChunks balances,
so no other file's packing changes, and no future single-file addition
(mine or anyone else's) can silently reintroduce this exact failure by
landing in its chunk.

Extracted as a small pure function, partitionIsolatedFiles (mirroring this
file's existing pattern of pulling packing/analysis logic out of main() for
in-process unit coverage — see computeSweepProtectSet, analyzeChunkEvents),
with 6 new tests in tests/run-tests-harness.test.cjs covering basename
matching across path separators, near-miss non-matches, the empty-list case,
and the isolated-set contents.

Root cause is now closed rather than papered over with a retry: this failure
is a property of one specific heavy file's chunk placement, not something
that recurs randomly. If codex-config.test.cjs itself is ever genuinely sped
up, this isolation can be revisited — this is a packing-side mitigation for
a known file's cost, not a claim the cost is irreducible.

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

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 16:44:53 -04:00
Michel Moreira
ae40529d31 chore(#4394): lint allowed-tools parity — Bash without Grep (#4431)
gen-plugin-skills.cjs --check already guarantees skills/*/SKILL.md matches
what commands/gsd/*.md generates, so the two trees cannot silently diverge
FROM EACH OTHER. Nothing guarded the shape #3085 actually found: a command
shipping Bash without Grep purely by omission, identical in both trees and
therefore invisible to a parity check that only compares them to each other.
That drift ran until 29 of 71 skills lacked a tool most of their siblings
declared, and a manual audit — not a gate — is what surfaced it.

Detection only: the lint never edits a command's allowed-tools.

Two failure classes, not one. Violations are the rule itself. Stale
exemptions are the other half: an entry whose command is gone, or which no
longer declares Bash without Grep, fails just as loudly. An exemption list
that can only grow becomes a list of things nobody re-examined, and a
pre-forgiven command silently absorbs the next omission.

That check earned its keep immediately. #4394 named eight exemptions from the
#3085 review — the six ns-* dispatchers, help, and surface — and seven of
them do not declare Bash at all, so the rule never reaches them. Listing them
would have pre-forgiven seven commands for a condition none of them has. Only
surface needs an entry.

The tests drive synthetic fixtures rather than the live corpus: asserting
"the real tree is clean" would say nothing about whether the rule can detect
anything, which is the exact failure mode this lint exists to close. The one
live-corpus arm asserts no stale exemptions — a property of this script's own
list — and deliberately does not pin a violation count, which would make it a
baseline every fix has to update.

Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-07 09:44:38 -04:00
Brenden Smerbeck
e54d3aa159 enhance(#4401): register workflow.compact_content as a validated config key (#4441)
* feat(#4401): register workflow.compact_content as a validated config key

- Add compact_content: false to the nested workflow object in
  gsd-core/bin/shared/config-defaults.manifest.json
- Add 'workflow.compact_content': false to SCHEMA_DEFAULTS in src/config.cts
  so an absent key resolves to false via config-get --raw
- validKeys entry in config-schema.manifest.json already present

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

* test(#4401): behavioral and boundary tests for workflow.compact_content

- 19 behavioral tests covering config-set/config-get round trip, invalid-shape
  rejection (banana, 42, empty string), the corrected null-unset semantics
  (#2046), absent-key resolution against config-defaults.manifest.json,
  config-new-project wiring, and doc-row shape assertions
- Drops the install-tree fixture-parity block (and its docstring item) that
  asserted gsd-core/references/compact-content-gate.md and
  gsd-core/workflows/compact/map-codebase.md fixture entries — those paths
  belong to #4402 and do not exist on this filtered branch

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

* docs(#4401): document workflow.compact_content in both config references

- One 4-cell row in docs/CONFIGURATION.md (workflow.* run)
- One 5-cell row under Workflow Fields in gsd-core/references/planning-config.md
- Both cross-reference ADR-4139

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

* chore(#4401): add changeset

- Added-type fragment, pr: 4401 (issue number; backfill to the real PR number
  is a required follow-up once the PR is opened, per D-08 and CHANGESET-PR-
  FIELD-DRIFT)

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

* chore(#4401): backfill changeset pr field to #4441

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

* fix(#4401): derive workflow.compact_content default from CONFIG_DEFAULTS

SCHEMA_DEFAULTS['workflow.compact_content'] hardcoded the literal false
instead of deriving it from CONFIG_DEFAULTS the way 3 of its 8 sibling
entries do (smart_zone_tokens, pr_strict, inline_plan_threshold), leaving
a single-source-of-truth drift risk: a future manifest-only edit to the
default could silently diverge from this literal, only caught later by
the D-03 test if it ever happened to manifest.

Adds compact_content to CONFIG_DEFAULTS in src/config-loader.cts and
derives SCHEMA_DEFAULTS from it in src/config.cts, matching the majority
sibling pattern. Found during maintainer review (review-open-prs) of
this PR.

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

* fix(#4401): map compact_content in config-field-docs NAMESPACE_MAP

The previous commit added compact_content to CONFIG_DEFAULTS in
src/config-loader.cts but missed the matching entry in
tests/config-field-docs.test.cjs's NAMESPACE_MAP, which maps flat
CONFIG_DEFAULTS keys to their namespaced doc form before checking
gsd-core/references/planning-config.md for a match. Without it, the
test looked for a bare `compact_content` doc reference instead of the
actual `workflow.compact_content` row, and failed:
"CONFIG_DEFAULTS keys missing from planning-config.md: compact_content".

Found by actually running gsd-test against the branch rather than
trusting the plausible-looking fix.

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

* test(#4401): register compact-content-4139 test in the docs-guard lane

tests/compact-content-4139.test.cjs's D-06 tests read docs/CONFIGURATION.md
directly (fs.readFileSync) to assert the workflow.compact_content doc row's
shape, which makes it a doc-reading test file under the #3753 docs-guard
lane. It was never added to scripts/docs-guard-registry.cjs's
DOCS_GUARD_TESTS map and carries no docs-guard-exempt marker, so
tests/ci-docs-guard-registry.test.cjs's registration lint correctly failed:
"compact-content-4139.test.cjs reads a docs/ path but is not registered in
the docs-guard lane and carries no docs-guard-exempt marker".

Registers it with ['docs/CONFIGURATION.md'] (the only real docs/-prefixed
path it reads; gsd-core/references/planning-config.md is outside this
registry's docs/ scope, matching the sibling config-field-docs.test.cjs
entry's existing convention).

Found by actually running gsd-test against the branch — this gap predates
the maintainer's config-loader.cts fix and was already present in the
original PR.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
Co-authored-by: sim <sim@local>
2026-09-06 19:52:59 -04:00
Tom Boucher
1c0acb2359 feat(#4422): block merging into next/main while the base branch's Tests run is red (#4428)
* feat(#4422): block merging into next/main while the base branch's Tests run is red

Adds a next-health job to test.yml that checks the base branch's own last
push-triggered Tests run via the GitHub API and fails the existing "Required
tests" required check when it's red, with a maintainer-applied "fix-next"
label as the explicit escape hatch for the fix-forward PR itself. No
branch-protection config change needed — it rides the already-required
check. The job is deliberately not gated behind preflight, same reasoning
as the changes job: a compute-free API read has nothing to save by waiting.

Documents the fix-next label in CONTRIBUTING.md and adds a property test
locking the CLEAN/RED/INDETERMINATE classification's iff-relationship.

This closes the second half of the 2026-09-06 RCA: three unrelated PRs
merged on top of an already-broken next before anyone noticed it was red.

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

* fix: close two zero-margin CI timing gaps found while verifying #4422

Discovered while watching this branch's own CI, root-caused via /diagnose
rather than dismissed as Windows flakiness:

1. tests/gsd-check-update-worker-atomic-cache.test.cjs's outer timeout
   (15000ms) exactly matched the inner npm-view timeout the worker wraps
   (NPM_VIEW_TIMEOUT_MS, gsd-core/bin/check-latest-version.cjs). A slow
   registry response raced two SIGKILLs at the same instant, killing the
   worker before it could catch its own timeout and degrade gracefully.
   Windows's shell-wrapped npm subprocess made the race lose more often
   there, but the zero margin was platform-agnostic. Fixed by giving the
   test real headroom (+10s) beyond the named constant it wraps, plus an
   invariant test so the two values can't silently collide again.

2. scripts/run-tests.cjs's per-chunk weight budget (MAX_FILES_PER_CHUNK)
   let a Windows full-matrix chunk that was well under budget by the
   Linux/macOS-calibrated weight table (~32/60 units) still exceed the
   600s wall-clock backstop — codex-config.test.cjs's genuinely-measured
   weight (17.87) doesn't transfer 1:1 to Windows's slower install/
   subprocess overhead. Windows now gets its own lower cap (40 vs 60).

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

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 17:29:54 -04:00
Tom Boucher
38e4ce5f62 fix(#4186): anchored status vocabulary, record-session arg guard, recount pin (#4381)
* fix(#4186): anchored status vocabulary, record-session arg guard, recount pin

Three defects from #4186:

1. normalizeStateStatus ran a first-match-wins SUBSTRING chain over the
   free-prose body Status field, so prose merely mentioning a status word
   was silently rewritten to a credible wrong token (a .planning/ path in
   Italian prose -> status: planning; verifica -> verifying; completezza ->
   completed). Recognition is now an ANCHORED whole-field match against a
   declared vocabulary (STATUS_EXACT_TOKENS + STATUS_ANCHORED_PATTERNS,
   state-document.cts) — case/whitespace-tolerant, branch-order artifacts
   preserved (Planning complete -> planning; Phase complete — ready for
   verification -> verifying). The recorded lenient fallback (#3873 row 26)
   stands: unrecognized prose passes through verbatim. Read-side consumers
   (W011, statusline) ride the same function.

2. The progress recount skew (stray *-SUMMARY.md inflating
   completed_plans) is already dead on next via #1988/PR #2016
   (countMatchedSummaries pairs summaries to plans) — verified live and
   pinned with regression rows composed against the #4129/#4359 ratchet.

3. state record-session with no args executed and wrote STATE.md; it now
   errors like state update (stopped-at or resume-file required), handler-
   side so SDK callers are covered too. Four tests pinning the bare-call
   write are updated to the new contract.

* fix(#4186): update status pins to the anchored vocabulary contract

Bench round 1 follow-ups:

- Legacy bare 'Milestone complete' kept as reader-side vocabulary
  (ADR-2207 removed the writers, not recognition of legacy files).
- state.test pins updated: 'Paused at Plan 3' and round-trip
  'Executing Plan 5' were pins of the substring guessing itself —
  the round-trip now uses the real handler form 'Executing Phase 5'.
- record-session no-op/no-fields tests repurposed to the usage-error
  contract (CLI + SDK-level ExitError), byte-unchanged assertions kept.
- statusline tests repinned: vocabulary values collapse to keywords;
  narratives render the documented first-word fallback instead of a
  guessed token. Hook doc comment updated to match.
- docs-guard exempt baseline: state.test.cjs now cites docs/CLI-TOOLS.md.
- docs/CLI-TOOLS.md: record-session signature notes the required flag.

* fix(#4186): repair a dangling sentence in the schema docstring

* test(#4186): bound the completed_plans scan regex (#2128 class)

* chore(#4186): backfill changeset PR number

---------

Co-authored-by: sim <sim@local>
2026-09-06 17:08:24 -04:00
Tom Boucher
2920bbc022 fix(#4421): rescind #494's macOS full-matrix skip on changed test files (#4427) 2026-09-06 15:42:23 -04:00
Tom Boucher
b7406b293f enhance(#2618): render pending todos as one bounded bullet per todo (#4384) 2026-09-06 08:06:39 -04:00
Tom Boucher
0be5bf865a enhance(#3783): audit-uat summary segments current-milestone vs archived debt (#4336)
* test(#3783): add failing coverage for audit-uat summary segmentation

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

* fix(#3783): segment audit-uat summary into current_milestone and archived buckets

Additive: current_milestone/archived are new; total_items, total_files, parse_gap_files, by_phase, and by_category are unchanged.

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

* docs(#3783): add changeset fragment for audit-uat summary segmentation

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

* chore(#3783): allowlist the new audit-uat-summary-segmentation test file

lint-test-file-count.cjs baselines the "audit" module (keyed off bin/lib/audit.cjs)
at 6 pre-existing files; this adds the new dedicated suite as a 7th, matching the
module's existing one-file-per-feature-slice precedent.

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

* test(#3783): fix phase/file number mismatch in the mixed-milestone fixture

The active phase fixture used dir "02-current" with file "01-UAT.md" — a
cross-phase stray per phase-id.cts's isPhaseArtifact/scopeToPhase (#3511),
so the file was silently excluded from the scan and current_milestone read
{files:0, items:0} instead of {files:1, items:1}. Confirmed by direct CLI
run against a hand-built fixture before recommitting. Renamed the file to
02-UAT.md to match its directory's phase number, matching every other
fixture in this suite.

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

* docs(#3783): backfill changeset PR number to 4336

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

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 19:03:14 -04:00
Cody Anderson
77e2472ca0 enhance(#4221): replace installer Read() deny rules with a managed secret-read guard hook (#4236)
* feat(#4221): gsd-secret-read-guard PreToolUse hook + registration

Add hooks/gsd-secret-read-guard.js, a blocking PreToolUse guard on
Read|Grep|Bash that denies reads of .env, .env.<suffix> and .secrets
(the .env.example/.sample/.template/.dist templates stay readable).
Read checks file_path; Grep checks an explicit path and judges the glob
per brace alternative; Bash runs a two-pass token scan (quotes, comments,
redirects with fd digits, separators, $( )/backtick/<( ) recursion,
heredoc bodies never scanned as commands, nested bash -c/eval rescans,
git <ref>:<path> shapes) with a closed non-reading exemption set for
existence checks. Fail-open crash policy; 1 MiB commands are denied as
command-too-large; more than 64 glob alternatives as glob-too-complex.

Why: Claude Code 2.1.259 makes every `cd DIR && grep …` compound prompt
for approval whenever any Read() deny rule exists, even in auto mode. A
hook denial is not a permission rule and never arms that check. The
installer-written deny rules are retired in the follow-up commit.

Registration: hooks.json (Read|Grep|Bash, timeout 5), build-hooks
HOOKS_TO_COPY, managed-hooks-registry, runtime-hooks-surface (blocking
guard with BLOCKING_GUARD_TIMEOUT_S; Kimi ReadFile|Grep|Shell),
shell-command-projection managed sets, installer-migration-report,
OpenCode/Kilo plugin (grep tool mapping, include -> glob, dispatch),
docs tables in five locales, ADR-766 always-on list, regen:derived
fixtures, and a new table-driven unit suite.

* test(#4221): pin the secret-read guard in existing hook gates

Register gsd-secret-read-guard.js in every existing hook gate: the
hooks-crash-policy table (deny row; 6 -> 7 deny cases), plugin-manifest
REQUIRED_HOOKS and its Read|Grep|Bash group, docs-hooks-table-parity
EXPECTED_SURFACE_HOOKS, install.test MANAGED_JS_HOOKS, install-minimal-
hooks JS_HOOKS/BLOCKING_GUARDS, portable-node-runner GUARD_HOOKS,
kilo-upgrades PLUGIN_GUARD_HOOKS, the Kimi normalization-parity and
typed-payload floors, the OpenCode adapter (grep mapping, include ->
glob, three dispatch tests) and a Kimi TOML matcher assertion.

* fix(#4221): retire installer Read() deny rules (legacy filter)

Rename GSD_CLAUDE_DENY_PERMISSIONS to GSD_CLAUDE_LEGACY_DENY_PERMISSIONS
and stop adding the three Read(.env) / Read(.env.*) / Read(.secrets)
strings. mergeClaudePermissions now only filters them out of an existing
permissions.deny: an absent deny key stays absent, a malformed one is
still repaired to [], and an array emptied by the filter is deleted so
no `"deny": []` residue is left. Uninstall filters the same legacy list
and, symmetric with the Antigravity branch, drops an emptied allow or
deny key and an emptied permissions object.

Unlike the #2278 allow-side migration there is no surviving current
deny list, so the constant is renamed rather than mirrored. Removal is
byte-exact: a hand-written identical rule is indistinguishable from the
installer's and is removed too (the manifest never recorded permission
strings). USER-GUIDE and CONTEXT.md updated.

* test(#4221): flip install-regressions deny-rule assertions to the retired shape

The fresh-merge, non-destructive merge, idempotency, end-to-end install,
reinstall and uninstall assertions now expect no Read(.env*) deny rules
and no permissions.deny key on a fresh install; the deny:null repair case
is kept. A new describe block covers the legacy filter: retired strings
removed with a user entry kept, partial sets, near-miss strings
untouched, idempotency, GSD-only deny array deleted, a pre-existing
empty deny preserved, and uninstall symmetry for allow/deny/permissions.

* chore(#4221): add changeset fragment for PR #4236

* fix(#4221): case-fold names; scan shell stdin and xargs pipes

Review round 1 (trek-e):

- Blocker: secret-name matching is now case-insensitive in the Read,
  Grep (path and glob) and Bash paths, so `.ENV` / `.Secrets` on a
  case-insensitive filesystem are recognized as the same secret file.
- Major: a shell interpreter's script is now scanned wherever it comes
  from. The tokenizer keeps heredoc bodies as per-segment tokens and
  records separator operators; pass 2 groups by segment id and resolves
  bash/sh/zsh/dash/ksh/su invocation mode: `-c` (including combined
  `-lc`) scans the script operand, a file operand is checked as a file
  (a `<( )` operand's echo/printf output is reconstructed), otherwise
  stdin is the script and heredocs, here-strings and a piped echo/printf
  source are scanned. `eval` joins all its operands; `source`/`.` handle
  process substitution. Data heredocs (`cat <<EOF`, the commit-message
  shape) stay unscanned.
- Major: `… | xargs <cmd>` checks the upstream segment's operands as
  file names when the sub-command reads (`echo .env | xargs cat`,
  `find . -name .env | xargs cat`); `-a`/`--arg-file` suppresses the
  inference; a shell sub-command's `-c` script is scanned.

Header, USER-GUIDE bullet and changeset updated; documented gaps now
include piped scripts from non-echo sources and `exec`/`timeout`
wrappers. 60 new suite cases pin the block and allow shapes.

---------

Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-05 04:00:08 -04:00
JusticeWay
0fca71eaae enhance(#2529): cover every workflow with response-language directives + CI lint (#2558)
* enhance(#2529): cover every workflow with response-language directives + CI lint

Every workflow now carries response-language coverage in one of three forms,
and a CI lint keeps it that way.

- 43 workflows load the new shared reference,
  `gsd-core/references/response-language-directive.md`, by eager `@`-import.
- Lazy-loaded modes/steps/templates, which cannot rely on an eager import,
  carry an exact inline directive; 35 such paths are pinned by exact path.
- Fragments dispatched by a covered parent inherit coverage, proven per file
  rather than granted per directory.

The 45 workflows whose directive covered only "questions, prompts, and
explanations" now name inter-tool narration, which is the defect #2529
reports: the running commentary between tool calls stayed English while the
answers around it were translated.

`scripts/lint-response-language-coverage.cjs` enforces it and fails closed on
three independent discovery failures (unreadable catalog, empty catalog,
unfollowed symlink). It resolves which reference a workflow imports and applies
the same four-predicate test to that file, so a weakened shared reference
uncovers its importers instead of passing silently, reported once as a systemic
failure rather than 43 times. The walk follows symlinked subtrees with a
realpath cycle bound. `lint:ci` invokes it by name.

REQ-LANG-03 and REQ-LANG-04 state the contract in docs/FEATURES.md;
REQ-LANG-04 names the two forms that satisfy it ("narration", "between tool
calls") rather than enumerating class members an author cannot use verbatim,
and a test pins that text to what the matcher accepts.

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

* chore(#2529): register the coverage test in the docs-guard lane

`107eb8c1` (#3787) landed the docs-guard lane on `next` while this PR was
open: a test that reads a `docs/` path must be named in
`scripts/docs-guard-registry.cjs` or carry a `docs-guard-exempt` marker,
so the guards that read a doc run on the PR that changes it.

`tests/response-language-coverage.test.cjs` reads `docs/FEATURES.md` -- it
extracts every form REQ-LANG-04 offers an author and runs each through the
matcher that enforces it. Registration, not exemption, is the correct side
of that gate: a reword of the requirement with no code change is precisely
the diff this test exists to catch, and it is the diff the lane would
otherwise skip.

Registered narrowly (`['docs/FEATURES.md']`) rather than with the `'*'`
sentinel, so an unrelated docs change does not pull this test into the lane.

Verified: lint-docs-guard-registration 0 violations, tests/ci-docs-guard-registry.test.cjs
51/51, lint:ci exit 0.

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

* chore(#2529): consolidate this PR's emitted-growth acks into its own fragment

This PR ripples emitted bytes across 85 workflow paths. Until now each ripple
was acknowledged by appending to whichever live fragment owned that path,
because two ack sources may never name the same path.

`a84f7563` (#3078) swept all 45 fully-spent fragments off `next`. Forty-two of
the paths this PR grows were owned by swept fragments, so those keys are now
unowned and this PR's own fragment declares them directly -- one path, one
source, and no dependence on a fragment that no longer exists. Each adopted
entry keeps its measurement and records where it came from.

Two paths are handled differently, because the sweep did not free them:

- `review.md` is now owned by `3034-parallel-reviewer-lanes.json`, which
  landed on `next` after the sweep. Its entry is live, so the old route still
  applies: this PR's note is appended to that entry rather than declared a
  second time.
- `plan-review-convergence.md` keeps the arrangement made in round 24.

Result: 3 fragments in the directory, 85 keys in this PR's own,
0 cross-source duplicates. `lint-emitted-drift-ack` exit 0,
`tests/emitted-attribution.test.cjs` green.

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

* fix(#2529): move REQ-LANG-03/04 into the feature fragment that now generates them

`36375513` (#3845) made docs/FEATURES.md a generated projection of
docs/features/*.md, marked "do not edit by hand". This PR wrote REQ-LANG-03
and REQ-LANG-04 straight into the generated file, so the rebase left the
requirement present in the projection and absent from its source -- the next
regeneration would have deleted both, and `tests/features-index-gate.test.cjs`
was already red on the mismatch.

Both requirements now live in docs/features/response-language-config.md
alongside REQ-LANG-01 and -02. Regenerating produces a docs/FEATURES.md that is
byte-identical to the committed one, so the text this PR shipped is unchanged --
only its source of truth moved to where #3840 put it.

The docs-guard registration is widened to name the fragment as well as the
projection. The requirement's source is the fragment now, and an edit there
that skips regeneration would otherwise reach this guard through neither path.

Verified: features-index-gate 68/68, lint-docs-guard-registration 0 violations,
ci-docs-guard-registry + response-language-coverage 142/142.

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

* chore(#2529): hand the plan-phase ack back to its new live owner

`c933184b` (#3825) landed `3172-stated-failing-direction.json` on `next` after
fragment had adopted that path when the sweep left it unowned, so the merged
tree named it from two sources -- a hard failure in
`scripts/lint-emitted-drift-ack.cjs`.

The path has a live owner again, so the append route applies: this PR's note
joins that entry, carrying its own measurement, and the key is dropped from
this PR's fragment (84 keys left, the others untouched). The provenance
sentence written for the swept-fragment case is removed rather than reused --
this path was never orphaned, so that account of it would be false.

Same shape as `review.md` and `plan-review-convergence.md`: ownership is a
property of the merged tree, and a fragment landing upstream after a push can
reclaim a key no local check would have flagged.

Verified: lint-emitted-drift-ack exit 0, lint:ci exit 0.

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

* fix(#2529): state byte figures that are true against the tree

The reference claimed `execute-phase.md` has "2 bytes of headroom under the
ceiling named below". That was true when the sentence was written -- the file
sat at 93398 against the 93400 comfort assert -- and upstream has since shrunk
it to 91493 against a 93600 hard ceiling, so the figure now understates the
headroom by three orders of magnitude. The rationale the sentence supports does
not depend on the number, so the number is gone rather than refreshed: a
restated figure would go stale again on the next upstream edit, and nothing
parses it.

Audited every other numeric claim this PR ships the same way, mechanically
against the merge base: all 82 FILE-delta claims in the ack fragment match the
real per-file delta exactly, and the 1,629-byte reference and 63-byte import
line check out. One class was imprecise: the 41 notes for workflows whose
inline directive was rewritten in place quoted the conversion counterfactual as
"+1,692 bytes more loaded context", which is the reference form's whole weight,
not the increase over the inline directive those files already carry. Each now
names both quantities and the net (+1,605 / +1,609 / +1,584).

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

* fix(#2529): one rule for pinned vs inherited coverage, and the docs to pick it

Review measured that 14 of the 35 pinned fragments would pass by inheritance
anyway, and that the PR asserted both readings at once: inheritance is real
coverage (so those 14 pins are noise) or it is not (so 30 inheriting fragments
are green-but-uncovered). Only one can be true.

Inheritance is real: the predicate proves it per file -- the parent must
dispatch this exact path from a read/execute context AND be covered itself --
so the parent's directive is in the loaded context by the time the fragment is
read. The 14 pins are therefore removed along with the directive lines they
pinned, and those files inherit like the 30 structurally identical ones. The
rule is now stated where the set is declared, and enforced from the other side
by a test: no member of the pinned set may be one that would have inherited.
That is what decides the form for the next fragment.

- pinned set 35 -> 21; 14 workflow files revert to their base content
- `findViolations` no longer returns early on a pinned path: a file that becomes
  eagerly loaded and takes the shared reference is strictly better off, and the
  gate must not red that. The reference form is admitted because its own wording
  is validated in turn; an arbitrary reworded inline line still fails.
- the reference-directive cache is keyed by size and mtime, not by path alone,
  so a rewritten reference re-asked in one process no longer returns the stale
  verdict
- `carriesInlineDirective` names its negation blindness: four independent hits
  read vocabulary, not polarity
- the real-tree scan asserts each source produced files instead of `> 152`, a
  constant that read as the workflow count and would have passed a scan that
  lost one of its two directories
- the pinned-set size assertion goes the same way: the size follows from the
  rule, so the rule is what the suite asserts

Docs, for the gate that now governs every future workflow:
- `docs/contributing/response-language-coverage.md` -- why the narration class
  is the discriminator, the four coverage forms, the decision order that picks
  one, the pinned line, and what each failure message means
- a row in CONTRIBUTING.md's CI checks table, matching the docs-guard row
- `docs/CONFIGURATION.md` points at it from the `response_language` entry

Also: the changeset said 45 reworded workflows; it is 44 (42 @-reference + 21
pinned + 44 rewritten = 107 touched). That text ships to CHANGELOG.md.

`3707-parse-gap-reporting.json` landed on `next` reclaiming `audit-uat.md` and
`progress.md`; both handed back by the append route, leaving 82 keys here.

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

* fix(#2529): correct the reference-taker count, 43 -> 42

The ack notes said the import line is byte-identical "in each of the 43
workflows that take the reference" and that the alternative would be "43 inline
copies". The shared reference has 42 importers; the 43rd file in review's table
is `execute-phase.md`, which imports the OTHER reference. Corrected in all 41
notes that carry the sentence, across this PR's fragment and the two it appends
to.

Found by re-running the numeric audit from the previous round after the rebase,
which also re-verified all 84 FILE-delta claims against the new base -- all
exact.

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

* chore(#2529): migrate the emitted-drift ack from a fragment to commit trailers

ADR-3942 (#3954) landed while this PR was open: the acknowledgment is now a commit
trailer and tests/emitted-drift-acks/ no longer exists. The fragment is deleted and
each key it declared becomes one trailer, reasons unchanged.

The four keys this PR had handed to 3034-*, 3172-* and 3707-* under the one-source
rule come home here. That rule was the whole reason for the hand-backs, and the
trailer model has no shared namespace to collide in -- five of this PR's rounds were
spent on exactly those collisions.

Emitted-Drift-Ack-Growth: add-backlog.md — #2529 — RESTATED in round 10, superseding this PR's earlier "+63 bytes, prose only" wording, which reported a file delta as if it were the whole cost. The workflow gains the shared response-language directive as a single `@`-reference line. FILE delta: +63 bytes, byte-identical in each of the 42 workflows that take the reference. LOADED-CONTEXT delta: +1,692 bytes per workflow — the 63-byte import line plus the 1,629 bytes of `gsd-core/references/response-language-directive.md`, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"). The repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so they see 63 of those 1,692 bytes; the remaining 1,629 are declared here because no gate reads them. The eager import is accepted on its merits, not hidden: 42 inline copies would be 43 places for the wording to drift, and the reference is the one place it is maintained. Prose only: no step, gate, tool invocation, or subagent dispatch shape changed.
Emitted-Drift-Ack-Growth: add-phase.md — #2529 — RESTATED in round 10, superseding this PR's earlier "+63 bytes, prose only" wording, which reported a file delta as if it were the whole cost. The workflow gains the shared response-language directive as a single `@`-reference line. FILE delta: +63 bytes, byte-identical in each of the 42 workflows that take the reference. LOADED-CONTEXT delta: +1,692 bytes per workflow — the 63-byte import line plus the 1,629 bytes of `gsd-core/references/response-language-directive.md`, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"). The repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so they see 63 of those 1,692 bytes; the remaining 1,629 are declared here because no gate reads them. The eager import is accepted on its merits, not hidden: 42 inline copies would be 43 places for the wording to drift, and the reference is the one place it is maintained. Prose only: no step, gate, tool invocation, or subagent dispatch shape changed.
Emitted-Drift-Ack-Growth: add-tests.md — #2529 MAJOR 1 (round 10): the workflow's pre-existing inline response-language directive is rewritten IN PLACE so the sentence names inter-tool NARRATION explicitly — narration between tool calls, status updates, progress notes, findings — instead of only "questions, prompts, and explanations". That older wording is the defect #2529 reports (it leaves the running commentary between tool calls in English while the answers around it are translated), and `scripts/lint-response-language-coverage.cjs` had been certifying it as coverage, so the gate legitimised the bug. +87 bytes, prose only: no step, gate, tool invocation, or subagent dispatch shape changed. FILE delta and LOADED-CONTEXT delta are both +87 here, and that identity is the point — the directive was deliberately NOT converted to an `@`-reference, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"), so the conversion would have bought a smaller measured file at a cost of 1,692 bytes of loaded context per workflow — the 63-byte import line plus the 1,629-byte reference — against the 87 bytes this inline directive costs, a net +1,605. Stated plainly because the gates cannot state it: the repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so an `@`-reference conversion would have READ as a smaller change to every gate in the repo while costing 1,605 bytes more loaded context per invocation.
Emitted-Drift-Ack-Growth: add-todo.md — #2529 MAJOR 1 (round 10): the workflow's pre-existing inline response-language directive is rewritten IN PLACE so the sentence names inter-tool NARRATION explicitly — narration between tool calls, status updates, progress notes, findings — instead of only "questions, prompts, and explanations". That older wording is the defect #2529 reports (it leaves the running commentary between tool calls in English while the answers around it are translated), and `scripts/lint-response-language-coverage.cjs` had been certifying it as coverage, so the gate legitimised the bug. +87 bytes, prose only: no step, gate, tool invocation, or subagent dispatch shape changed. FILE delta and LOADED-CONTEXT delta are both +87 here, and that identity is the point — the directive was deliberately NOT converted to an `@`-reference, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"), so the conversion would have bought a smaller measured file at a cost of 1,692 bytes of loaded context per workflow — the 63-byte import line plus the 1,629-byte reference — against the 87 bytes this inline directive costs, a net +1,605. Stated plainly because the gates cannot state it: the repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so an `@`-reference conversion would have READ as a smaller change to every gate in the repo while costing 1,605 bytes more loaded context per invocation.
Emitted-Drift-Ack-Growth: ai-integration-phase.md — #2529 MAJOR 1 (round 10): the workflow's pre-existing inline response-language directive is rewritten IN PLACE so the sentence names inter-tool NARRATION explicitly — narration between tool calls, status updates, progress notes, findings — instead of only "questions, prompts, and explanations". That older wording is the defect #2529 reports (it leaves the running commentary between tool calls in English while the answers around it are translated), and `scripts/lint-response-language-coverage.cjs` had been certifying it as coverage, so the gate legitimised the bug. +87 bytes, prose only: no step, gate, tool invocation, or subagent dispatch shape changed. FILE delta and LOADED-CONTEXT delta are both +87 here, and that identity is the point — the directive was deliberately NOT converted to an `@`-reference, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"), so the conversion would have bought a smaller measured file at a cost of 1,692 bytes of loaded context per workflow — the 63-byte import line plus the 1,629-byte reference — against the 87 bytes this inline directive costs, a net +1,605. Stated plainly because the gates cannot state it: the repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so an `@`-reference conversion would have READ as a smaller change to every gate in the repo while costing 1,605 bytes more loaded context per invocation. Re-homed in round 16: the fragment that carried this sentence (`3423-required-reading.json`) was retired on `next` by ddf85287 (fix(#3357), #3513), and no fragment on `next` declares this path now. The ack therefore returns to this PR's own fragment, which is the only live source for it — the change to the path is this PR's.
Emitted-Drift-Ack-Growth: analyze-dependencies.md — #2529 — RESTATED in round 10, superseding this PR's earlier "+63 bytes, prose only" wording, which reported a file delta as if it were the whole cost. The workflow gains the shared response-language directive as a single `@`-reference line. FILE delta: +63 bytes, byte-identical in each of the 42 workflows that take the reference. LOADED-CONTEXT delta: +1,692 bytes per workflow — the 63-byte import line plus the 1,629 bytes of `gsd-core/references/response-language-directive.md`, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"). The repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so they see 63 of those 1,692 bytes; the remaining 1,629 are declared here because no gate reads them. The eager import is accepted on its merits, not hidden: 42 inline copies would be 43 places for the wording to drift, and the reference is the one place it is maintained. Prose only: no step, gate, tool invocation, or subagent dispatch shape changed.
Emitted-Drift-Ack-Growth: audit-fix.md — #2529 — RESTATED in round 10, superseding this PR's earlier "+63 bytes, prose only" wording, which reported a file delta as if it were the whole cost. The workflow gains the shared response-language directive as a single `@`-reference line. FILE delta: +63 bytes, byte-identical in each of the 42 workflows that take the reference. LOADED-CONTEXT delta: +1,692 bytes per workflow — the 63-byte import line plus the 1,629 bytes of `gsd-core/references/response-language-directive.md`, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"). The repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so they see 63 of those 1,692 bytes; the remaining 1,629 are declared here because no gate reads them. The eager import is accepted on its merits, not hidden: 42 inline copies would be 43 places for the wording to drift, and the reference is the one place it is maintained. Prose only: no step, gate, tool invocation, or subagent dispatch shape changed. Re-homed in round 19: this PR declared the path in its own fragment, and `3602-workflow-subagent-model-resolution.json` landed on `next` declaring it too. One path takes exactly one ack source, so the sentence moves here and the key leaves ours. Re-homed from `3602-workflow-subagent-model-resolution.json` in round 26: `a84f7563` (#3078) swept that fragment as all-spent, so this path is unowned and this PR's own fragment declares it directly.
Emitted-Drift-Ack-Growth: audit-milestone.md — #2529 — RESTATED in round 10, superseding this PR's earlier "+63 bytes, prose only" wording, which reported a file delta as if it were the whole cost. The workflow gains the shared response-language directive as a single `@`-reference line. FILE delta: +63 bytes, byte-identical in each of the 42 workflows that take the reference. LOADED-CONTEXT delta: +1,692 bytes per workflow — the 63-byte import line plus the 1,629 bytes of `gsd-core/references/response-language-directive.md`, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"). The repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so they see 63 of those 1,692 bytes; the remaining 1,629 are declared here because no gate reads them. The eager import is accepted on its merits, not hidden: 42 inline copies would be 43 places for the wording to drift, and the reference is the one place it is maintained. Prose only: no step, gate, tool invocation, or subagent dispatch shape changed. Re-homed from `2962-zsh-nomatch-for-glob-portability.json` in round 26: `a84f7563` (#3078) swept that fragment as all-spent, so this path is unowned and this PR's own fragment declares it directly.
Emitted-Drift-Ack-Growth: audit-uat.md — A live/archived split was added and then reverted on this branch (see `$comment`): the split's extra rule in `initialize`, the narrowed Unparsed-table filter, and the separate 'Unparsed UAT Files in Archived Milestones' informational section are all removed, so the file settles at origin/next 5582 -> 7124 bytes (+1542, final). #2529 — RESTATED in round 10, superseding this PR's earlier "+63 bytes, prose only" wording, which reported a file delta as if it were the whole cost. The workflow gains the shared response-language directive as a single `@`-reference line. FILE delta: +63 bytes, byte-identical in each of the 42 workflows that take the reference. LOADED-CONTEXT delta: +1,692 bytes per workflow — the 63-byte import line plus the 1,629 bytes of `gsd-core/references/response-language-directive.md`, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"). The repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so they see 63 of those 1,692 bytes; the remaining 1,629 are declared here because no gate reads them. The eager import is accepted on its merits, not hidden: 42 inline copies would be 43 places for the wording to drift, and the reference is the one place it is maintained. Prose only: no step, gate, tool invocation, or subagent dispatch shape changed.
Emitted-Drift-Ack-Growth: autonomous.md — #2529 — RESTATED in round 10, superseding this PR's earlier "+63 bytes, prose only" wording, which reported a file delta as if it were the whole cost. The workflow gains the shared response-language directive as a single `@`-reference line. FILE delta: +63 bytes, byte-identical in each of the 42 workflows that take the reference. LOADED-CONTEXT delta: +1,692 bytes per workflow — the 63-byte import line plus the 1,629 bytes of `gsd-core/references/response-language-directive.md`, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"). The repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so they see 63 of those 1,692 bytes; the remaining 1,629 are declared here because no gate reads them. The eager import is accepted on its merits, not hidden: 42 inline copies would be 43 places for the wording to drift, and the reference is the one place it is maintained. Prose only: no step, gate, tool invocation, or subagent dispatch shape changed. Re-homed in round 16: `3210-autonomous-precondition-gate.json` landed on `next` in 8fc88f66 (fix(#3210), #3528) and declares this path today. One path takes exactly one ack source, so the sentence moves here and the key leaves this PR's fragment. Re-homed from `3210-autonomous-precondition-gate.json` in round 26: `a84f7563` (#3078) swept that fragment as all-spent, so this path is unowned and this PR's own fragment declares it directly.
Emitted-Drift-Ack-Growth: check-todos.md — #2529 MAJOR 1 (round 10): the workflow's pre-existing inline response-language directive is rewritten IN PLACE so the sentence names inter-tool NARRATION explicitly — narration between tool calls, status updates, progress notes, findings — instead of only "questions, prompts, and explanations". That older wording is the defect #2529 reports (it leaves the running commentary between tool calls in English while the answers around it are translated), and `scripts/lint-response-language-coverage.cjs` had been certifying it as coverage, so the gate legitimised the bug. +87 bytes, prose only: no step, gate, tool invocation, or subagent dispatch shape changed. FILE delta and LOADED-CONTEXT delta are both +87 here, and that identity is the point — the directive was deliberately NOT converted to an `@`-reference, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"), so the conversion would have bought a smaller measured file at a cost of 1,692 bytes of loaded context per workflow — the 63-byte import line plus the 1,629-byte reference — against the 87 bytes this inline directive costs, a net +1,605. Stated plainly because the gates cannot state it: the repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so an `@`-reference conversion would have READ as a smaller change to every gate in the repo while costing 1,605 bytes more loaded context per invocation.
Emitted-Drift-Ack-Growth: cleanup.md — #2529 MAJOR 1 (round 10): the workflow's pre-existing inline response-language directive is rewritten IN PLACE so the sentence names inter-tool NARRATION explicitly — narration between tool calls, status updates, progress notes, findings — instead of only "questions, prompts, and explanations". That older wording is the defect #2529 reports (it leaves the running commentary between tool calls in English while the answers around it are translated), and `scripts/lint-response-language-coverage.cjs` had been certifying it as coverage, so the gate legitimised the bug. +87 bytes, prose only: no step, gate, tool invocation, or subagent dispatch shape changed. FILE delta and LOADED-CONTEXT delta are both +87 here, and that identity is the point — the directive was deliberately NOT converted to an `@`-reference, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"), so the conversion would have bought a smaller measured file at a cost of 1,692 bytes of loaded context per workflow — the 63-byte import line plus the 1,629-byte reference — against the 87 bytes this inline directive costs, a net +1,605. Stated plainly because the gates cannot state it: the repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so an `@`-reference conversion would have READ as a smaller change to every gate in the repo while costing 1,605 bytes more loaded context per invocation. Re-homed in round 18: this PR declared the path in its own fragment, and `2142-quick-task-archival.json` landed on `next` declaring it too. One path takes exactly one ack source, so the sentence moves here and the key leaves ours. Re-homed from `2142-quick-task-archival.json` in round 26: `a84f7563` (#3078) swept that fragment as all-spent, so this path is unowned and this PR's own fragment declares it directly.
Emitted-Drift-Ack-Growth: code-review-fix.md — #2529 — RESTATED in round 10, superseding this PR's earlier "+63 bytes, prose only" wording, which reported a file delta as if it were the whole cost. The workflow gains the shared response-language directive as a single `@`-reference line. FILE delta: +63 bytes, byte-identical in each of the 42 workflows that take the reference. LOADED-CONTEXT delta: +1,692 bytes per workflow — the 63-byte import line plus the 1,629 bytes of `gsd-core/references/response-language-directive.md`, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"). The repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so they see 63 of those 1,692 bytes; the remaining 1,629 are declared here because no gate reads them. The eager import is accepted on its merits, not hidden: 42 inline copies would be 43 places for the wording to drift, and the reference is the one place it is maintained. Prose only: no step, gate, tool invocation, or subagent dispatch shape changed. Re-homed in round 13: `3190-code-review-fix-auto-rewrite-review.json` landed on `next` in 1d5d7795 (fix(#3190), #3434) and declares this path too. One path takes exactly one ack source, so the sentence moves here and the key leaves this PR's fragment. Re-homed from `3190-code-review-fix-auto-rewrite-review.json` in round 26: `a84f7563` (#3078) swept that fragment as all-spent, so this path is unowned and this PR's own fragment declares it directly.
Emitted-Drift-Ack-Growth: code-review.md — #2529 — RESTATED in round 10, superseding this PR's earlier "+63 bytes, prose only" wording, which reported a file delta as if it were the whole cost. The workflow gains the shared response-language directive as a single `@`-reference line. FILE delta: +63 bytes, byte-identical in each of the 42 workflows that take the reference. LOADED-CONTEXT delta: +1,692 bytes per workflow — the 63-byte import line plus the 1,629 bytes of `gsd-core/references/response-language-directive.md`, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"). The repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so they see 63 of those 1,692 bytes; the remaining 1,629 are declared here because no gate reads them. The eager import is accepted on its merits, not hidden: 42 inline copies would be 43 places for the wording to drift, and the reference is the one place it is maintained. Prose only: no step, gate, tool invocation, or subagent dispatch shape changed. Re-homed in round 19: the fragment that carried this sentence (`3503-diff-base-scope-anchor.json`) was retired on `next` by 2fca0e17 (enhance(#2554), #3695), and `2554-code-review-depth-overrides.json` declares this path today. One path takes exactly one ack source, so the sentence follows the path to its live owner. Re-homed from `2554-code-review-depth-overrides.json` in round 26: `a84f7563` (#3078) swept that fragment as all-spent, so this path is unowned and this PR's own fragment declares it directly.
Emitted-Drift-Ack-Growth: complete-milestone.md — #2529 MAJOR 1 (round 10): the workflow's pre-existing inline response-language directive is rewritten IN PLACE so the sentence names inter-tool NARRATION explicitly — narration between tool calls, status updates, progress notes, findings — instead of only "questions, prompts, and explanations". That older wording is the defect #2529 reports (it leaves the running commentary between tool calls in English while the answers around it are translated), and `scripts/lint-response-language-coverage.cjs` had been certifying it as coverage, so the gate legitimised the bug. +87 bytes, prose only: no step, gate, tool invocation, or subagent dispatch shape changed. FILE delta and LOADED-CONTEXT delta are both +87 here, and that identity is the point — the directive was deliberately NOT converted to an `@`-reference, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"), so the conversion would have bought a smaller measured file at a cost of 1,692 bytes of loaded context per workflow — the 63-byte import line plus the 1,629-byte reference — against the 87 bytes this inline directive costs, a net +1,605. Stated plainly because the gates cannot state it: the repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so an `@`-reference conversion would have READ as a smaller change to every gate in the repo while costing 1,605 bytes more loaded context per invocation. Re-homed in round 15: the fragment carrying it (`3458-audit-open-acknowledge-wiring.json`) was retired on `next` and the path is declared by `3409-unreachable-guard-arms.json` today. One path takes exactly one ack source, so the sentence follows the path to its live owner. Re-homed from `3409-unreachable-guard-arms.json` in round 26: `a84f7563` (#3078) swept that fragment as all-spent, so this path is unowned and this PR's own fragment declares it directly.
Emitted-Drift-Ack-Growth: debug.md — #2529 MAJOR 1 (round 10): the workflow's pre-existing inline response-language directive is rewritten IN PLACE so the sentence names inter-tool NARRATION explicitly — narration between tool calls, status updates, progress notes, findings — instead of only "questions, prompts, and explanations". That older wording is the defect #2529 reports (it leaves the running commentary between tool calls in English while the answers around it are translated), and `scripts/lint-response-language-coverage.cjs` had been certifying it as coverage, so the gate legitimised the bug. +87 bytes, prose only: no step, gate, tool invocation, or subagent dispatch shape changed. FILE delta and LOADED-CONTEXT delta are both +87 here, and that identity is the point — the directive was deliberately NOT converted to an `@`-reference, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"), so the conversion would have bought a smaller measured file at a cost of 1,692 bytes of loaded context per workflow — the 63-byte import line plus the 1,629-byte reference — against the 87 bytes this inline directive costs, a net +1,605. Stated plainly because the gates cannot state it: the repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so an `@`-reference conversion would have READ as a smaller change to every gate in the repo while costing 1,605 bytes more loaded context per invocation. Re-homed in round 14: the fragment that carried this sentence (`3149-init-debug-entry-point.json`) was retired on `next` by 26f8015c (fix(#3448), #3476), and `3448-debug-autoresume-next-action.json` declares the path today. One path takes exactly one ack source, so the sentence follows the path to its live owner rather than being dropped or re-armed under a retired number. Re-homed from `3448-debug-autoresume-next-action.json` in round 26: `a84f7563` (#3078) swept that fragment as all-spent, so this path is unowned and this PR's own fragment declares it directly.
Emitted-Drift-Ack-Growth: diagnose-issues.md — #2529 — RESTATED in round 10, superseding this PR's earlier "+63 bytes, prose only" wording, which reported a file delta as if it were the whole cost. The workflow gains the shared response-language directive as a single `@`-reference line. FILE delta: +63 bytes, byte-identical in each of the workflows that take the reference. LOADED-CONTEXT delta: +1,692 bytes per workflow — the 63-byte import line plus the 1,629 bytes of `gsd-core/references/response-language-directive.md`, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"). The repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so they see 63 of those 1,692 bytes; the remaining 1,629 are declared here because no gate reads them. The eager import is accepted on its merits, not hidden: an inline copy in every workflow would be that many places for the wording to drift, and the reference is the one place it is maintained. Prose only: no step, gate, tool invocation, or subagent dispatch shape changed. Re-homed in round 19: this PR declared the path in its own fragment, and `3602-workflow-subagent-model-resolution.json` landed on `next` declaring it too. One path takes exactly one ack source, so the sentence moves here and the key leaves ours. Re-homed from `3602-workflow-subagent-model-resolution.json` in round 26: `a84f7563` (#3078) swept that fragment as all-spent, so this path is unowned and this PR's own fragment declares it directly.
Emitted-Drift-Ack-Growth: discuss-phase.md — #2529 round 36: this workflow's inline directive was rewritten in round 10 to name inter-tool narration, but in the compressed form, and that rewrite came to −1 byte against `next` — so it declared no growth and this key was absent from this PR's ack set until now. Round 36 replaces the compressed clause with the same enumeration the other rewordings carry — narration between tool calls, status updates, progress notes, findings, questions, prompts, and explanations — because `discuss-phase.md` started from the identical upstream sentence as `verify-work.md` and `new-milestone.md` and those two took the full list, so the shorthand was an inconsistency rather than a decision. +87 bytes against `next`, prose only: no step, gate, tool invocation, or subagent dispatch shape changed. The directive stays INLINE rather than becoming an `@`-reference, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"), so the conversion would have cost 1,692 bytes of loaded context against the 87 this sentence costs. `commands/gsd/discuss-phase.md` dispatches this workflow lazily (`Read and execute ...`) rather than `@`-importing it, so the 87 bytes land in the installed file and are read once the workflow is dispatched, not on every command invocation.
Emitted-Drift-Ack-Growth: discuss-phase-assumptions.md — #2529 MAJOR 1 (round 10): the workflow's pre-existing inline response-language directive is rewritten IN PLACE so the sentence names inter-tool NARRATION explicitly — narration between tool calls, status updates, progress notes, findings — instead of only "questions, prompts, and explanations". That older wording is the defect #2529 reports (it leaves the running commentary between tool calls in English while the answers around it are translated), and `scripts/lint-response-language-coverage.cjs` had been certifying it as coverage, so the gate legitimised the bug. +87 bytes, prose only: no step, gate, tool invocation, or subagent dispatch shape changed. FILE delta and LOADED-CONTEXT delta are both +87 here, and that identity is the point — the directive was deliberately NOT converted to an `@`-reference, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"), so the conversion would have bought a smaller measured file at a cost of 1,692 bytes of loaded context per workflow — the 63-byte import line plus the 1,629-byte reference — against the 87 bytes this inline directive costs, a net +1,605. Stated plainly because the gates cannot state it: the repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so an `@`-reference conversion would have READ as a smaller change to every gate in the repo while costing 1,605 bytes more loaded context per invocation. Re-homed in round 15: `3409-unreachable-guard-arms.json` landed on `next` in #3558 and declares this path too. One path takes exactly one ack source, so the sentence moves here and the key leaves this PR's fragment. Re-homed from `3409-unreachable-guard-arms.json` in round 26: `a84f7563` (#3078) swept that fragment as all-spent, so this path is unowned and this PR's own fragment declares it directly.
Emitted-Drift-Ack-Growth: discuss-phase-power.md — #2529 — RESTATED in round 10, superseding this PR's earlier "+63 bytes, prose only" wording, which reported a file delta as if it were the whole cost. The workflow gains the shared response-language directive as a single `@`-reference line. FILE delta: +63 bytes, byte-identical in each of the 42 workflows that take the reference. LOADED-CONTEXT delta: +1,692 bytes per workflow — the 63-byte import line plus the 1,629 bytes of `gsd-core/references/response-language-directive.md`, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"). The repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so they see 63 of those 1,692 bytes; the remaining 1,629 are declared here because no gate reads them. The eager import is accepted on its merits, not hidden: 42 inline copies would be 43 places for the wording to drift, and the reference is the one place it is maintained. Prose only: no step, gate, tool invocation, or subagent dispatch shape changed.
Emitted-Drift-Ack-Growth: do.md — #2529 MAJOR 1 (round 10): the workflow's pre-existing inline response-language directive is rewritten IN PLACE so the sentence names inter-tool NARRATION explicitly — narration between tool calls, status updates, progress notes, findings — instead of only "questions, prompts, and explanations". That older wording is the defect #2529 reports (it leaves the running commentary between tool calls in English while the answers around it are translated), and `scripts/lint-response-language-coverage.cjs` had been certifying it as coverage, so the gate legitimised the bug. +87 bytes, prose only: no step, gate, tool invocation, or subagent dispatch shape changed. FILE delta and LOADED-CONTEXT delta are both +87 here, and that identity is the point — the directive was deliberately NOT converted to an `@`-reference, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"), so the conversion would have bought a smaller measured file at a cost of 1,692 bytes of loaded context per workflow — the 63-byte import line plus the 1,629-byte reference — against the 87 bytes this inline directive costs, a net +1,605. Stated plainly because the gates cannot state it: the repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so an `@`-reference conversion would have READ as a smaller change to every gate in the repo while costing 1,605 bytes more loaded context per invocation.
Emitted-Drift-Ack-Growth: docs-update.md — #2529 MAJOR 1 (round 10): the workflow's pre-existing inline response-language directive is rewritten IN PLACE so the sentence names inter-tool NARRATION explicitly — narration between tool calls, status updates, progress notes, findings — instead of only "questions, prompts, and explanations". That older wording is the defect #2529 reports (it leaves the running commentary between tool calls in English while the answers around it are translated), and `scripts/lint-response-language-coverage.cjs` had been certifying it as coverage, so the gate legitimised the bug. +83 bytes, prose only: no step, gate, tool invocation, or subagent dispatch shape changed. FILE delta and LOADED-CONTEXT delta are both +83 here, and that identity is the point — the directive was deliberately NOT converted to an `@`-reference, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"), so the conversion would have bought a smaller measured file at a cost of 1,692 bytes of loaded context per workflow — the 63-byte import line plus the 1,629-byte reference — against the 83 bytes this inline directive costs, a net +1,609. Stated plainly because the gates cannot state it: the repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so an `@`-reference conversion would have READ as a smaller change to every gate in the repo while costing 1,609 bytes more loaded context per invocation. Re-homed in round 19: this PR declared the path in its own fragment, and `3602-workflow-subagent-model-resolution.json` landed on `next` declaring it too. One path takes exactly one ack source, so the sentence moves here and the key leaves ours. Re-homed from `3602-workflow-subagent-model-resolution.json` in round 26: `a84f7563` (#3078) swept that fragment as all-spent, so this path is unowned and this PR's own fragment declares it directly.
Emitted-Drift-Ack-Growth: edit-phase.md — #2529 — RESTATED in round 10, superseding this PR's earlier "+63 bytes, prose only" wording, which reported a file delta as if it were the whole cost. The workflow gains the shared response-language directive as a single `@`-reference line. FILE delta: +63 bytes, byte-identical in each of the 42 workflows that take the reference. LOADED-CONTEXT delta: +1,692 bytes per workflow — the 63-byte import line plus the 1,629 bytes of `gsd-core/references/response-language-directive.md`, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"). The repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so they see 63 of those 1,692 bytes; the remaining 1,629 are declared here because no gate reads them. The eager import is accepted on its merits, not hidden: 42 inline copies would be 43 places for the wording to drift, and the reference is the one place it is maintained. Prose only: no step, gate, tool invocation, or subagent dispatch shape changed. Re-homed in round 13: `3262-editphase-milestone-scope-guard.json` landed on `next` in fd4715f8 (fix(#3262), #3446) and declares this path too. One path takes exactly one ack source, so the sentence moves here and the key leaves this PR's fragment. Re-homed from `3262-editphase-milestone-scope-guard.json` in round 26: `a84f7563` (#3078) swept that fragment as all-spent, so this path is unowned and this PR's own fragment declares it directly.
Emitted-Drift-Ack-Growth: eval-review.md — #2529 MAJOR 1 (round 10): the workflow's pre-existing inline response-language directive is rewritten IN PLACE so the sentence names inter-tool NARRATION explicitly — narration between tool calls, status updates, progress notes, findings — instead of only "questions, prompts, and explanations". That older wording is the defect #2529 reports (it leaves the running commentary between tool calls in English while the answers around it are translated), and `scripts/lint-response-language-coverage.cjs` had been certifying it as coverage, so the gate legitimised the bug. +87 bytes, prose only: no step, gate, tool invocation, or subagent dispatch shape changed. FILE delta and LOADED-CONTEXT delta are both +87 here, and that identity is the point — the directive was deliberately NOT converted to an `@`-reference, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"), so the conversion would have bought a smaller measured file at a cost of 1,692 bytes of loaded context per workflow — the 63-byte import line plus the 1,629-byte reference — against the 87 bytes this inline directive costs, a net +1,605. Stated plainly because the gates cannot state it: the repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so an `@`-reference conversion would have READ as a smaller change to every gate in the repo while costing 1,605 bytes more loaded context per invocation. Re-homed in round 16: the fragment that carried this sentence (`3423-required-reading.json`) was retired on `next` by ddf85287 (fix(#3357), #3513), and no fragment on `next` declares this path now. The ack therefore returns to this PR's own fragment, which is the only live source for it — the change to the path is this PR's.
Emitted-Drift-Ack-Growth: execute-plan.md — #2529 MAJOR 1 (round 10): the workflow's pre-existing inline response-language directive is rewritten IN PLACE so the sentence names inter-tool NARRATION explicitly — narration between tool calls, status updates, progress notes, findings — instead of only "questions, prompts, and explanations". That older wording is the defect #2529 reports (it leaves the running commentary between tool calls in English while the answers around it are translated), and `scripts/lint-response-language-coverage.cjs` had been certifying it as coverage, so the gate legitimised the bug. +87 bytes, prose only: no step, gate, tool invocation, or subagent dispatch shape changed. FILE delta and LOADED-CONTEXT delta are both +87 here, and that identity is the point — the directive was deliberately NOT converted to an `@`-reference, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"), so the conversion would have bought a smaller measured file at a cost of 1,692 bytes of loaded context per workflow — the 63-byte import line plus the 1,629-byte reference — against the 87 bytes this inline directive costs, a net +1,605. Stated plainly because the gates cannot state it: the repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so an `@`-reference conversion would have READ as a smaller change to every gate in the repo while costing 1,605 bytes more loaded context per invocation. Re-homed in round 14: the fragment that carried this sentence (`2652-quick-diagnose-dispatch-isolation.json`) was retired on `next` by 362d0434 (fix(#3370), #3478), and `3370-execute-phase-gate-conflation.json` declares the path today. One path takes exactly one ack source, so the sentence follows the path to its live owner rather than being dropped or re-armed under a retired number. Re-homed from `3370-execute-phase-gate-conflation.json` in round 26: `a84f7563` (#3078) swept that fragment as all-spent, so this path is unowned and this PR's own fragment declares it directly.
Emitted-Drift-Ack-Growth: explore.md — #2529 — RESTATED in round 10, superseding this PR's earlier "+63 bytes, prose only" wording, which reported a file delta as if it were the whole cost. The workflow gains the shared response-language directive as a single `@`-reference line. FILE delta: +63 bytes, byte-identical in each of the 42 workflows that take the reference. LOADED-CONTEXT delta: +1,692 bytes per workflow — the 63-byte import line plus the 1,629 bytes of `gsd-core/references/response-language-directive.md`, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"). The repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so they see 63 of those 1,692 bytes; the remaining 1,629 are declared here because no gate reads them. The eager import is accepted on its merits, not hidden: 42 inline copies would be 43 places for the wording to drift, and the reference is the one place it is maintained. Prose only: no step, gate, tool invocation, or subagent dispatch shape changed. Re-homed from `2229-explore-claim-disposition.json` in round 26: `a84f7563` (#3078) swept that fragment as all-spent, so this path is unowned and this PR's own fragment declares it directly.
Emitted-Drift-Ack-Growth: extract-learnings.md — #2529 — RESTATED in round 10, superseding this PR's earlier "+63 bytes, prose only" wording, which reported a file delta as if it were the whole cost. The workflow gains the shared response-language directive as a single `@`-reference line. FILE delta: +63 bytes, byte-identical in each of the 42 workflows that take the reference. LOADED-CONTEXT delta: +1,692 bytes per workflow — the 63-byte import line plus the 1,629 bytes of `gsd-core/references/response-language-directive.md`, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"). The repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so they see 63 of those 1,692 bytes; the remaining 1,629 are declared here because no gate reads them. The eager import is accepted on its merits, not hidden: 42 inline copies would be 43 places for the wording to drift, and the reference is the one place it is maintained. Prose only: no step, gate, tool invocation, or subagent dispatch shape changed.
Emitted-Drift-Ack-Growth: fast.md — #2529 — RESTATED in round 10, superseding this PR's earlier "+63 bytes, prose only" wording, which reported a file delta as if it were the whole cost. The workflow gains the shared response-language directive as a single `@`-reference line. FILE delta: +63 bytes, byte-identical in each of the 42 workflows that take the reference. LOADED-CONTEXT delta: +1,692 bytes per workflow — the 63-byte import line plus the 1,629 bytes of `gsd-core/references/response-language-directive.md`, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"). The repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so they see 63 of those 1,692 bytes; the remaining 1,629 are declared here because no gate reads them. The eager import is accepted on its merits, not hidden: 42 inline copies would be 43 places for the wording to drift, and the reference is the one place it is maintained. Prose only: no step, gate, tool invocation, or subagent dispatch shape changed. Re-homed in round 18: this PR declared the path in its own fragment, and `3585-planning-commit-guard.json` landed on `next` declaring it too. One path takes exactly one ack source, so the sentence moves here and the key leaves ours. Re-homed from `3585-planning-commit-guard.json` in round 26: `a84f7563` (#3078) swept that fragment as all-spent, so this path is unowned and this PR's own fragment declares it directly.
Emitted-Drift-Ack-Growth: forensics.md — #2529 — RESTATED in round 10, superseding this PR's earlier "+63 bytes, prose only" wording, which reported a file delta as if it were the whole cost. The workflow gains the shared response-language directive as a single `@`-reference line. FILE delta: +63 bytes, byte-identical in each of the 42 workflows that take the reference. LOADED-CONTEXT delta: +1,692 bytes per workflow — the 63-byte import line plus the 1,629 bytes of `gsd-core/references/response-language-directive.md`, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"). The repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so they see 63 of those 1,692 bytes; the remaining 1,629 are declared here because no gate reads them. The eager import is accepted on its merits, not hidden: 42 inline copies would be 43 places for the wording to drift, and the reference is the one place it is maintained. Prose only: no step, gate, tool invocation, or subagent dispatch shape changed.
Emitted-Drift-Ack-Growth: graduation.md — #2529 MAJOR 1 (round 10): the workflow's pre-existing inline response-language directive is rewritten IN PLACE so the sentence names inter-tool NARRATION explicitly — narration between tool calls, status updates, progress notes, findings — instead of only "questions, prompts, and explanations". That older wording is the defect #2529 reports (it leaves the running commentary between tool calls in English while the answers around it are translated), and `scripts/lint-response-language-coverage.cjs` had been certifying it as coverage, so the gate legitimised the bug. +87 bytes, prose only: no step, gate, tool invocation, or subagent dispatch shape changed. FILE delta and LOADED-CONTEXT delta are both +87 here, and that identity is the point — the directive was deliberately NOT converted to an `@`-reference, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"), so the conversion would have bought a smaller measured file at a cost of 1,692 bytes of loaded context per workflow — the 63-byte import line plus the 1,629-byte reference — against the 87 bytes this inline directive costs, a net +1,605. Stated plainly because the gates cannot state it: the repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so an `@`-reference conversion would have READ as a smaller change to every gate in the repo while costing 1,605 bytes more loaded context per invocation.
Emitted-Drift-Ack-Growth: health.md — #2529 MAJOR 1 (round 10): the workflow's pre-existing inline response-language directive is rewritten IN PLACE so the sentence names inter-tool NARRATION explicitly — narration between tool calls, status updates, progress notes, findings — instead of only "questions, prompts, and explanations". That older wording is the defect #2529 reports (it leaves the running commentary between tool calls in English while the answers around it are translated), and `scripts/lint-response-language-coverage.cjs` had been certifying it as coverage, so the gate legitimised the bug. +87 bytes, prose only: no step, gate, tool invocation, or subagent dispatch shape changed. FILE delta and LOADED-CONTEXT delta are both +87 here, and that identity is the point — the directive was deliberately NOT converted to an `@`-reference, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"), so the conversion would have bought a smaller measured file at a cost of 1,692 bytes of loaded context per workflow — the 63-byte import line plus the 1,629-byte reference — against the 87 bytes this inline directive costs, a net +1,605. Stated plainly because the gates cannot state it: the repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so an `@`-reference conversion would have READ as a smaller change to every gate in the repo while costing 1,605 bytes more loaded context per invocation. Re-homed in round 13: the fragment that carried this sentence (`2573-state-head-freshness.json`) was retired on `next` by 7ddcc198 (fix(#3309)), and `3309-health-docs-generated.json` declares the path today. One path takes exactly one ack source, so the sentence follows the path to its live owner rather than being dropped or re-armed under a retired number. Re-homed from `3309-health-docs-generated.json` in round 26: `a84f7563` (#3078) swept that fragment as all-spent, so this path is unowned and this PR's own fragment declares it directly.
Emitted-Drift-Ack-Growth: help.md — #2529 — RESTATED in round 10, superseding this PR's earlier "+63 bytes, prose only" wording, which reported a file delta as if it were the whole cost. The workflow gains the shared response-language directive as a single `@`-reference line. FILE delta: +63 bytes, byte-identical in each of the 42 workflows that take the reference. LOADED-CONTEXT delta: +1,692 bytes per workflow — the 63-byte import line plus the 1,629 bytes of `gsd-core/references/response-language-directive.md`, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"). The repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so they see 63 of those 1,692 bytes; the remaining 1,629 are declared here because no gate reads them. The eager import is accepted on its merits, not hidden: 42 inline copies would be 43 places for the wording to drift, and the reference is the one place it is maintained. Prose only: no step, gate, tool invocation, or subagent dispatch shape changed.
Emitted-Drift-Ack-Growth: import.md — #2529 MAJOR 1 (round 10): the workflow's pre-existing inline response-language directive is rewritten IN PLACE so the sentence names inter-tool NARRATION explicitly — narration between tool calls, status updates, progress notes, findings — instead of only "questions, prompts, and explanations". That older wording is the defect #2529 reports (it leaves the running commentary between tool calls in English while the answers around it are translated), and `scripts/lint-response-language-coverage.cjs` had been certifying it as coverage, so the gate legitimised the bug. +87 bytes, prose only: no step, gate, tool invocation, or subagent dispatch shape changed. FILE delta and LOADED-CONTEXT delta are both +87 here, and that identity is the point — the directive was deliberately NOT converted to an `@`-reference, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"), so the conversion would have bought a smaller measured file at a cost of 1,692 bytes of loaded context per workflow — the 63-byte import line plus the 1,629-byte reference — against the 87 bytes this inline directive costs, a net +1,605. Stated plainly because the gates cannot state it: the repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so an `@`-reference conversion would have READ as a smaller change to every gate in the repo while costing 1,605 bytes more loaded context per invocation. Re-homed in round 18: this PR declared the path in its own fragment, and `3576-references-canonical-cites.json` landed on `next` declaring it too. One path takes exactly one ack source, so the sentence moves here and the key leaves ours. Re-homed from `3576-references-canonical-cites.json` in round 26: `a84f7563` (#3078) swept that fragment as all-spent, so this path is unowned and this PR's own fragment declares it directly.
Emitted-Drift-Ack-Growth: inbox.md — #2529 MAJOR 1 (round 10): the workflow's pre-existing inline response-language directive is rewritten IN PLACE so the sentence names inter-tool NARRATION explicitly — narration between tool calls, status updates, progress notes, findings — instead of only "questions, prompts, and explanations". That older wording is the defect #2529 reports (it leaves the running commentary between tool calls in English while the answers around it are translated), and `scripts/lint-response-language-coverage.cjs` had been certifying it as coverage, so the gate legitimised the bug. +87 bytes, prose only: no step, gate, tool invocation, or subagent dispatch shape changed. FILE delta and LOADED-CONTEXT delta are both +87 here, and that identity is the point — the directive was deliberately NOT converted to an `@`-reference, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"), so the conversion would have bought a smaller measured file at a cost of 1,692 bytes of loaded context per workflow — the 63-byte import line plus the 1,629-byte reference — against the 87 bytes this inline directive costs, a net +1,605. Stated plainly because the gates cannot state it: the repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so an `@`-reference conversion would have READ as a smaller change to every gate in the repo while costing 1,605 bytes more loaded context per invocation.
Emitted-Drift-Ack-Growth: ingest-docs.md — #2529 MAJOR 1 (round 10): the workflow's pre-existing inline response-language directive is rewritten IN PLACE so the sentence names inter-tool NARRATION explicitly — narration between tool calls, status updates, progress notes, findings — instead of only "questions, prompts, and explanations". That older wording is the defect #2529 reports (it leaves the running commentary between tool calls in English while the answers around it are translated), and `scripts/lint-response-language-coverage.cjs` had been certifying it as coverage, so the gate legitimised the bug. +87 bytes, prose only: no step, gate, tool invocation, or subagent dispatch shape changed. FILE delta and LOADED-CONTEXT delta are both +87 here, and that identity is the point — the directive was deliberately NOT converted to an `@`-reference, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"), so the conversion would have bought a smaller measured file at a cost of 1,692 bytes of loaded context per workflow — the 63-byte import line plus the 1,629-byte reference — against the 87 bytes this inline directive costs, a net +1,605. Stated plainly because the gates cannot state it: the repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so an `@`-reference conversion would have READ as a smaller change to every gate in the repo while costing 1,605 bytes more loaded context per invocation. Re-homed from `2658-trae-instruction-file-path.json` in round 26: `a84f7563` (#3078) swept that fragment as all-spent, so this path is unowned and this PR's own fragment declares it directly.
Emitted-Drift-Ack-Growth: insert-phase.md — #2529 — RESTATED in round 10, superseding this PR's earlier "+63 bytes, prose only" wording, which reported a file delta as if it were the whole cost. The workflow gains the shared response-language directive as a single `@`-reference line. FILE delta: +63 bytes, byte-identical in each of the 42 workflows that take the reference. LOADED-CONTEXT delta: +1,692 bytes per workflow — the 63-byte import line plus the 1,629 bytes of `gsd-core/references/response-language-directive.md`, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"). The repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so they see 63 of those 1,692 bytes; the remaining 1,629 are declared here because no gate reads them. The eager import is accepted on its merits, not hidden: 42 inline copies would be 43 places for the wording to drift, and the reference is the one place it is maintained. Prose only: no step, gate, tool invocation, or subagent dispatch shape changed.
Emitted-Drift-Ack-Growth: list-phase-assumptions.md — #2529 — RESTATED in round 10, superseding this PR's earlier "+63 bytes, prose only" wording, which reported a file delta as if it were the whole cost. The workflow gains the shared response-language directive as a single `@`-reference line. FILE delta: +63 bytes, byte-identical in each of the 42 workflows that take the reference. LOADED-CONTEXT delta: +1,692 bytes per workflow — the 63-byte import line plus the 1,629 bytes of `gsd-core/references/response-language-directive.md`, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"). The repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so they see 63 of those 1,692 bytes; the remaining 1,629 are declared here because no gate reads them. The eager import is accepted on its merits, not hidden: 42 inline copies would be 43 places for the wording to drift, and the reference is the one place it is maintained. Prose only: no step, gate, tool invocation, or subagent dispatch shape changed.
Emitted-Drift-Ack-Growth: list-seeds.md — #2529 — RESTATED in round 10, superseding this PR's earlier "+63 bytes, prose only" wording, which reported a file delta as if it were the whole cost. The workflow gains the shared response-language directive as a single `@`-reference line. FILE delta: +63 bytes, byte-identical in each of the 42 workflows that take the reference. LOADED-CONTEXT delta: +1,692 bytes per workflow — the 63-byte import line plus the 1,629 bytes of `gsd-core/references/response-language-directive.md`, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"). The repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so they see 63 of those 1,692 bytes; the remaining 1,629 are declared here because no gate reads them. The eager import is accepted on its merits, not hidden: 42 inline copies would be 43 places for the wording to drift, and the reference is the one place it is maintained. Prose only: no step, gate, tool invocation, or subagent dispatch shape changed.
Emitted-Drift-Ack-Growth: list-workspaces.md — #2529 — RESTATED in round 10, superseding this PR's earlier "+63 bytes, prose only" wording, which reported a file delta as if it were the whole cost. The workflow gains the shared response-language directive as a single `@`-reference line. FILE delta: +63 bytes, byte-identical in each of the 42 workflows that take the reference. LOADED-CONTEXT delta: +1,692 bytes per workflow — the 63-byte import line plus the 1,629 bytes of `gsd-core/references/response-language-directive.md`, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"). The repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so they see 63 of those 1,692 …

* fix(#2529): read the catalog-relative dispatch spelling, and the plural of "output"

Two false positives in the coverage lint, both surfaced by this round's work
rather than by a red gate finding them for us.

#3552 landed `execute-phase/steps/protected-branch.md` on next while this PR was
open, dispatched from execute-phase.md's `"none"` arm with the path written
RELATIVE to the catalog. `namesFragmentAsEntryPoint` only ever looked for the
`gsd-core/workflows/`-rooted spelling, so it read a live dispatch as no dispatch
and the new fragment as uncovered. It now accepts both spellings and matches the
relative one on a path boundary, so `vendor/<path>` cannot vouch for `<path>`.

Recognizing that spelling makes one pin redundant: execute-phase.md dispatches
executor-isolation-dispatch.md the same way, so the fragment inherits and its
own copy of the sentence comes back out. That is the rule round 29 encoded,
enforced by the test that measures it rather than by hand.

`output` was the one term in USER_OUTPUT_RE without an `s?`, so "translate all
outputs, including narration between tool calls" read as uncovered. The new
property tests caught it on their first run.

Those properties pin the rule the hand-written cases are instances of: four
signals on ONE line accept, dropping any one rejects, spreading them across
lines rejects. The vocabulary is written out in the test rather than read back
from the script's regexes, per CONTRIBUTING.md "Fixture provenance (#2371)" -- a
generator seeded from the matcher can only re-derive what the matcher already
believes, and that independence is what caught the plural. Both new properties
are mutation-verified: dropping the narration predicate reds the necessity
property, and collapsing the document to a single line reds the cross-line one.

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

* enhance(#2529): spell the narration enumeration out in the last two shorthand directives

discuss-phase.md and plan-phase.md were the only two of this PR's 44
rewordings that abbreviated the inserted clause to "narration between tool
calls included" instead of naming the output classes the way the rest of them
do. Both forms satisfy the lint's four predicates, so nothing was broken --
but the point of #2529 is that an author reading one workflow should not have
to infer what the neighbouring one means by "included".

Both abbreviations were size decisions rather than wording ones, and both
reasons have since expired because next shrank the files. discuss-phase.md
sat 25 bytes under the 32,000-byte #717 dispatcher budget and now has 1,825;
plan-phase.md sat 87 bytes under the 94,519-byte ADR-857 capstone ratchet
against a +108 clause and now has 3,180. Neither budget is raised here and no
unrelated prose is trimmed; workflow-size-budget and
phase6-capstone-conformance both pass.

discuss-phase.md started from the identical upstream sentence as verify-work.md
and new-milestone.md ("All user-facing questions, prompts, and explanations in
this workflow"), and those two received the full enumeration; it now matches
them exactly. plan-phase.md keeps its own scope word ("orchestrator output") and
its subagent pass-through instruction, both upstream's, and only trades the
shorthand for the enumeration.

The shorthand now appears nowhere in the catalog. The two remaining variants
(plan-review-convergence.md, spec-phase.md) keep upstream's own verb and scope
and end on "report prose", which is what those workflows actually emit --
rewriting those would change a directive's strength, not its wording.

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

* fix(#2529): match the workflow extension case-insensitively in coverage discovery

`findMarkdownFilesRecursive` filtered on `entry.name.endsWith('.md')`, so
`SETTINGS.MD` — the same file to Windows and macOS, a different one to Linux —
was skipped on the only platform whose verdict gates the merge. The direction of
that failure is the problem: a workflow the walk declines to see is a workflow
this lint certifies by omission, which is the same vacuous pass `main()` already
refuses when discovery returns nothing at all.

The filter is now an allowlist keyed on the lowercased `path.extname`.
`.mdx` stays out on purpose: admitting an extension states what a workflow IS,
and that claim has a second half — `inheritsParentCoverage` resolves a
fragment's parent as `<workflow>.md`. An `.mdx` entry belongs here next to the
parent resolution it would have to move with, not ahead of it.

Two tests: an uppercase-extension file is discovered AND lands as a violation
rather than an exemption, and every admitted extension is spelled so the
lowercasing match can reach it (an uppercase or dotless entry would be dead
configuration that reads like coverage).

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

* enhance(#2529): cover the quick-batch workflow that #3676 landed uncovered

`next` gained `quick-batch.md` and nine step fragments in 2f64e6230 (#3676,
PR #4212) with no response-language directive, so the merge result reds this
PR's own lint with 10 violations. The lint is doing exactly what it exists to
do; the coverage is what has to move.

`quick-batch.md` takes the shared @-reference on line 1, the same as the other
42 top-level workflows, and eight of the nine fragments then inherit through
its `read and execute` stubs. The ninth does not:
`quick-batch/steps/plan-checker-loop.md` is dispatched by a SIBLING fragment
(`planner-wave.md:134`) and named in the parent only inside a parenthetical
with no dispatch verb, which is the shape round 29's rule already covers for
`execute-phase/steps/regression-gate-run.md` and
`plan-phase/steps/prd-express-path.md`. It carries the pinned inline directive
and joins `EXACT_INLINE_DIRECTIVE_WORKFLOWS`; the comment above that set now
names four such fragments instead of three. Coverage: 163 workflows.

`FULL_BUDGET` in tests/skill-frontmatter-contract.test.cjs moves 844 -> 846.
The same commit grew `help/modes/full.md` from 834 to 844 lines, landing it
exactly on the ceiling with zero slack, and the two lines this PR adds there
are its pinned directive and the blank separating it. That is a coverage
contract every workflow carries, not the content creep the budget guards.
The #597 ratchet rule holds: actualMax 846, slack 0, well inside LARGE_GRACE.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Emitted-Drift-Ack-Growth: quick-batch.md — #2529: the workflow arrived on `next` in 2f64e6230 (#3676, PR #4212) with no response-language directive, so this PR's lint reds on the merge result; covering it is the PR's whole contract, not an optional extra. It gains the shared directive as a single eager `@`-reference line, the identical form the other 42 top-level workflows take. FILE delta: +62 bytes, as the gate measures it. LOADED-CONTEXT delta: +1,691 bytes — the import line plus the 1,629 bytes of `gsd-core/references/response-language-directive.md`, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"). The repo's size gates read the FILE and not the transitive inline, so they see 62 of those 1,691 bytes; the remaining 1,629 are declared here because no gate reads them. Prose only: no step, gate, tool invocation, or subagent dispatch shape changed. Nine `quick-batch/steps/*` fragments are covered without a byte of their own — eight inherit through the parent's dispatch stubs, and the ninth takes the pinned inline sentence, which the emitted surface does not measure.

* fix(#2529): scope row 48 by what a diff says, not by which paths it names

`tests/gsd-quick-batch-quick-regression.test.cjs` treats any branch touching a
`quick-batch` path as #3676 phase work, then forbids it from editing ordinary
`quick.md`. This PR covers EVERY workflow with the shared response-language
directive — quick-batch.md and its fragments included — so the scope check
turned true, and the row read this PR's one-line directive on `quick.md` as a
phase violation.

That is the false positive the row's own #3730 note already scoped away from,
arriving by the other door: not an unrelated branch that misses the surface,
but a catalog-wide sweep that touches all of it. A path now counts as phase
work only when its diff says something other than the coverage contract, and
the two accepted directive forms are read from
`scripts/lint-response-language-coverage.cjs` rather than restated, so a
reworded contract cannot leave the carve-out matching prose the lint no longer
recognizes. A file the branch ADDED still counts — every line is new, which is
what a real #3676-phase branch looks like.

The invariant is unweakened in the direction that matters: a phase branch that
edits `commands/gsd/quick.md`, `gsd-core/workflows/quick.md` or anything under
`quick/steps/` for any reason other than the directive still fails the row.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-04 21:12:12 -04:00
Tom Boucher
2f4f7538e9 fix(#4264): wire both TDD dispatch backends to phase.tdd-applicable (#4284) 2026-09-04 16:08:42 -04:00
Tom Boucher
75ee7b0214 enhance(#4273): add phase.tdd-applicable single-owner predicate (#4277)
* enhance(#4273): add phase.tdd-applicable single-owner predicate

One query verb computes TDD-applicability for a plan (CLI flag, plan
type: tdd frontmatter, a task's tdd="true" attribute, or the
workflow.tdd_mode config default), mirroring phase.mvp-mode's
precedence-cascade shape. Foundation for epic #4272 Phase 2, which
wires both dispatch backends to consume it instead of restating the
predicate independently.

Also fixes workflow.tdd_mode, workflow.research, and
workflow.nyquist_validation, which never reached
cmdInitExecutePhase/cmdInitPlanPhase/cmdInitDebug/cmdInitNewMilestone
because loadConfig() never populates config.workflow — a dead
accessor found while wiring this verb's own config read, fixed inline
per the no-defer rule rather than left alongside it.

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

* docs(#4273): document phase.tdd-applicable's FEATURES.md entry

Add a docs/features/ fragment for the new phase.tdd-applicable query
verb and regenerate docs/FEATURES.md. docs/COMMANDS.md is left
untouched: it documents /gsd-* slash commands only, and the sibling
verb phase.tdd-applicable mirrors (phase.mvp-mode) has no formal CLI
reference entry anywhere in docs/ either -- only inline prose mentions
in docs/reference/workflow-fragments.md -- so there is no COMMANDS.md
precedent to extend.

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

* fix(#4273): use PHASE_NOT_FOUND reason code, remove try/finally from tests

Two orthogonal code reviews flagged a mistyped error reason and a CONTRIBUTING.md-banned try/finally pattern in the phase.tdd-applicable change; both are corrected here.

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

* fix(#4273): stop whitelisting capability-owned config keys centrally

workflow.tdd_mode, workflow.research, and workflow.nyquist_validation are
each already owned by their own first-party capability's federated config
schema (the tdd/research/nyquist capabilities declare them under their own
capability.json `config`), resolved via isCapabilityConfigKey. Adding them
to gsd-core/bin/shared/config-schema.manifest.json's central validKeys, as
the prior commit in this branch did (mirroring workflow.mvp_mode, which
genuinely is central-only), declares the same key in two places at once.
That collision breaks capability-loader.cts's loadRegistry composition:
gsd-test caught this as 84-85 unrelated failures across
capability-cli/capability-command-dispatch/capability-lifecycle test files,
every one showing "unknown capability: <id>" for a freshly-installed
third-party capability that should have resolved fine.

Verified directly (not asserted): reverting only this file, keeping the
config-loader.cts tdd_mode/research/nyquist_validation flattening and the
init.cts call-site fixes from the prior commit, and re-running the exact
capability install + capability set repro from
tests/capability-cli.test.cjs's "issue-2322" test locally reproduces the
failure with the whitelist entries present and clears it without them.
loadConfig() still surfaces all three flattened values correctly with no
central whitelist entry (confirmed directly against the compiled module) —
the whitelist additions were never required for the #4273 fix to work; they
were an incorrect over-application of the mvp_mode precedent to keys that
aren't central.

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

* fix(#4273): use getNested for tdd_mode (no legacy top-level fallback), allowlist new test file

Both fixes address defects found by a gsd-test bench run: tdd_mode routed through get() invented an undocumented top-level alias that silently outranked the canonical workflow.tdd_mode key, and the new phase-tdd-applicable test file was missing from the file-count allowlist.

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

* chore(#4273): backfill changeset PR number

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

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 14:14:56 -04:00
Tom Boucher
18e5cfff8a fix(#4250, #4260): distinguish a timed-out npm audit from a JSON parse failure, retry with backoff (#4251)
* fix(#4250): distinguish a timed-out npm audit from a JSON parse failure

npm-audit-baseline.cjs's runPackageLockAudit, and the near-identical
auditProductionVulns helper in npm-integrity-gate.test.cjs, both grabbed
e.stdout whenever an npm audit child process exited non-zero -- without
checking whether the process was actually killed by its 180s timeout.
A timeout-killed process's stdout is truncated mid-write, not complete
JSON, so JSON.parse threw a misleading "Unexpected end of JSON input"
instead of naming npm's registry timeout as the real cause.

Root-caused live during a CI investigation: npm's own status page
reported degraded service, and the registry's bulk-advisories endpoint
was returning 503/hanging, causing npm audit to sit until the timeout
fired.

Adds a shared isTimeoutKill(error) predicate (checks execFileSync's
documented killed/signal fields) and checks it first in both catch
blocks, throwing a clear, actionable error before ever reaching
JSON.parse. The pre-existing "non-zero exit with complete JSON"
recovery path is unchanged and still covered by regression tests.

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

* chore(#4250): add changeset for npm-audit timeout fix

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

* fix(#4250): share the timeout-kill error message and cover auditProductionVulns

Two independent review passes (standards + spec) on the first commit found
real gaps: the timeout-kill error message was duplicated verbatim between
runPackageLockAudit and the near-identical auditProductionVulns helper in
tests/npm-integrity-gate.test.cjs (this repo's own Generative Fix Divergence
anti-pattern -- shared logic across parallel surfaces with no parity check),
and auditProductionVulns picked up the same production fix with zero test
coverage of its own.

Extracts buildTimeoutKillError(cwd), used by both callers so the message
cannot independently drift. Gives auditProductionVulns the same injectable
execFileSyncImpl seam runPackageLockAudit already had, and adds the matching
regression tests (timeout-kill throws the clear error; the pre-existing
non-zero-exit-with-complete-JSON path still recovers correctly).

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

* chore(#4250): backfill changeset PR number to #4251

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

* diag(#4250): surface captured stderr in the timeout-kill error

The killed child process's stderr is buffered in-memory by execFileSync
and attached to the thrown error, but nothing surfaced it -- the timeout
message named the timeout but discarded the one piece of data that could
show WHY npm was still running when it fired (DNS stall, TLS handshake
stall, a registry-side retry loop, all look identical without it).

buildTimeoutKillError now takes the killed error and includes its stderr
(or an explicit 'no stderr was captured' note) in the message. This is a
diagnostic improvement for the next CI occurrence, not a behavior fix --
local reproduction has directly ruled out npm version (installed the
exact CI-bundled 11.17.0 and ran it against this repo: 0.49s, clean),
general npm registry reachability (0.4-1.4s locally, repeatedly), and
npm ci speed (2m, succeeded) as explanations for the 180s CI hangs.

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

* fix(#4260): bounded retry with backoff for npm audit calls, finish the extraction

The audit backend has real, independent latency variance from the rest of
the npm registry -- measured (see #4260): a bulk-advisories POST took
43.41s vs 0.20s for a plain registry fetch on the same host, and the same
endpoint returned no response at all (000) twice in the same window,
while status.npmjs.org reported fully operational throughout. Against
that, runPackageLockAudit and its near-duplicate auditProductionVulns
each made exactly one attempt with no retry -- any single bad moment
failed a REQUIRED CI gate on a transport hiccup, not a real advisory.

Replaces the single 180s attempt with runNpmAuditWithRetry: up to 3
attempts at 60s each (comfortably above the worst measured working
latency) with exponential backoff between them. Only a confirmed
timeout-kill is retried; a genuine non-timeout failure still fails
immediately, and exhausting all attempts still fails the gate -- per
#4260's own caveat, silently disarming a required security check on a
transport error is worse than occasionally re-running CI.

Also finishes the extraction #4260 flagged as stopped halfway:
auditProductionVulns (tests/npm-integrity-gate.test.cjs) duplicated
runPackageLockAudit's entire candidate loop, recovery branch, and timeout
classification, differing only in npm args and precondition check. It is
now a thin wrapper delegating to the newly-exported runInstalledTreeAudit,
which shares runNpmAuditWithRetry with runPackageLockAudit -- one
implementation instead of two that could independently drift.

buildTimeoutKillError now reports attempt count and still surfaces
captured stderr from the last kill.

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

* chore(#4260): update changeset for retry/backoff scope

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

* test(#4260): budget for two sequential retry-audit calls, close coverage gaps

Two review passes on the retry/backoff commit found real gaps:

- TEST_TIMEOUT_MS budgeted only one retry-audit call's worst case (210s),
  but checkTreeAgainstBaseline makes two sequential calls (HEAD tree via
  auditProductionVulns, baseline tree via runPackageLockAudit) -- combined
  worst case is ~372s. If both genuinely exhausted retries, node:test's
  own timeout would fire first and mask buildTimeoutKillError's clear
  message, undercutting #4250's own fix in that edge case. Recomputed
  using the same backoff formula the production code uses, so it can't
  independently drift.

- buildTimeoutKillError's default-attempts(1) singular-phrasing branch had
  zero direct test coverage (nothing calls it with a single attempt
  anymore) -- a real mutation-testing risk. Added direct tests for both
  phrasing branches plus the no-error-object case.

- runInstalledTreeAudit's null-guard skip paths (missing package.json,
  missing node_modules) had no tests, unlike runPackageLockAudit's
  matching paths. Added for parity.

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

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 10:02:17 -04:00
Tom Boucher
97ce61dee2 fix(#3990): state the RED/GREEN/REFACTOR cycle once, embed tdd.md conditionally (#4228)
* test(#3990): the RED/GREEN/REFACTOR cycle is stated once, embedded conditionally

* fix(#3990): state the cycle once — pointers in consumers, conditional tdd.md embeds

Emitted-Drift-Ack-Growth: execute-phase.md — #3990 conditions the tdd.md embed on the dispatch being TDD

* chore(#3990): changeset for the single-statement TDD cycle

* chore(#3990): backfill changeset pr number

* fix(#4228): linear cycle check — the lazy-span regex pinned a Windows core for the whole job cap

* test(#3990): allowlist pin tracks the rebased line

* fix(tests): npm-integrity gate names an empty audit output explicitly — empty stdout crashed the parse as a bare SyntaxError

* fix: name an empty npm-audit stdout explicitly — it crashed the parse as a bare SyntaxError

Observed on CI (several branches, all lanes): spawnSync npm ETIMEDOUT with
empty stdout; the empty string survived the recovery path and surfaced as
'SyntaxError: Unexpected end of JSON input', hiding the captured error. The
recovery path now requires non-empty stdout, and an empty result throws with
the captured stdout/stderr/message so the actual error is on the record.
Root cause of the ETIMEDOUT itself is NOT diagnosed here — this change only
stops masking it.

---------

Co-authored-by: sim <sim@local>
2026-09-03 21:41:14 -04:00
Tom Boucher
1fe85cd43e chore(#4244): ESLint rules for the #4220 Windows dirname-walk / TMPDIR-triad bug class (#4246)
* fix(#4244): repoint TEMP/TMP alongside TMPDIR and fix the sweepProtectSet fixed-point walk

Repo-wide sweep (ahead of adding lint rules for these exact bug classes)
found both incident patterns still live and unfixed on `next`:

- scripts/run-tests.cjs's sweepProtectSet walk stopped on
  `cur !== runTempRoot && cur.length > 1` — a POSIX-only sentinel.
  win32 dirname('D:\') is a fixed point (length 3, never satisfies
  `> 1`... wait, it does satisfy length>1), so a selected file living
  outside runTempRoot (the common case) spins the walk forever on
  Windows. Extracted a pure, exported computeSweepProtectSet helper
  that terminates on dirname(cur) === cur instead, with in-process
  RuleTester-style coverage for both win32 and posix paths.

- tests/run-tests-temp-root.test.cjs's own #4020 regression test set
  only TMPDIR on its runNode(...) child env. Node's os.tmpdir() never
  reads TMPDIR on Windows (only TEMP, then TMP), so the redirect
  silently no-oped there — masked because Windows CI died in the
  dirname-walk hang above before ever reaching this test.

- tests/config-schema.property.test.cjs's fallow config-set test had
  the same TMPDIR-only pattern, direct process.env assignment this
  time, restored in its own finally block.

Origin: #4220 and its shared root cause #4020.

* feat(#4244): require-full-tmpdir-triad and no-unbounded-dirname-walk ESLint rules

Two custom local ESLint rules catch the #4220 / #4020 Windows CI hang bug
class at author time, joining the ADR-1703 DEFECT.WINDOWS-TEST-PORTABILITY
catalog. Neither eslint-plugin-unicorn nor eslint-plugin-n has a rule for
either shape.

- local/require-full-tmpdir-triad: flags a TMPDIR environment override
  (direct process.env.TMPDIR assignment, or a TMPDIR property in a
  spawn-like call's env: object literal) not accompanied by TEMP and TMP
  in the same scope. Node's os.tmpdir() never reads TMPDIR on Windows.
  Registered on tests/**/*.cjs, matching the require-userprofile-with-home
  precedent.

- local/no-unbounded-dirname-walk: flags a while/do-while loop reassigning
  from dirname() with no fixed-point termination guard
  (dirname(cur) !== cur, or path.parse(cur).root). path.dirname() is a
  no-op at the platform root, but the value differs by platform
  (win32 'D:\' is length 3, posix '/' is length 1), so a POSIX-shaped
  length/equality bound never fires on Windows. Registered on BOTH
  tests/**/*.cjs and scripts/**/*.cjs — the real #4020 bug lived in
  scripts/run-tests.cjs, not tests/.

Both rules join the zero-escape-hatch discipline already established for
this catalog (no bespoke comment marker; PROTECTED_RULES in
tests/portability-rule-disable-ban.test.cjs independently bans
eslint-disable of either). ADR-1703 and its two companion contributing
docs get an amendment documenting the mechanism, code examples, and the
repo-wide sweep (three live instances found and fixed in the prior
commit; no others found). CI test-scope selection updated so an edit to
either rule or to scripts/run-tests.cjs re-runs the right suites.

* fix(#4244): no-unbounded-dirname-walk must analyze a single-condition loop test too

checkWhile bailed out early unless node.test was a LogicalExpression,
so a single-condition loop -- while (cur !== root) { cur = dirname(cur); } --
was silently skipped and never reported. That is the EXACT minimal
shape of the original #4020/#4220 bug, and it is literally the shape
used by this rule's own shipped RuleTester fixtures (the "equality-only
bound" invalid cases), which were failing (0 errors reported, 1
expected) until this fix -- confirmed by running RuleTester directly
against both fixtures, not just via a passing test-runner exit code.

The conjunct-collection helper already handled a non-LogicalExpression
test correctly (it pushes a single node as the sole conjunct); only the
early-return gate needed to stop requiring a compound && / || test.

Verified: RuleTester run directly against both previously-broken
fixtures plus two new sanity cases (a guarded single-condition loop
stays valid; an unrelated single-condition loop stays silent), and a
fresh `npx eslint .` across the whole repo remains clean (no other
single-condition dirname-walk shape exists in the tree).

* fix(#4244): require-full-tmpdir-triad must recognize a destructured child_process call

isSpawnLikeCallee only recognized a MemberExpression callee
(child_process.spawnSync(...)) or a bare identifier in
ENV_LOCAL_HELPER_NAMES (runNode). A destructured import called bare --
const { spawnSync } = require('child_process'); spawnSync(...) -- has an
Identifier callee named "spawnSync", which matched neither branch, so
the whole env-literal check was skipped. gsd-test caught this: both
"invalid: child_process.spawnSync with TMPDIR-only env" cases in
tests/require-full-tmpdir-triad.rule.test.cjs were failing (0 errors
reported, 1 expected).

Widened the bare-identifier branch to also match any of the known
ENV_CHILD_PROCESS_METHODS names, matched by name only -- the same
lightweight convention this repo's other eslint-rules/*.cjs use (e.g.
no-hardcoded-tmp.cjs's isFsMethodCall), not full import data-flow
tracing.

Verified: RuleTester run directly against all 11 cases in
tests/require-full-tmpdir-triad.rule.test.cjs (not just the two that
were failing), all pass; a fresh npx eslint . and npm run lint:ci
across the whole repo remain clean.

* fix(#4244): correct a stale escape-hatch reference in a test comment

The comment on the "length comparison against another expression's
length" case referenced a "// allow-dirname-walk marker" that doesn't
exist -- the rule has zero comment-based escape hatches by design
(ADR-1703), and an earlier draft's marker mechanism was removed before
this branch's first commit. Spec-axis review caught the stale
reference. No behavior change; comment-only.

* chore(#4244): backfill changeset PR number (pr:0 -> pr:4246)

---------

Co-authored-by: sim <sim@local>
2026-09-03 14:14:09 -04:00
Tom Boucher
456136659d fix(#4220): terminate the Windows temp-sweep ancestor walk (and two bugs it unmasked) (#4245)
* fix(#4220): terminate the temp-sweep ancestor walk with a fixed-point check

scripts/run-tests.cjs's sweepProtectSet block walked each selected test
file's ancestor directories, stopping on `cur !== runTempRoot &&
cur.length > 1` — a POSIX-only sentinel. path.posix.dirname('/') === '/'
(length 1) correctly stops, but path.win32.dirname('C:\\') === 'C:\\'
(length 3) never satisfies the length check, so the walk spun forever on
Windows whenever a selected file lived outside runTempRoot (the common
case). This has hung every Windows CI shard since #4207.

Extract the walk into a pure, exported computeSweepProtectSet(selected,
runTempRoot, dirnameImpl) helper and replace the length sentinel with a
fixed-point check (stop when dirnameImpl(cur) === cur), which terminates
correctly on POSIX, Windows drive roots, and UNC roots alike with no
platform branch.

* fix(#4220): repoint TEMP/TMP alongside TMPDIR in run-tests-temp-root test child env

Node's os.tmpdir() on Windows never reads TMPDIR, only TEMP/TMP. The
test's runNode child-process env override only set TMPDIR, so on a
real Windows runner nested inside a run-tests invocation the child
inherited the outer process's already-repointed TEMP/TMP and its
mkdtempSync(os.tmpdir()) landed under the outer run's temp root
instead of the test's intended `outer` directory. This was masked on
gsd-test's benches and locally because Windows CI always died in the
#4220 infinite loop before reaching this test.

* fix(#4220): stop the ancestor walk from protecting the filesystem root itself

computeSweepProtectSet added `cur` to the protect set before checking
whether dirname(cur) === cur, so on the terminating iteration it
protected the filesystem root (posix `/`, and analogously a win32
drive root) instead of stopping before adding it. Caught by the
existing posix-parity regression assertion
(`!protectSet.has('/')`) on the linux-node24 gsd-test bench. Reorder
to compute the parent and check the fixed point before adding.

* fix(#4220): backfill changeset pr number to 4245

---------

Co-authored-by: sim <sim@local>
2026-09-03 13:42:14 -04:00
Tom Boucher
114dfcb739 fix(#4196): npm-audit gate blocks only NEW advisories, not pre-existing ones (#4214)
* fix(#4196): npm-audit gate blocks only NEW advisories, not pre-existing ones

The #3588 gate failed on ANY advisory in the production tree, regardless
of whether the PR/push actually introduced it. Because npm's advisory
database updates continuously and independently of repo state, a commit
could pass this gate at merge time and fail it minutes later on the
identical tree -- proven on PR #4188/dce40eeb6, which passed on all 3
OSes at 15:57-16:27 and failed the same assertion at 16:19-16:30 on the
unchanged commit, purely because GHSA-jqff-g426-hqxp was disclosed for
fast-uri in the interim.

scripts/npm-audit-baseline.cjs diffs the head tree's vulnerable-package
set against a resolved baseline (the PR's target branch, or the prior
commit on a direct push) and blocks only newly-introduced advisories.
When no baseline can be resolved, falls back to the original
zero-tolerance behavior -- fail-closed, never silently weaker.

* fix(#4196): pin the npm-audit baseline instead of using a drift-prone ref

Two orthogonal reviews found the same class of bug this repo already
fixed once for a different gate (see GSD_EMITTED_BASE's own incident
comment in test.yml): origin/<branch> is live under fetch-depth: 0 and
can advance mid-run, so resolveBaselineRef()'s fallback to
origin/${GITHUB_BASE_REF} could silently disagree with the tree
ci-rebase-check.cjs actually merged. Wire AUDIT_BASELINE_REF from the
workflow to github.event.pull_request.base.sha / github.event.before,
the same pinned values GSD_EMITTED_BASE already relies on.

Also: HEAD~1 assumed exactly one commit per push, which this repo's
allow_rebase_merge:true setting can violate (a rebase-merged PR lands
as several discrete commits in one push) -- github.event.before is
git's own record of the correct pre-push state, not an assumed offset.
HEAD~1 remains as a documented last-resort fallback for out-of-band
invocations (e.g. gsd-test) that don't set any of the above, alongside
a new local-branch fallback for gsd-test's local `next` (not
origin/next) sandbox shape.

---------

Co-authored-by: sim <sim@local>
2026-09-02 22:38:37 -04:00
Tom Boucher
2f64e6230a feat(#3676): quick-batch command, workflow, and isolation integration (#4212)
* test(#3676): add failing tests for quick-batch dispatch core

Failing-first tests for Phase 4 of epic #3344 (ADR-1239 "Quick-batch
binding"): quick-batch-dispatch.test.cjs / .property.test.cjs cover the
new pure decision-logic module (arg validation, effective concurrency,
deterministic merge order, spawn backpressure, verification/merge
routing, cleanup-entry construction — design doc rows 3-15,24,26-28,
30-36,39; property rows 51-53). quick-batch-update-items.test.cjs
covers the new updateBatchItems export on src/quick-batch.cts (rows
15,22-23, including the negative cycle-rejection case).
quick-batch-command-router.test.cjs covers the new
gsd-tools quick-batch CLI family (rows 46-47). These reference modules/
exports that do not exist yet.

* feat(#3676): implement quick-batch dispatch core, updateBatchItems, and command router

Phase 4 of epic #3344 (ADR-1239 "Quick-batch binding") CORE decision
layer — CLI verbs and pure orchestration logic only; no workflow
markdown, no Agent()/git-worktree I/O.

- src/quick-batch-dispatch.cts (new): pure decision functions consumed
  by the (separate, follow-up) /gsd:quick-batch workflow markdown —
  parseQuickBatchArgs, computeEffectiveConcurrency, computeMergeOrder,
  computeSpawnPlan, routeVerificationOutcome, routeMergeOutcome,
  buildCleanupManifestEntry (the last parses caller-supplied plan text
  via the existing parsePlanDocument; no filesystem access).

- src/quick-batch.cts: adds updateBatchItems, resolving the design
  doc's Open Question 1 as ONE additive export on this module instead
  of the second, independent BATCH.json writer the design doc
  originally proposed. Reuses the same withPlanningLock transaction
  shape, computeWaves, and platformWriteSync call resumeBatch/
  completeQuickItem already use; fails closed without persisting on
  an unknown item, an unknown/self dependency, or an introduced cycle.

- src/quick-batch-command-router.cts (new): gsd-tools quick-batch CLI
  family, wired into HOST_COMMAND_ROUTERS (gsd-core/bin/gsd-tools.cjs)
  as a first-party always-on command (like /gsd:quick), not the opt-in
  capability-registry path graphify uses. Verbs: create/update/resume/
  complete (wrap quick-batch.cts) and effective-concurrency/
  merge-eligible/spawn-plan/verification-routing/merge-routing/
  cleanup-entry/parse-args (wrap quick-batch-dispatch.cts).

Design doc rows covered: 3-15, 22-24, 26-28, 30-39, 46-47. Property
rows 51-53. Rows covering workflow markdown / Agent() dispatch /
`git worktree` behavior (16-21, 25, 29, 40-45, 48-50) remain for the
follow-up markdown-authoring pass, per the phase brief's explicit
scope boundary.

* docs(#3676): register quick-batch-dispatch/command-router modules in bookkeeping surfaces

New-.cts-module ripple for the two Phase 4 modules (epic #3344,
ADR-1239 "Quick-batch binding"): .gitignore (compiled .cjs artifacts,
ADR-457 build-at-publish), eslint.config.mjs (lint the .cts source,
not the emitted .cjs), docs/INVENTORY.md + docs/INVENTORY-MANIFEST.json
(via `node scripts/gen-inventory-manifest.cjs --write`, after
`npm run build:lib`), and CONTEXT.md glossary entries for
"Quick-Batch Dispatch Core Module" and "Quick-Batch Command Router
Module", plus an update to the existing "Quick-Batch Core Primitives
Module" entry documenting the new updateBatchItems export.

* test(#3676): fold updateBatchItems tests into quick-batch.test.cjs (fix lint-test-file-count)

scripts/lint-test-file-count.cjs buckets any quick-batch-*.test.cjs
file under the quick-batch production module by longest-prefix match,
and that module is already at its 2-file cap (quick-batch.test.cjs +
quick-batch.property.test.cjs). The standalone
tests/quick-batch-update-items.test.cjs added in the prior commit
pushed it to 3 and failed `npm run lint:ci`. Fold its content into
quick-batch.test.cjs (append-only — no existing test in that file is
modified) and update the CONTEXT.md glossary reference to match.

Surfaced while re-running `GITHUB_BASE_REF=next npm run lint:ci` after
`npm ci` (this worktree previously had no local node_modules, which
also made gen-scripts-cli-exit/gen-hooks-cli-exit/gen-exit-code-*
unable to resolve typescript — resolved by npm ci, no code change
needed there). `npm run lint:ci` and
`npx tsc -p tsconfig.build.json --noEmit` are both green after this
fix.

* test(#3676): add failing tests for the quick-batch command/workflow markdown

Failing-first tests for Phase 4's markdown-authoring pass (epic #3344,
ADR-1239 "Quick-batch binding"): gsd-quick-batch-workflow.test.cjs
covers commands/gsd/quick-batch.md's frontmatter/objective/process,
gsd-core/workflows/quick-batch.md's byte-size boundary (row 49, ADR
1610 NEW_FILE_CAP) and step-fragment count, the isolation model
(rows 20-22), the executor single-writer invariant (row 18), merge
validation reusing the existing bounded primitive (row 25), the
optional research/plan-checker/verification leaves (rows 16,17,19,
30,31), planning-failure blocking execution (row 29), the submodule
guard (rows 36,44), and the new agents/gsd-planner.md quick-batch
mode (rows 13-15). gsd-quick-batch-quick-regression.test.cjs covers
row 48 (ordinary /gsd:quick stays byte-identical). Named
`gsd-quick-batch-*` (not `quick-batch-*`) so lint-test-file-count's
longest-prefix bucketing doesn't fold these markdown-only tests into
the already-capped quick-batch/quick-batch-dispatch/
quick-batch-command-router production-module buckets from the CORE
pass. These reference files that do not exist yet.

* feat(#3676): author the quick-batch command, workflow, and planner mode

Phase 4 markdown-authoring pass (epic #3344, ADR-1239 "Quick-batch
binding") — the orchestration layer that calls into Pass 1's CLI
verbs (src/quick-batch-command-router.cts).

- commands/gsd/quick-batch.md (new): frontmatter/objective/process,
  delegates argument validation to `quick-batch parse-args`
  (parseQuickBatchArgs) rather than re-deriving the grammar.

- gsd-core/workflows/quick-batch.md (new, 11843 bytes — under ADR
  1610's 32768-byte NEW_FILE_CAP for a brand-new file) + 9 lazy-loaded
  step fragments under gsd-core/workflows/quick-batch/steps/:
  resume-mode, batch-init, research-phase (flag:--research),
  planner-wave (+ nested plan-checker-loop when --validate),
  worktree-dispatch, merge-wave, verification-wave (flag:--validate),
  completion. Covers design doc rows 3-45: capacity/isolation
  resolution (reusing dispatch-isolation-gate.md verbatim), per-DAG-
  layer planning with full-task-catalog prompts and always-required
  depends_on/files_modified frontmatter, serialized worktree create/
  merge/cleanup via the existing worktree.cleanup-wave primitive,
  deterministic wave-order merging, verification routing
  (human_needed/gaps_found), the executor single-writer invariant,
  submodule fail-loud guard, and #1941 fork-base auto-degrade.

- agents/gsd-planner.md: additive new `load_mode_context` bullet for
  `**Mode:** quick-batch`, pointing at the new
  gsd-core/references/planner-quick-batch.md reference (documents the
  always-required depends_on/files_modified contract, reusing the
  existing frontmatter grammar — no new keys). Existing modes
  byte-identical, only a new bullet added.

- src/init.cts (+init-command-router.cts, +command-aliases.cts):
  cmdInitQuickBatch / `init.quick-batch` — model profiles,
  commit_docs, roadmap/planning existence checks, and the
  section_manifest field gating research-phase/verification-wave
  (reuses the existing flag:--research/flag:--validate WHEN_VOCABULARY
  atoms — no new atom needed).

Rows 16-21, 25, 29, 36, 38, 39, 44, 46-50 covered structurally by the
prior test(#3676) commit; rows 3-15, 22-24, 26-28, 30-35, 37, 40-43,
45 covered by construction (verb wiring, single-writer prompt
constraints, crash-window resume via unmodified Phase 3 primitives).

* docs(#3676): regenerate skills/inventory/section-manifest/install-tree; baseline the intentional word-splitting pattern

npm run regen:derived output for the new command/workflow/reference
(epic #3344, ADR-1239 "Quick-batch binding"):
- skills/gsd-quick-batch/SKILL.md (generated from commands/gsd/quick-batch.md)
- docs/INVENTORY.md rows for /gsd-quick-batch, quick-batch.md,
  planner-quick-batch.md, and the quick-batch-dispatch.cjs/
  quick-batch-command-router.cjs CLI-module rows' now-live
  `/gsd-quick-batch` cross-reference (was "(separate, follow-up)")
  + docs/INVENTORY-MANIFEST.json (`node scripts/gen-inventory-manifest.cjs --write`)
- gsd-core/workflows/section-manifest.json (`npm run gen:section-manifest`)
  — research-phase/verification-wave gsd:section entries for the new
  quick-batch workflow
- tests/fixtures/install-tree/*.json (`npm run gen:install-tree`) —
  the new command/workflow/skill/reference files now ship to every
  runtime

scripts/lint-workflow-shellcheck-baseline.json: 3 new entries for
gsd-core/workflows/quick-batch.md's intentional flag-token/$ARGUMENTS
word-splitting (SC2046/SC2086) — the same deliberate unquoted-optional-
flag pattern gsd-core/workflows/quick.md already carries baselined
(e.g. `$DISCUSS_PARAM $RESEARCH_PARAM` in quick.md's own Step 2);
quoting would break the intended "omit this arg when the flag is
false" splitting.

* fix(#3676): close prompt-injection and argv/glob-injection gaps in quick-batch leaf dispatch

Security review pass findings, both confirmed real:

1. HIGH — prompt injection, no boundaries. Every leaf-dispatch fragment
   interpolated the raw, attacker-influenced task ${description} (and
   the shared ${TASK_CATALOG_TABLE}, broadcasting every item's raw
   description into every planner's prompt in the layer) straight into
   Agent() prompt bodies with no boundary. Fixed by wrapping every such
   interpolation in a <security_context> + DATA_START/DATA_END
   boundary, matching the CONCRETE convention already implemented in
   this repo (agents/gsd-debug-session-manager.md, agents/gsd-debugger.md,
   gsd-core/workflows/debug.md) — commands/gsd/quick.md's own
   <security_notes> only asserts this convention in prose, so the
   debug-agent files are the real precedent followed here. Added a new
   <security_notes> block to commands/gsd/quick-batch.md (it had none)
   documenting both this fix and the one below.

2. MEDIUM — unquoted $ARGUMENTS -> argv/glob injection.
   gsd-core/workflows/quick-batch.md and commands/gsd/quick-batch.md both
   ran `gsd_run quick-batch parse-args --raw -- $ARGUMENTS` UNQUOTED,
   causing shell word-splitting and pathname expansion on raw task-list
   text before the parser ever saw it. Fixed at the source: added a
   `--text <string>` form to the `parse-args` verb
   (src/quick-batch-command-router.cts) that accepts the ENTIRE
   $ARGUMENTS as ONE quoted argv element and does the whitespace split
   itself, in Node — which is never glob-aware, unlike the shell.
   Both call sites now use `--text "$ARGUMENTS"`. The `-- <tokens>` form
   is kept for direct/test callers that already have a real argv array.

The SC2086 baseline entry added for the original unquoted line is now
stale (`node scripts/lint-workflow-shellcheck.cjs` no longer reports
it) and has been removed; the two SC2046 entries for the UNRELATED,
still-unquoted `$([ "$VALIDATE_MODE" = true ] && echo --validate)`-style
conditional-flag splitting remain — that line only ever expands to one
of a few known-safe literal strings (never raw user text), matching
quick.md's own already-baselined convention exactly.

Tests: quick-batch-command-router.test.cjs covers the new --text form
(token splitting, glob-shaped text passing through literally
unexpanded, whitespace-only input). gsd-quick-batch-workflow.test.cjs
asserts the DATA_START/DATA_END boundary on every leaf prompt
(research-phase/planner-wave/plan-checker-loop/verification-wave,
including the shared task catalog) and the quoted --text call sites.

* fix(#3676): strengthen test-depth gaps in rows 9, 18, 24, 34, 35

Spec review pass findings — the test matrix claimed "yes" coverage
these assertions did not actually support:

- Row 9 (--jobs 0/-1/abc hostile case): previously asserted rejection
  only. Added an end-to-end assertion (tests/quick-batch-command-router.test.cjs,
  committed alongside the security fix that touches the same file) that
  .planning/quick-batches/ is never created for any rejected value —
  createBatch is genuinely never reached.
- Row 18 (--resume <unknown-batch-id>): previously only exercised a
  hand-corrupted BATCH.json, never a genuinely nonexistent batch
  directory. Added the real nonexistent-id case (also in
  quick-batch-command-router.test.cjs).
- Row 24 (post-planning updateBatchItems racing a concurrent
  completeQuickItem for a different item, both through
  withPlanningLock): zero test existed. Added a property test
  (tests/quick-batch.property.test.cjs, appended — Phase 3's own file,
  no existing test touched) exercising both call orders and asserting
  no lost update in the final on-disk manifest — the same technique
  Phase 3's own row-15 lock-contention property test uses (sequential
  calls through the real lock; a working mutex makes any interleaving
  equivalent to some serial order, so this is the same claim a literal
  concurrent-thread test would make without OS-level threading).
- Row 34 (worktree preserved on merge_failed) and row 35 (undeclared-
  deletion detection): both were previously asserted only at the pure
  routeMergeOutcome level. Added tests/gsd-quick-batch-merge-integration.test.cjs
  using the SAME real-git-fixture pattern tests/worktree-safety.test.cjs
  already establishes for executeWorktreeWaveCleanupPlan (real repo,
  real worktree, a REAL merge conflict / a REAL file deletion diffed
  against declared_deletions) — asserting the actual worktree directory
  survives on disk, not just that a pure function returns a
  preserveWorktree:true field. Named gsd-quick-batch-* so lint-test-
  file-count's bucketing doesn't fold it into any capped module bucket.

Row 48 (/gsd:quick regression) intentionally left as-is per the
reviewer's own framing: the byte-identity claim is already
mechanically proven by the changed-path diff (git diff --name-only
empty on those two paths IS byte-identity), and a genuine execution-
level regression test would require actually running the workflow —
out of scope for this repo's unit-test model (no other quick.md
regression test in this repo does that either).

* docs(#3676): add the changeset and user-facing docs the command needed

Standards review pass findings — both HARD:

- Missing changeset. None of the 6 prior #3676 commits touched
  .changeset/*. /gsd-quick-batch is a new user-facing command;
  CLAUDE.md/CONTRIBUTING.md require one. Added
  .changeset/silly-rams-caper.md (type: Added, pr: 0 placeholder —
  backfilled after the PR opens, matching CLAUDE.md's own documented
  convention and Phase 3's own precedent, #4190's
  .changeset/mellow-yaks-squeak.md). Uses the docs-convention hyphen
  form `/gsd-quick-batch` throughout, never the source-artifact colon
  form (`scripts/lint-docs-command-form.cjs` confirms 0 violations;
  that check scans docs/**, not .changeset/, so it was never actually
  in scope for the fragment itself, but the wording still follows the
  doc convention for consistency, matching how Phase 3's own fragment
  named the not-yet-shipped command).
- Missing docs. Added docs/how-to/batch-quick-tasks.md (Diátaxis
  how-to, matching docs/how-to/handle-quick-and-fast-tasks.md's
  existing convention for /gsd-quick /gsd-fast) covering --jobs,
  --validate, --research, --resume, --file, the capacity/isolation
  interaction, and resume/failure recovery. Cross-linked from
  docs/README.md's how-to index and from handle-quick-and-fast-tasks.md's
  own "Related" section. Added a /gsd-quick-batch section to
  docs/COMMANDS.md (same table format as the existing /gsd-quick
  entry) and docs/features/quick-batch.md (REQ-QB-01..12, same
  frontmatter shape as docs/features/quick-mode.md) — regenerated
  docs/FEATURES.md (179 features) and skills/gsd-quick-batch/SKILL.md
  via the standard generators.

* fix(#3676): close docs-parity, attribution, and generated-registry gaps gsd-test caught

gsd-test's real run against 155e8975b3 found 43 failures, all rooted in
this phase's own new command/workflow never being registered across
~10 independent generated/hand-maintained registries this repo keeps
in parity by convention. Root-caused each, no test weakened or
special-cased.

- help.md ↔ commands/gsd/ bidirectional parity (docs-parity-live-
  registry.test.cjs): added a /gsd:quick-batch entry to
  gsd-core/workflows/help/modes/full.md (the real help.md content;
  gsd-core/workflows/help.md is a thin dispatcher) documenting every
  flag (--file/--jobs/--validate/--research/--resume), matching the
  existing /gsd:quick entry's format.

- gen-section-manifest.test.cjs: quick-batch.md's
  `gsd_run query init.quick-batch` invocation used inline
  `$([ ... ] && echo --flag)` substitutions, which never satisfy the
  test's exact-whitespace-token / assigned-variable detection (the
  trailing `))` glued onto `--research` in the compound substitution
  broke the "exact token" match). Rewrote to the same
  VALIDATE_PARAM/RESEARCH_PARAM two-line pattern
  gsd-core/workflows/quick.md's own Step 2 already uses.

- runtime-launcher-parity.test.cjs: the 8 quick-batch/steps/*.md
  fragments that call gsd_run each needed their OWN embedded copy of
  the canonical shim preamble (every workflow .md that calls gsd_run
  carries its own copy — reading one file does not persist shell state
  into another). Ran `node scripts/sync-runtime-launcher.cjs`, which
  inserted it before each file's first gsd_run call.
  plan-checker-loop.md correctly has none — it never calls gsd_run
  directly.

- Namespace routing (skill-manifest.test.cjs, install-nested-
  layout.test.cjs, runtime-artifact-layout-surface.test.cjs): added
  `quick-batch` to commands/gsd/ns-workflow.md's `requires:` array and
  routing table (same namespace `quick` already routes through), and
  to src/clusters.cts's `utility` cluster (same cluster `quick`
  already belongs to). Verified by hand-running installRuntimeArtifacts
  + applySurface for augment/cline against a real temp install: exactly
  6 top-level gsd-ns-* router dirs, gsd-quick-batch correctly nested
  under gsd-ns-workflow/skills/, never re-flattened.

- mcp-server-catalog.test.cjs: hardcoded command count 71 -> 72 (a
  brand-new command is a real count change, not a bug this test should
  hide).

- model-omit-when-inherit-guard.test.cjs: added the canonical
  `<!-- #2517 model-omit-on-inherit -->` marker block to
  gsd-core/workflows/quick-batch.md (every leaf dispatch — planner/
  researcher/checker/executor/verifier — lives in a steps/ fragment,
  read combined with the host by this test's own readWorkflowCombined,
  same as quick.md's own research-phase.md carries it for its gated
  section). Also fixed a genuine pre-existing inconsistency in the
  test's own "#2711: the guarded set is derived from dispatch sites"
  check: its `nonDispatching` computation read the BARE host file while
  `derived` (the set it's checked against) reads the combined
  host+steps content — inconsistent with that same test file's own
  #2994 doc comment explaining why the combined read is necessary.
  quick-batch.md is the first workflow whose EVERY model="{...}"
  dispatch site lives in a mandatory (never gated) steps/ fragment —
  extracted to stay under ADR-1610's tighter NEW_FILE_CAP for a
  brand-new file — which is what exposed the mismatch. Fixed by using
  the same readWorkflowCombined read in both places.

- skill-frontmatter-contract.test.cjs: shortened
  commands/gsd/quick-batch.md's frontmatter `description` from 107 to
  91 chars (<=100 budget), and added `quick-batch.md` to the hand-
  maintained KNOWN_SKILLS consolidation allowlist with a #3676
  justification comment (a genuinely new first-party command, not a
  consolidation of an existing skill).

- workflow-fragments-emission.install.test.cjs: added `quick-batch.md`
  to the hand-maintained MARKED_WORKFLOWS set (composeWorkflow is
  deliberately NOT a no-op for it — its research-phase/verification-
  wave sections are gated).

- Regenerated all downstream artifacts (npm run build:lib && npm run
  regen:derived && npm run gen:plugin-skills -- --write && npm run
  gen:features -- --write): skills/gsd-quick-batch/SKILL.md,
  skills/gsd-ns-workflow/SKILL.md, install-tree fixtures for
  augment/cline/hermes/qwen/trae/zcode.

- emitted-attribution.test.cjs: agents/gsd-planner.md's #3676 addition
  (one new `load_mode_context` bullet pointing at the new
  gsd-core/references/planner-quick-batch.md reference) grew the file
  124 bytes without an acknowledgment trailer. Acknowledged below —
  the growth is the deliberate, additive, single-bullet change from
  the earlier feat(#3676) commit, not drift.

Verified: npm run build:lib clean, npx tsc -p tsconfig.build.json
--noEmit clean, GITHUB_BASE_REF=next npm run lint:ci fully green
(includes lint-workflow-shellcheck, lint-test-file-count,
lint-docs-command-form). The deep install/spawn/registry tests gsd-test
actually runs (docs-parity-live-registry, gen-section-manifest,
runtime-launcher-parity, install-nested-layout,
runtime-artifact-layout-surface, skill-manifest, skill-frontmatter-
contract, mcp-server-catalog, model-omit-when-inherit-guard,
workflow-fragments-emission) are not part of lint:ci — each fix above
was independently verified by hand-invoking the exact production
function the failing test calls (installRuntimeArtifacts, applySurface,
composeWorkflow, the CLUSTERS union, the section-manifest forwarding
regex) against the real repo tree and confirming the expected shape.

Emitted-Drift-Ack-Growth: gsd-planner.md — additive #3676 quick-batch mode bullet in load_mode_context (one new line pointing at gsd-core/references/planner-quick-batch.md); not drift.

* fix(#3676): trim the /gsd:quick-batch help.md entry to fit the LARGE tier line budget

skill-frontmatter-contract.test.cjs's "feature #3039: tiered help —
size budgets" enforces a SEPARATE line-count ceiling for
gsd-core/workflows/help/modes/full.md (FULL_BUDGET = 844 lines,
tighten-only ratchet, scripts/lib/allowlist-ratchet.cjs's
assertTightCeiling) — independent of the skill-frontmatter description-
length budget and consolidation allowlist I touched in the prior round;
those are unrelated checks in the same test FILE, not the same check.

Root cause: the /gsd:quick-batch entry I added to full.md in the
docs-parity fix round was 17 lines, pushing the file from 834 to 851
lines — 7 over the 844 ceiling. Condensed the entry (merged the
per-flag bullet list into one dense "Flags:" line, dropped from 3
Usage examples to 1) to 844 lines exactly — at the ceiling with zero
slack, which assertTightCeiling accepts (it only fails on
actualMax > ceiling, or on slack > grace when the ceiling is too
LOOSE — zero slack triggers neither).

Verified after trimming: full.md still contains a live /gsd:quick-batch
reference (bidirectional parity) and all 5 argument-hint flags
(--jobs/--validate/--research/--resume/--file) still appear as literal
tokens (docs-parity-live-registry.test.cjs's own flag-coverage check,
re-run by hand against the trimmed content).

Verified: npm run build:lib clean, npx tsc -p tsconfig.build.json
--noEmit clean, GITHUB_BASE_REF=next npm run lint:ci fully green.

* docs(#3676): backfill changeset pr number to 4212

Follow-up to fix(#3676) commits — .changeset/silly-rams-caper.md's
pr:0 placeholder backfilled with the real PR number now that
gh api POST /pulls has returned it (#4212). Matches CLAUDE.md's PR
Number Handling convention and Phase 3's own #4190 precedent
(708c5a3f8c). Doc-only (root-level .changeset/*.md fragment), exempt
from a fresh gsd-test run per pre-pr-gate.sh's DOC_ONLY_RE.

* fix(#3676): resolve prompt-injection-scan false positive on test fixture

tests/quick-batch.test.cjs:232's row 11b regression proves the task-list
parser carries a prompt-injection-shaped task description through
createBatch as inert data, never interpreted. The fixture has to be a
real "ignore all previous instructions..." phrase or the test asserts
nothing, but the full-file --diff scan flagged it once unrelated edits
in the same file pulled it into the changed-file set.

Add the file to prompt-injection-scan.sh's ALLOWLIST, matching the
sanctioned, precedented exemption already used for other legitimate
security-regression fixtures (tests/windsurf-conversion.test.cjs,
tests/health-validation.test.cjs, tests/continuation-grammar-parity.test.cjs)
per DEFECT.PROMPT-INJECTION-SCAN-COLLISION.

---------

Co-authored-by: sim <sim@local>
2026-09-02 22:38:31 -04:00
Tom Boucher
7d6d788b51 fix(#4020): bound the test run's temp footprint with a swept run-scoped root (#4207)
* test(#4020): the runner must bound and sweep a run-scoped temp root

* fix(#4020): bound the run's temp footprint with a swept run-scoped root

* test(#4020): isolate the env-mutating rows in child processes

* fix(#4020): gate root removal on ownership so nested runners spare the outer root

* test(#4020): pass the probe file via --files, the runner's explicit-file flag

* test(#4020): resolve the probe by basename, as --files matching requires

* test(#4020): assert root survival, not content survival, in the nested-row

* chore(#4020): changeset for the run-scoped temp root

* chore(#4020): backfill changeset pr number

* fix(#4020): the sweep spares ancestors of the runner's own selected files

* fix(#4020): TMPDIR precedence — an operator redirect beats inherited TEMP/TMP

* fix(#4020): only the root's owner sweeps — a nested runner spares live sibling fixtures

---------

Co-authored-by: sim <sim@local>
2026-09-02 20:47:34 -04:00
Tom Boucher
858bb89769 ci(#4196): exempt dependabot[bot] from issue-link, title, and unsolicited-PR gates (#4203)
Dependabot has no mechanism to link a PR it opens to a repo issue -- its
alerts live in the Security tab, not as issues -- so require-issue-link,
pr-title-validator, and auto-close-unsolicited-prs all rejected its PRs
by design (confirmed live on #4193: auto-closed for "no pre-approved
issue", then flagged again by the title gate on reopen). Exempt by
authenticated author login (github.event.pull_request.user.login /
context.payload.pull_request.user.login), which GitHub attributes and a
crafted title or branch name cannot forge -- scoped narrowly to
dependabot[bot] only, no other author gets this treatment.

Co-authored-by: sim <sim@local>
2026-09-02 15:56:43 -04:00
Tom Boucher
2131fe13f3 enhance(#3464): exec() detection widening, citation-debt cleanup — Phase 8 (#4171)
* feat(#3464): widen no-source-grep to detect regex.exec() on tracked text

Adds an execCall kind alongside the existing regexTest detection --
regex.exec(tracked) was invisible to the rule while regex.test(tracked)
was already caught, despite both reading a source-derived string through
a regex. Measured: 4 previously-invisible sites across 2 files.

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

* test(#3464): migrate 4 sites newly flagged by the exec() widening

docs-hooks-table-parity.test.cjs's three regex-extraction loops are
site-scoped marked (source-text-is-the-product) -- the dynamic
preToolEvent/postToolEvent dialect branching they mirror is explicitly
documented as not statically parseable, so a literal-pattern mirror is
the practical minimum-cost check.

no-bare-gsd-tools-command-position.test.cjs's readRouterVerbs() now
requires HOST_COMMAND_ROUTERS directly instead of regex-walking
gsd-tools.cjs's source text -- the same accessor three other suites
already use.

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

* fix(#3464): pay down 6 grandfathered uncited allow-test-rule markers

Two were genuinely load-bearing (suppressing a real detected violation)
and just needed a citation added -- phase6-capstone-conformance.test.cjs,
runtime-name-policy.test.cjs, both now (#3464).

Four were dead-weight file-header markers suppressing nothing -- each
file's real effective sites are covered by separate, already-cited
markers elsewhere in the same file. Deleted outright rather than cited,
per Phase 1's own precedent (remove non-load-bearing markers instead of
grandfathering them forever) -- codex-config.test.cjs (two copies),
gsd-check-update-worker-platform-gate.test.cjs, orphaned-hooks.test.cjs,
settings-jsonc.test.cjs.

allowlist.json: 134 -> 128 entries.

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

* chore(#3464): re-baseline effective-exemption ceiling to 84

The exec() widening's 3 newly-marked sites are now suppressed and
counted; ceiling rises 81 -> 84, the exact measured high-water mark.

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

* fix(#3464): correct citation and restore a wrongly-deleted marker

Two review corrections, both found by the orthogonal review pass:

- docs-hooks-table-parity.test.cjs's 3 new exec() markers cited #3464
  (mechanically "the phase that widened the rule") when the file's own
  established, correct reference is #3839 (the issue this whole test
  exists to enforce, already cited in its file header) -- fixed to match.

- gsd-check-update-worker-platform-gate.test.cjs's deleted file-header
  marker was NOT dead weight: its codeOnly() helper wraps readFileSync
  and is called inline as an assert argument, a genuine source-grep
  pattern on real .cjs/.js source that the rule cannot currently see
  (helper-function indirection is a distinct blind spot from anything
  Phase 7/8 measured) -- CONTRIBUTING.md is explicit that "unverified"
  is not the same as "vestigial." Restored, site-scoped this time
  (directly above codeOnly(), not as an inert file-header comment) and
  cited (#3103, the issue the file's own docstring already references).

codex-config.test.cjs's two deletions and orphaned-hooks.test.cjs's /
settings-jsonc.test.cjs's deletions were independently re-verified and
stand: their flagged lines read generated .toml/.json OUTPUT, not
source, or have no residual pattern at all.

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

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 08:11:23 -04:00
Carlos Cativo
9b77320580 fix(#4076): add missing gsd-hook-version header to gsd-node-runner.sh (#4092)
* fix(#4076): add missing gsd-hook-version header to gsd-node-runner.sh

gsd-node-runner.sh was registered in MANAGED_HOOKS but shipped without a
gsd-hook-version header, so gsd-check-update-worker.js always classified it
as 'definitely stale' (a missing header is indistinguishable from a
pre-version-tracking file). Every install on an otherwise up-to-date
version showed a permanent, unclearable '⚠ stale hooks — run /gsd-update'
warning naming this one file.

Root cause: the build-hooks.js comment claimed the file is 'not a
registered hook' and 'staged verbatim — no templating', but it IS in
MANAGED_HOOKS (managed-hooks-registry.cjs:34) and install.js already
stamps {{GSD_VERSION}} into every .sh hook unconditionally, gsd-node-runner.sh
included. The comment contradicted both the registry and the installer's
actual behavior, and the header line itself was simply never added.

Fix: add the header (matching every other managed .sh hook's format) and
correct the comment so it no longer asserts the opposite of what the
registry and installer actually do.

Adds a regression test that iterates every MANAGED_HOOKS entry and asserts
it carries a header matching the worker's own detection regex, so a future
hook added to the registry without one fails CI instead of shipping
silently.

Fixes #4076

* chore(#4076): add changeset fragment for PR #4092

* fix(#4076): address review nits — drop unneeded exemption, fix blank line

Per @trek-e's review on #4092:
- tests/managed-hooks.test.cjs:96: the readFileSync call uses a loop
  variable (entry-derived hookPath), not a literal path, so
  local/no-source-grep's static literal-path detector never flags it —
  the allow-test-rule exemption comment was unnecessary. Replaced with a
  plain note explaining the source-read rationale.
- tests/managed-hooks.test.cjs:121-122: dropped a stray extra blank line
  before the bug #2136 section divider.

---------

Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-02 08:04:37 -04:00
Tom Boucher
5c7243e54b fix(#3995): derive the review diff base from the phase directory (#4181)
* test(#3995): diff base keys on the phase directory, not commit subjects

All three derivation sites (Tier 3, spawn_reviewer, fallow pre-pass)
must anchor on the phase directory's first commit; the milestone-blind
repro (an archived milestone's same-numbered phase commit capturing
the base) is the failing-first row. #3191/#3503 rows reworked to the
directory-anchor contract; T6 docs-parity forbids any remaining
phase-scope message-grep site.

* fix(#3995): derive the review diff base from the phase directory

A phase number is unique within a milestone, not a repository; the
message grep had no milestone bound and tail -1 deliberately selected
the oldest same-numbered subject, dragging archived milestones phases
into the scope (7 files to 3388 plus the >50 depth downgrade). All
three lockstep sites now anchor on the first commit that added anything
under the phase own directory — the same anchor class
git-base-branch phaseStartCommit uses. ShellCheck baseline gains the
escaped fragment shifted parse signature.

Emitted-Drift-Ack-Growth: code-review.md — phase-directory anchor replaces the message-grep derivation at both sites (#3995)

* chore(#3995): backfill changeset pr number

---------

Co-authored-by: sim <sim@local>
2026-09-02 06:03:17 -04:00
Tom Boucher
f16ff7d1b3 enhance(#3545): widen no-source-grep with fold+hooks, migrate 76 sites (#4161)
* feat(#3545): widen no-source-grep with one-hop path-fold and hooks dir

Resolve a readFileSync() path argument that is a bare Identifier one hop
back to its VariableDeclarator initializer before classification, and
recognize `hooks` as a source directory alongside bin/lib/gsd-core/src.

Measured (epic #3464 phase 7): fold+hooks together newly flag 76
unsuppressed sites across 18 files that were previously invisible to
identifier-indirected or hooks/-rooted source reads. Neither widening
alone is sufficient — hooks-only surfaces 0 new sites, confirming #3520's
prior finding that the identifier-indirection gap must close first.

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

* test(#3545): migrate 76 sites newly flagged by the fold+hooks widening

Per-site classification: rewrite behaviorally (require() the real module,
assert on its actual exported behavior) wherever the read was a proxy for
code behavior; add a site-scoped `// allow-test-rule: <reason> (#3545)`
marker only where the raw source text genuinely is the product under test
(codex-config.test.cjs's adapter-header-contract checks, install.js
structural-wiring guards with no exported symbol, AST-parse fixture
inputs, etc.) — each marker cites an existing repo-sanctioned category
from CONTRIBUTING.md's allow-test-rule exception table.

Also converts two try/finally test bodies (introduced during this same
migration) to the required t.after() cleanup pattern per CONTRIBUTING.md.

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

* chore(#3545): re-baseline effective-exemption ceiling to 81

The fold+hooks widening's own newly-detected sites are now suppressed by
site-scoped markers, moving them from invisible into the tightly-ratcheted
effective-exemption count. Ceiling rises from 10 to 81 (the exact measured
high-water mark, grace unchanged at 2) — a deliberate, measured re-baseline
per the widening working as intended, not an ordinary ceiling bump.

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

* fix(#3545): use canonical allow-test-rule category tokens

4 markers added during migration cited an issue ref correctly but didn't
use one of CONTRIBUTING.md's seven recognized category tokens, unlike
every other marker in this change. Cosmetic only — same suppression
lines, same effective/live counts (81/81, 0 live).

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

* fix(#3545): correct stale phase-artifact path in test comment

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

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 21:40:38 -04:00
Tom Boucher
4dfc46bbe7 enhance(#3348): add a context-drift pre-check gate to plan-phase (#4147)
* test(#3348): add failing-first coverage for the context-drift gate

* feat(#3348): add context-drift pre-check gate for plan-phase

Compares each phase's *-RESEARCH.md/*-PATTERNS.md/*-VALIDATION.md/*-SPEC.md
effective last-changed time (git commit time, falling back to mtime for
uncommitted edits) against *-CONTEXT.md's, so plan-phase no longer silently
reuses an upstream artifact that predates a decision added to CONTEXT.md
after that artifact was derived from it. Deterministic, no model call.

New `gsd_run verify context-drift <phase>` command, sibling to the existing
verify.codebase-drift/verify.schema-drift gates in the drift capability.
Warn-only by default (workflow.context_drift_precheck), with an opt-in
workflow.context_drift_action: block escape hatch. Wired at plan:pre in
plan-phase.md, before both the RESEARCH.md and PATTERNS.md reuse decisions.

* fix(#3348): address code-review findings — raw-text-match, stale comment, import placement, duplicated phase resolution

* fix(#3859): pin the real commit's diff.ignoreSubmodules to match the empty-diff probe

The #3859 empty-diff guard decides whether a submodule bump would land using
`--ignore-submodules=dirty`, overriding the caller's `diff.ignoreSubmodules`
config. The real `git commit -- <paths>` that follows was never given the
same override, so under a bare `diff.ignoreSubmodules=all` repo config the
two calculations disagree: driven on git 2.39.5 (Debian bookworm, the
linux-node24 test-matrix image), the guard correctly stands aside but the
scoped commit itself then silently fails (exit 1, no error text) for a
gitlink bump it had just confirmed would be recorded, surfacing as
commit_failed instead of committed:true.

Pin `-c diff.ignoreSubmodules=dirty` onto the scoped commit call too, so the
probe and the commit it protects can never diverge. Harmless when no
submodule path is involved (driven: identical outcome on an ordinary scoped
file, with and without the flag).

* fix(#3348): guard resolvePhaseDirByToken's exact-match fallback against path traversal

* fix(#3348): retarget phase-enumeration-drift exemption to the consolidated resolvePhaseDirByToken helper

cmdVerifySchemaDrift's inline readdirSync was already function-scoped-exempt
in lint-phase-enumeration-drift.cjs as a single-phase LOOKUP (not a
current-milestone enumeration). This PR's refactor pass lifted that block
into a shared helper, resolvePhaseDirByToken, also used by the new
cmdVerifyContextDrift — the guard tracks exemptions by enclosing function
name, so the readdirSync now lives in an unexempted function and started
firing. Move the exemption to resolvePhaseDirByToken (same written reason,
now covering both callers) instead of migrating to listAllPhaseDirs, which
would introduce two real behavior deltas here: it catches readdirSync
failures internally (old code let them throw) and sorts results by phase
number before matchPhaseDirs picks matches[0] (old code used raw,
OS-dependent readdirSync order).

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

* fix(#3348): satisfy lint:ci — slash form, capability registry regen

- docs/features/context-drift-gate.md used the deprecated /gsd: colon
  form; docs are never passed through the install-time slash-form
  converters, so lint-docs-command-form requires the hyphen form.
  Regenerated docs/FEATURES.md from the corrected fragment.
- Regenerated gsd-core/bin/lib/capability-registry.cjs after editing
  capabilities/drift/capability.json (lint:generated-sync).

* fix(#3859): pin the real commit's diff.ignoreSubmodules via env, not argv -c

The prior fix pinned `-c diff.ignoreSubmodules=dirty` onto the scoped commit's
argv via `commitArgs.unshift(...)`. `-c key=val` must precede the `commit`
subcommand, so this shifted `commitArgs[0]` from `'commit'` to `'-c'` for
every scoped commit call, breaking 17 position-based assertions in the
commit-files pathspec regression suite that read `a[0] === 'commit'` to find
the commit invocation among recorded git calls.

`execGit` already accepts an `env` option merged onto `process.env` before
spawning. Git honors `GIT_CONFIG_COUNT`/`GIT_CONFIG_KEY_0`/`GIT_CONFIG_VALUE_0`
as a per-invocation config override functionally identical to `-c key=val`,
expressed via env instead of argv. Passing that env alongside the existing
commitArgs (still `['commit', ..., '--', ...stagedPaths]`, argv unchanged)
fixes the real commit's effective diff.ignoreSubmodules to match the
empty-diff guard's probe without moving anything in argv position 0. Scoped
to exactly the canScope branch, matching the probe's own preconditions and
leaving no behavior change for commits the probe never evaluated.

No test file changes needed — the 17 previously-failing assertions test
argv[0] against the array passed into execGit, which never changes.

* fix(#3348): register verify-context-drift in the check subcommand router

The drift capability's new plan:pre gate declares check.query
"verify.context-drift", which normalizes to `check verify-context-drift`,
but no such subcommand was routed — phase6-capstone-conformance's
uniform-block-field test failed with "Unknown check subcommand" for
every declared gate query.

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

* fix(#3348): extend #1592's exact-key-list snapshot for the new context-drift config keys

tests/capability-registry.test.cjs asserted an exact, hardcoded snapshot
of the drift capability's config keys. #3348 legitimately adds two new
keys (workflow.context_drift_precheck, workflow.context_drift_action)
for its own plan:pre context-drift gate — extend the expected set
(and clarify the assertion message) without weakening the test's
exactness.

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

* fix(#3348): reconcile E2's exemption-migration pin with the resolvePhaseDirByToken extraction

#3348 (an earlier commit on this branch, e4b80ad81) extracted
cmdVerifySchemaDrift's inline phasesDir readdirSync/matchPhaseDirs block
into the shared resolvePhaseDirByToken helper (also used by the new
cmdVerifyContextDrift), and retargeted lint-phase-enumeration-drift.cjs's
function-scoped exemption from cmdVerifySchemaDrift to
resolvePhaseDirByToken accordingly — cmdVerifySchemaDrift no longer
contains a line the guard's detectors match, so it needs no exemption.

tests/phase-locator.test.cjs's E2 test still pinned the exemption to the
old name (cmdVerifySchemaDrift), unaware of the migration. Update E2 to
match the same "migrated call site's exemption must move, not
duplicate" pattern the test already applies to cmdRoadmapAnalyze and
cmdInitMilestoneOp just below it: drop cmdVerifySchemaDrift from the
still-exempt list and add symmetric assertions that it no longer
carries the exemption while resolvePhaseDirByToken now does.

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

* fix(#3348): fix two self-contradicting/nondeterministic tests in context-drift.test.cjs

'always exits 0 (query command contract)' included the no-phase-arg
case, which contradicts the file's own earlier
'errors with usage message on missing phase arg' test (that case
legitimately exits 1 via the Usage error) — drop it from the
always-exits-0 cases.

'degrades to mtime comparison outside a git repo' and '...in a repo
with no commits' relied on real wall-clock ordering between two
back-to-back writeFileSync calls to prove CONTEXT.md is newer than
RESEARCH.md; on a fast filesystem both can land in the same mtime
tick, producing a tie that computeContextDrift's strict `<` correctly
treats as not-stale, so stale_artifacts comes back empty. Make both
tests deterministic via explicit fs.utimesSync instead of relying on
timing (CONTRIBUTING.md: never assert elapsed wall-clock time).

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

* fix(#3348): add context_drift_precheck:false to the plan:pre all-off fixture

The "all plan:pre when-keys false" fixture explicitly disables every
known workflow.* plan:pre toggle, but didn't yet know about the new
workflow.context_drift_precheck key (defaults to true), so the new
drift context-drift gate stayed active and broke the
empty-activeHooks assertion.

Emitted-Drift-Ack-Growth: plan-phase.md — adds the #3348 context-drift plan:pre pre-check section (new ## 4.6); this PR's own diff, not incidental drift.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(#3348): backfill changeset PR number (pr:0 -> 4147)

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 21:39:37 -04:00
Dennis Alexis Valin Dittrich
b848b23861 feat(#3778): dispatch plan:pre planner contributions before quick planning (#3934)
* feat(#3778): dispatch plan:pre planner contributions in quick.md

- Add plan:pre capability gate to quick.md Step 5, mirroring plan-phase.md's
  existing render + generic contribution dispatch pattern
- Inject planner-targeted contribution fragments into the planner prompt,
  after AGENT_SKILLS_PLANNER, matching D-08 ordering
- Add tests/quick-plan-pre-capabilities.test.cjs proving the dispatch is
  generic (D-01) via real scanWiredKinds/coveredKindsInRegion functions
- Record quick.md's byte-growth rationale in this commit trailer for every
  gsd-core-verbatim runtime

Emitted-Drift-Ack-Growth: quick.md — #3778: Step 5 (Spawn planner, quick mode) gains a `plan:pre` capability gate, mirroring `plan-phase.md:420-424` and `:797`. This is shipped shell and prose read by an agent at runtime, not compiled, so the reasoning has to travel with the feature rather than being deferred to a reference doc: (1) the dispatch paragraph phrases role routing possessively ("the role each entry's `into` names") rather than as an `into ==` equality, because `coveredKindsInRegion` (scripts/gen-loop-host-contract.cjs) voids a segment's `kind == "contribution"` coverage credit when a role or capability equality shares that same segment — an equality phrasing here would silently fail the generic-dispatch proof required by D-01; (2) `activeHooks` is read directly in-context from `PLAN_PRE_HOOKS_JSON`/`HOOKS_JSON` and the unfiltered `rendered` digest is explicitly forbidden from being pasted, because `rendered` carries every kind and role — including non-planner-targeted contributions such as a `into: "checker"` twin — and pasting it would leak checker-scoped guidance into the planner's prompt (T-01-02 in the threat model); (3) the injection block sits inside `<planning_context>` AFTER `${AGENT_SKILLS_PLANNER}` and after the Project skills line, matching plan-phase's `:741` -> `:797` ordering (D-08), so agent-skills content is never shadowed by capability-contributed prose. No prose was moved into an eagerly `@`-imported reference to shrink the measured file — @gsd-core/references/loop-hook-dispatch.md already existed before this change and is deferred to for the generic contract only, exactly as plan-phase.md already does.

* test(#3778): expand quick.md plan:pre dispatch coverage to all nine locked conditions

Extend tests/quick-plan-pre-capabilities.test.cjs with D-02 (silent
omit-when-empty), D-03 (single shared planner spawn), D-06 (array-order
dispatch phrasing), D-07 (planner-only into filter), and D-08 (render call
< agent-skills placeholder < injection block < spawn ordering) assertions,
all extracted via a brace-bounded slice anchored on the literal injection
instruction rather than a naive first-brace scan (${AGENT_SKILLS_PLANNER}
and the surrounding prompt's ${VALIDATE_MODE ? ...} ternaries also contain
brace pairs).

Add a capability-registry.test.cjs describe block proving the registry-wide
D-07 exclusion is meaningful: at least one plan:pre contribution exists,
every plan:pre contribution has a non-empty into/fragment.inline, and the
registry as a whole carries at least one non-planner-into contribution.

Add a loop-host-contract.test.cjs regression pin for D-09: quick.md stays
absent from STEP_WORKFLOWS, parseLoopHostBlock still throws on quick.md's
real content, and buildContract() still yields exactly 5 entries.

Verified red-without-Task-1 by temporarily reverting quick.md to its
pre-f30de9cc content and re-running these three suites (D-08 failed as
expected), then restored via git checkout and re-confirmed green.

* docs(#3778): note quick planning also renders plan:pre in the tutorial

The tutorial's Step 6 named only /gsd-plan-phase as the trigger for the
plan:pre hook set. Since quick.md now dispatches the same hook set
(f30de9cc), the sentence understated the capability's real reach.

* feat(#3778): add changeset fragment

* chore(#3778): reference the upstream issue in the changeset fragment

The fragment was the only one of 81 in .changeset/ without a trailing
(#NNNN) reference or a bold lead-in. serializeChangelog auto-appends
only the pr: field, so the rendered CHANGELOG entry carried no link
back to issue #3778.

* test(#3778): scope the D-07 registry assertion to what it actually proves

The registry-wide non-planner check was named "D-07 exclusion is
meaningful", which overclaims: it proves only that `into` takes
non-planner values somewhere in the registry, not that anything is
excluded at plan:pre. Every plan:pre contribution is currently
into: "planner", so the filter is a forward-looking safeguard there.

Narrowing the assertion to plan:pre (as review suggested) would fail
today. Asserting plan:pre is all-planner would be brittle — it would
break the day a legitimate non-planner plan:pre contribution lands,
which is exactly when the safeguard starts doing work. So the
assertion is unchanged and only the name and comment are corrected.

* chore(#3778): point the changeset fragment at the upstream PR

The fragment carried pr: 3, the fork staging PR. changeset lint derives
the real PR number from GITHUB_EVENT_PATH, so on the upstream PR that
would read as pr-field drift. Point it at open-gsd/gsd-core#3934.

* test(#3778): require contributions in Quick revision prompts

* test(loop-host): require Quick auxiliary registration

* fix(#3778): preserve contributions in Quick plan revisions

* fix(#3778): validate Quick as a planner contribution host

* fix(#3778): tighten Quick contribution contract

* test(#3778): drop unnecessary source-contract exemption

* fix(#3778): require Quick planner target coverage

* docs(#3778): describe targeted auxiliary coverage

---------

Co-authored-by: davdittrich <davdittrich@gmail.com>
Co-authored-by: CI Rebase Check <ci@gsd-redux>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-01 21:16:57 -04:00
Tom Boucher
eca9c2b590 fix(#4112): portable grep in workflow markdown + submodule commit fix under diff.ignoreSubmodules (#4149)
* fix(#4112): ban GNU-only grep -P in workflow markdown to prevent macOS regressions

Add scripts/lint-portable-grep.cjs, wire it into lint:ci, and add
tests/lint-portable-grep.test.cjs. The #4112 shell-syntax fix newly exposed
a pre-existing grep -oP invocation that silently resolves to "" on stock
macOS's BSD grep (no -P support). This ratchet catches the same class of
GNU-coreutils assumption before it merges, mirroring lint-portable-timeout.cjs.

* fix(#4112): drop unneeded ls -l long-format that broke basename/dirname extraction

The previous commit on this branch replaced grep -oP 'phases/\K[^/]+'
(GNU-only, silently fails on macOS's BSD grep) with
basename "$(dirname "$phase")"), but left ls -lt (long format, -l) in
place. -l output is a full detail line (permissions, owner, size, date,
path), not a bare path, so dirname on that string throws "illegal
option -- r" (BSD) / errors under GNU coreutils too -- the extraction
never produced a usable value, on any platform. -l was never needed
here; only mtime-sort plus the bare path mattered. Dropping it to
ls -t restores one-bare-path-per-line output, which basename/dirname
actually requires. Confirmed on gsd-test's Linux bench: the #4112
regression test (tests/pause-work-context-detection.test.cjs) now
passes phase, spike, and sketch resolution.

Emitted-Drift-Ack-Growth: pause-work.md — portability fix (#4112): dropping grep -oP for a portable basename/dirname extraction is a few characters longer per line; no functional growth beyond the fix.
Emitted-Drift-Ack-Growth: sync-skills.md — portability fix (#4112): replacing grep -oP '(?<=--from )\S+' with a portable sed -E capture-group equivalent (no PCRE lookbehind available) is a longer expression; growth is the direct cost of the fix, not new functionality.

* fix(#3859): pin git commit itself against diff.ignoreSubmodules=all, not just the probe

git 2.39.x (the exact version on the CI Linux bench) resolves a
pathspec-scoped `git commit -- <path>` through the same
diff.ignoreSubmodules-gated machinery as `git diff`, so a genuinely
bumped submodule gitlink was silently dropped by the real commit even
though the #3859 empty-diff probe correctly saw the change. The commit
was misclassified as generic commit_failed because git's refusal text
never says "nothing to commit". Prefix the pathspec-scoped commit
invocation with -c diff.ignoreSubmodules=dirty, the same override the
probe already carries, so the two can no longer disagree. Confirmed on
git 2.50.1 (no-op) and git 2.39.5 via the actual gsd-tester-linux
v1.8.0-node24 bench image (turns the silent refusal into a commit).

* fix(#3859): carry the diff.ignoreSubmodules override via env, not argv

The prior commit prepended `-c diff.ignoreSubmodules=dirty` to the real
commit's argv, which shifted `commitArgs[0]` off `'commit'` and broke
several pre-existing #3859 regression tests
(tests/commit-files-pathspec.test.cjs) that assert on the raw argv
captured at the execGit seam, e.g. `gitCalls.some((a) => a[0] ===
'commit')`. Carry the same override via `GIT_CONFIG_COUNT` /
`GIT_CONFIG_KEY_0` / `GIT_CONFIG_VALUE_0` env vars instead, which git
honours identically and leaves argv untouched. Re-confirmed on the
gsd-tester-linux v1.8.0-node24 bench (git 2.39.5): the submodule commit
still succeeds.

* test(#3859): pin the commitEnv GIT_CONFIG_* override to canScope directly

The commitEnv/GIT_CONFIG_* override added in e935694fc/b3d37b929 was only
exercised indirectly through pre-existing submodule integration tests. Add
two dedicated regression tests that pin the canScope=false side of the
scoping decision: a whole-index commit (no --files) and an --amend commit
must both keep recording a bumped submodule gitlink under
diff.ignoreSubmodules=all with no override applied, since neither shape
carries a pathspec for that git internal check to consult.

* fix(#4112): match grep invocations after then/do/else/elif shell keywords

lint-portable-grep.cjs's GREP_INVOCATION_RE only anchored to line-start or
right after `| & ; ( \` {`, so a grep call positioned right after `then`,
`do`, `else`, or `elif` (e.g. `if x; then grep -oP '...'; fi`) was never
flagged, letting the GNU-only grep -P defect this lint exists to catch
reappear undetected in that shape.

* fix(#3859): apply the diff.ignoreSubmodules override to every commit, not just scoped ones

The commitEnv GIT_CONFIG_* override landed in e935694fc/b3d37b929 only when
canScope was true, on the assumption that git 2.39.5's silent refusal of a
bumped submodule gitlink under diff.ignoreSubmodules=all only affects a
pathspec-limited `git commit -- <paths>`. Reproduced directly against the
pinned CI tester image (ghcr.io/open-gsd/gsd-tester-linux:v1.8.0-node24,
git 2.39.5): a bare whole-index `git commit -m ...` with no pathspec at all
is refused identically when the only staged change is a submodule gitlink,
since git's "nothing to commit" check is a real diff against HEAD that
honours diff.ignoreSubmodules regardless of whether a pathspec narrows it.
Apply the override unconditionally instead of gating it on canScope; it
remains a confirmed no-op for --amend, which never hits this refusal at
all. Caught by the new regression test added in 579ac9f8f, which failed
against the real bench git version before this change.

* fix(#3859): apply diff.ignoreSubmodules override to commit-to-subrepo and pr-subrepo

cmdCommit already carries the GIT_CONFIG_* override that forces
diff.ignoreSubmodules=dirty on the actual git commit call so a bumped
submodule gitlink is not spuriously refused under git 2.39.5. The two
sibling multi-repo commit paths, cmdCommitToSubrepo and cmdPrSubrepo,
build the identical canScope-branched commit invocation but never
carried the override, so the same refusal there surfaces as a generic
commit_failed/error instead of a recorded gitlink. Apply the override
unconditionally to both, matching the corrected cmdCommit shape.

* fix(#3859): pin pr-subrepo's change detection against diff.ignoreSubmodules

cmdPrSubrepo discovers what to commit via `git status --porcelain`, which
(like the empty-diff probe fixed for cmdCommit) honors a local
diff.ignoreSubmodules=all config. Under that config, a genuinely bumped
submodule gitlink is invisible to the status scan, so changedFiles comes
back empty and the function reports nothing_to_commit before ever reaching
the commit call this same issue already fixed. Pin the status probe with
--ignore-submodules=dirty, mirroring the flag cmdCommit's diff probe already
uses, so a real gitlink bump is detected regardless of local config.

Found while adding a regression test for the previous #3859 follow-up fix:
the test failed not on the commit step but on this earlier detection step.

* docs(#3859): update changeset to cover all three fixed commit sites

* docs(#4112): backfill changeset PR number (pr:0 -> pr:4149)

---------

Co-authored-by: sim <sim@local>
2026-09-01 17:31:01 -04:00
Tom Boucher
80d2236ca5 fix(#4112): drop redundant $(( subshell wrapping in pause-work.md Context Detection (#4140)
* test(#4112): add failing-first regression coverage for pause-work.md Context Detection

Extracts and executes the shipped Context Detection bash block to prove the
$(( arithmetic-context misparse (SC1102/SC1106/SC2205) as a hard syntax
error, plus boundary/independence coverage for the phase/spike/sketch/
deliberation resolution the fix must preserve.

* test(#4112): fix false-green regression test — assert dash/sh failure, not bash -n

The prior version asserted bash -n syntax validity and wrapped bash -c
execution, neither of which detects this bug: bash/zsh silently fall back to
a working command-substitution reading of the ambiguous $(( construct, and
bash -n never evaluates arithmetic-context content at parse time. A gsd-test
run against the still-broken file passed 41594/41594 with this test in
place — a false green. POSIX sh/dash does not implement that fallback and
throws a real "Syntax error: Missing '))'", which is what this version
asserts against.

* fix(#4112): remove leaked tool-call tags corrupting the regression test file

A subagent's Write introduced trailing </content>/</invoke> markup at the
end of tests/pause-work-context-detection.test.cjs. gsd-test's own
tests/portability-rule-disable-ban.test.cjs (which parses every test file)
correctly caught this as a parse error. Strips the garbage lines; no
behavioral change.

* fix(#4112): drop redundant $(( subshell wrapping in pause-work.md Context Detection

phase=/spike=/sketch= opened with $((, which POSIX sh/dash parses as
arithmetic expansion and rejects (Syntax error: Missing '))') since the
enclosed text isn't valid arithmetic. bash/zsh silently retry it as command
substitution, which masked the defect there. Dropping the inner grouping
parens (matching the deliberation= line already in the same block) makes
the construct valid under bash, zsh, and dash alike, with identical
fallback-to-empty-string behavior when nothing matches.

Once the surrounding syntax parses, ShellCheck can now also analyze the
inner ls -lt calls it previously couldn't see past the parse failure,
newly surfacing the same pre-existing SC2012 ("use find instead of ls")
suggestion already baselined for the file's other ls usage. Baseline
updated to reflect the true current count; no ls-vs-find behavior change
made, as that is a separate, pre-existing, out-of-scope question.

* fix(#4112): verify shell discrimination instead of trusting the binary name

Code review finding: falling back from dash to sh could silently produce a
non-discriminating test on a host where /bin/sh is bash-compatible (e.g.
macOS) — such a shell never throws on the broken construct either, so the
test would pass whether the bug were present or not. resolvePosixShell now
verifies the candidate actually rejects a known-ambiguous $(( snippet
before using it, and skips with an explicit reason when none does. The
gsd-test Linux bench (dash as /bin/sh) is unaffected either way.

* fix(#4112): route regression test through process-seam, splitLines, cleanup

npm run lint:ci flagged 7 violations gsd-test's node:test run doesn't check:
local/no-adhoc-markdown-parsing (single fence-spanning regex),
local/no-crlf-fragile-split (bare \n split), local/no-unbounded-spawn (x4,
hand-rolled execFileSync with no timeout), and local/no-raw-rmsync-in-tests.
Rewrites the test to route every subprocess call through
tests/helpers/process-seam.cjs's runHook() (bounded by construction),
tests/helpers.cjs's cleanup() for temp-dir removal, and a line-by-line fence
scan using the text-lines.cts splitLines() seam, matching this repo's
established extraction idiom (tests/no-hardcoded-home-gsd-tools.test.cjs).
No behavioral change to what is asserted.

* docs(#4112): add changeset for the pause-work Context Detection fix

* docs(#4112): backfill changeset PR number (pr:0 -> pr:4140)

---------

Co-authored-by: sim <sim@local>
2026-09-01 13:03:33 -04:00
Tom Boucher
05092ff369 fix(#4113): add no-op to empty then-body in chunked-planning-mode.md's outline resume-check (#4125)
* fix(#4113): add no-op to empty then-body in chunked-planning-mode.md's outline resume-check

The `### 8.5.1 Outline Phase` resume-detection `if` block only contained a
comment in its `then` clause, which is a syntax error under both bash and
zsh if executed literally. The workflow ShellCheck lint added by #4109
already flags this (SC1009/1048/1072/1073/2105) but the findings were
masked by matching entries in the pre-existing-findings baseline; those
five entries are removed here so a regression re-surfaces as a new
failure instead of being silently re-absorbed.

* chore: backfill changeset PR number for #4125

* fix(#4113): fix second empty-then/continue-outside-loop defect surfaced by baseline cleanup

Removing the 5 stale baseline entries for chunked-planning-mode.md in the
prior commit unmasked a second, genuinely separate ShellCheck finding
(SC2105) in a different fenced block of the same file: a bare `continue`
outside any bash loop in the ### 8.5.2 per-plan resume-check. Same root
cause class as the original bug (pseudocode fenced as literal bash) —
fixed the same way, with a `:` no-op preserving the documentation.

---------

Co-authored-by: sim <sim@local>
2026-09-01 00:55:44 -04:00
Tom Boucher
cad70f4f3e fix(#4120): replace shellcheck npm dep with dependency-free downloader (#4121)
* fix(#4120): replace shellcheck npm dep with dependency-free downloader

The `shellcheck` devDependency (added in #4109) pulled in decompress@4.2.1
for archive extraction, which carries an unpatched CRITICAL zip-slip
vulnerability (GHSA-mp2f-45pm-3cg9, CVSS 9.1) plus two moderate findings.
decompress's latest published version IS the vulnerable one -- no patched
release exists upstream, so npm audit fix cannot resolve this by upgrading.

Removes the shellcheck package entirely and replaces its role with
scripts/lib/shellcheck-fetch.cjs: a small downloader using only Node's
built-in https/zlib plus a hand-written tar-entry reader, fetching a pinned
koalaman/shellcheck release directly from GitHub releases. The reader never
uses an archive-supplied name as a filesystem path (the exact defect class
decompress had) -- it only returns the matched entry's bytes; the caller
writes those bytes to a path it constructs itself. Bounds the download with
a 30s-per-hop timeout, consistent with the ShellCheck subprocess's own
timeout. Covers linux/darwin on x86_64/aarch64, matching this repo's actual
CI (lint-tests runs only on ubuntu-latest) and local dev needs; Windows
fails with a clear, honest error rather than silently misbehaving.

Adds tests/lint-workflow-shellcheck-fetch.test.cjs covering the tar-parser
(unit cases plus a fast-check property test per CLAUDE.md's parser-testing
requirement), a security behavioral pin confirming traversal-style entry
names are treated as opaque strings never filesystem paths, and boundary
coverage for the redirect-following logic's MAX_REDIRECTS limit
(limit-1/limit/limit+1, via an injectable transport, no real network I/O).

npm audit: 0 vulnerabilities (was 1 critical + 5 moderate). The lint script
reproduces the identical result against the current tree:
"212 pre-existing finding(s) from baseline, 0 new" -- no behavior
regression, no baseline changes needed.

* fix(#4120): register shellcheck-fetch.cjs with the installer

scripts/lib/shellcheck-fetch.cjs shipped without being added to
GSD_SCRIPTS_LIB_FILES in bin/install.js, which would have left it orphaned
on uninstall and broken the golden install-tree fixtures for every runtime.
Adds the entry and regenerates the 19 affected fixtures via
npm run gen:install-tree.

* docs(#4120): add changeset for the decompress CVE fix

---------

Co-authored-by: sim <sim@local>
2026-08-31 22:31:23 -04:00
Tom Boucher
e242d85c92 fix(#4109): rewrap unquoted DISPATCH_SLUGS-shaped consumption to survive zsh (#4116)
* test(#4109): add zsh regression coverage for gate-check and invoke_reviewers dispatch

Extends tests/review-plan-coverage-manifest.test.cjs to cover the two
DISPATCH_SLUGS consumption sites the existing #3301 coverage-check tests
don't reach: the write_reviews gate-check block (ALL_LANES_SKIPPED /
TOTAL_LANE_FAILURE counting) and invoke_reviewers' dispatch + join loops.
Both extract the real shipped bash from review.md and run it under bash and
zsh, same convention as the existing coverage-check rows. Expected RED under
zsh pre-fix, GREEN post-fix.

* fix(#4109): rewrap unquoted DISPATCH_SLUGS-shaped consumption to survive zsh

Bash word-splits an unquoted scalar on IFS by default; zsh does not (no
setopt SH_WORD_SPLIT anywhere in these files), so a scalar accumulated from
multiple space-separated tokens and then consumed via bare `for x in $VAR`
collapses onto one bogus iteration under zsh whenever it holds 2+ tokens.

Fixes all four review.md sites reported by #4108/#4109 (invoke_reviewers
dispatch + join loops, the gate-check block, and the #3301 coverage-check
block), plus the identical pattern found by a repo-wide sweep in
complete-milestone.md, code-review.md, pr-branch.md, sync-skills.md, and
execute-phase/steps/per-plan-worktree-gate.md. Each site is rewrapped in
unquoted command substitution (`$(printf '%s' "$VAR")`), which re-splits
identically under both shells regardless of SH_WORD_SPLIT — the same
mechanism that already made the accumulator-building loops in these files
shell-safe.

* test(#4109): warn loudly when the zsh probe fails instead of skipping silently

detectShells() drops the zsh test lane whenever a live zsh probe fails (e.g.
on a CI runner without zsh installed), and a dropped lane reads identically
to a passing one in the suite's own output — exactly the blind spot that let
#4109's bug class ship undetected. Both copies of this helper (this file and
its byte-identical duplicate in review-build-prompt-optional-sections.test.cjs)
now emit a greppable console.warn when the probe fails, so a run without zsh
reads as "zsh coverage unknown" rather than "all lanes green".

* ci(#4109): gate workflows/*.md's embedded bash blocks in lint:ci

Adds scripts/lint-workflow-shellcheck.cjs, wired into npm run lint:ci, so
this bug class can't land undetected a third time. Two independent checks
run over every ```bash block in gsd-core/workflows/**/*.md:

- ShellCheck (new `shellcheck` devDependency, downloads and caches the real
  koalaman/shellcheck binary) catches the general unquoted-expansion/
  word-splitting family in argument position. 212 pre-existing findings
  across the tree are absorbed into scripts/lint-workflow-shellcheck-baseline.json
  (matched on {file, code, message}, not line number, so unrelated edits
  elsewhere in a file can't spuriously un-baseline anything) — fixing all of
  them is out of scope for this issue; only NEW findings fail the build.
- A custom structural check specifically for #4109's own shape: ShellCheck
  does not flag a bare `for x in $VAR` word-list — it treats that as an
  intentional idiom under any ruleset (confirmed empirically). This check
  does, and gates the build on any occurrence, with zero tolerance (no
  baseline) since every known site was already swept and fixed on this
  branch.

* test(#4109): update pr-branch cherry-pick loop test anchor for the zsh fix

extractPickLoop()'s PICK_LOOP_MARKER located the create_pr_branch cherry-pick
loop by the literal substring "for HASH in $INCLUDED_COMMITS", which no
longer appears verbatim after this issue's fix rewrapped that loop in
unquoted command substitution. Updates the anchor (and its error message,
now derived from the same constant instead of duplicating stale text) to the
new literal form. No behavior change to the extraction logic itself.

Emitted-Drift-Ack-Growth: review.md — #4109's fix adds explanatory comments at 4 sites; net code is functionally equivalent, comment expansion grows the file
Emitted-Drift-Ack-Growth: complete-milestone.md — #4109's fix adds explanatory comments at 3 sites documenting the zsh word-splitting divergence
Emitted-Drift-Ack-Growth: code-review.md — #4109's fix adds an explanatory comment documenting the zsh word-splitting divergence
Emitted-Drift-Ack-Growth: pr-branch.md — #4109's fix adds explanatory comments at 3 sites (TRANSIENT_DIRS, INCLUDED_COMMITS, FILTER_PATHS)
Emitted-Drift-Ack-Growth: sync-skills.md — #4109's fix adds explanatory comments at 2 sites (CREATE_LIST/UPDATE_LIST, REMOVE_LIST)

* test(#4109): cover lint-workflow-shellcheck parsers + add timeout

Adds tests/lint-workflow-shellcheck.test.cjs covering the hand-rolled
parser/logic functions exported by scripts/lint-workflow-shellcheck.cjs
(stripCommandSubstitutions, stripShellComments, substitutePlaceholders,
extractForLoops, findBareForLoopSplits, findingKey, partitionAgainstBaseline)
that shipped with zero coverage — CLAUDE.md requires at least one fast-check
property test for parsers, included here (bare/braced forms always flagged
naming the variable; quoted/substituted/literal forms never are).

Also bounds runShellcheck's subprocess with a 60s timeout: the `shellcheck`
npm package's own API has no timeout option and internally blocks on a
synchronous spawnSync, so this reimplements the binary resolve/download step
via the package's own exported config/download and calls spawnSync directly
with a native timeout. And guards the CLI entry point with
`if (require.main === module)`, matching this repo's sibling dual-purpose
lint scripts — without it, requiring the module for its exported functions
(as the new test file does) also triggered a live ShellCheck run as a side
effect.

* docs(#4109): add changeset for the zsh word-splitting fix

* chore(#4109): backfill changeset PR number (#4116)

---------

Co-authored-by: sim <sim@local>
2026-08-31 18:08:06 -04:00
BeeHiggs
1a358ce0fd feat(#2761): bracket-tolerant read path — roadmap/validate/verify/state recognize bracket ids (epic #612 PR-2) (#2867)
* feat(#2761): gated heading-intro selection + one bracket identity grammar

Foundation. Two owner-level changes plus a federated convention resolver; no
reader consumes them yet.

1. GATED SELECTION, not an ungated widening.

   Widening every heading matcher requires the claim "no legacy ROADMAP contains
   a `[CODE.MM]` bracket followed by a digit", and that is false:
   `### [RFC.2119] 5:`, `### [v1.0] 2024:`, `### [ADR.612] 3:` and
   `### [ISO.8601] 2026:` are ordinary headings, and a widened reader claims each
   as a phase — moving phase_count and total_phases and adding W006 on projects
   that never opted in. No narrowing rescues it: the premise is about documents
   we do not control.

   `phaseHeadingPrefixSrcFor(baseline, convention, capturing?)` selects the
   pattern SOURCE at construction time. A project whose resolved
   `phase_id_convention` is not exactly 'bracket' compiles the same source string
   it compiled before. `baseline` is explicit because whether a site spells the
   any-bracket prefix or a bare `Phase\s+` is a fact about that site's history:
   handing the wider grammar to a bare site retro-grants tolerance it never had,
   in both directions — warnings appear, and a warning that fires today vanishes.

   Both bracket forms CAPTURE. `[GSD.999] Phase 07:` previously matched through
   the base alternative, which captures nothing, so a reader saw no bracket, fell
   back to the legacy token rule, and counted a labeled icebox heading while
   excluding the label-less one beside it — two derivations of one ROADMAP
   disagreeing.

2. ONE bracket identity grammar, one width rule.

   The milestone width is reconciled with the emit validator: pad2 output, so
   two digits or 3+ with no leading zero. Earlier spellings diverged in both
   directions — admitting `002`, which the validator rejects, and a bare `0` pad2
   never produces — and the section recognizers accepted `[GSD.2]`, which SCOPED
   a milestone no phase heading could then resolve into, recreating the
   on-disk-count fallback this epic removes. An unpadded bracket is now uniformly
   malformed: it scopes nothing, bounds nothing, sections nothing. W005 on its
   directories is the surfacing signal.

   The milestone field is boundary-anchored, so a malformed run cannot match by
   its prefix (`GSD.002-01` read as sentinel `00`). Recognition stays
   case-insensitive because readers compile `/i`, but identity helpers match
   `[A-Z]`, so a captured id is folded first — otherwise `### [gsd.999] 07:`
   failed every sentinel test. The qualified key shares the width, the `(?=-|$)`
   boundary and the single-sub-phase shape of the directory token, because
   phaseTokenMatches returns unconditionally on a qualified hit: a key matching a
   directory isPhaseDirName rejects would be a final wrong answer.

3. resolvePhaseIdConvention federates workstream -> root exactly as
   config-loader does — including that root is a fallback only when a WORKSTREAM
   is active, so a project-scoped directory stands alone. loadConfig cannot serve
   this: it merges against CONFIG_DEFAULTS and drops keys it does not know, and
   this key is not among them. It governs the bracket-selection reads ONLY.

PHASE_HEADING_PREFIX_SRC is left byte-identical: PR-1 shipped it, nothing
consumes it, and it is superseded rather than redefined.

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

* feat(#2761): roadmap.cts selects its heading grammar from the convention

Six matchers build their intro through the gated selector, and
cmdRoadmapAnalyze / cmdRoadmapGetPhase / getRoadmapPhaseWithFallback each
resolve the convention ONCE per command and thread it down.

Three sites take the any-bracket baseline (they already tolerated
`[anything] Phase N`); three take label-only (they spelled a bare `Phase\s+`).
Handing the wider grammar to a label-only site retro-grants tolerance it never
had — and not only by adding matches: on a legacy repo an unchecked
`- [ ] **[v1.0] Phase 05: Thing**` bullet would start SUPPRESSING the W006 that
fires today.

Sentinel handling under bracket ADDS a rule rather than replacing one: a
bracketed heading is a sentinel when its bracket milestone is reserved
(`### [GSD.999] 01:`) OR when its token is, so the engine-wide 0/999 backlog
convention keeps applying to `### [GSD.02] 999:`. Replacing the token rule let a
mid-migration ROADMAP — bracket headings plus a legacy backlog block, exactly
the content this epic targets — add entries to the progress denominator. The
captured id is folded before the identity test, so a lowercase
`### [gsd.999] 07:` is excluded too.

The DIRECTORY read is threaded too. `cmdRoadmapAnalyze` resolves the convention
once and hands it to all four of its heading/checklist patterns, but the single
`phaseTokenMatches` call that decides `disk_status`, `plan_count`,
`summary_count`, `has_context` and `has_research` was left two-argument — so
every canonical `{CODE}.{MM}-{PP}-slug` directory read as `no_directory` with
zero counts, on the PR's own headline verb, while the SAME build resolved those
same directories correctly in three other places on the same repo (W006/W007 via
phaseTokenFromDir, `state json` via the milestone filter, and the W021
milestone-complete read through this very helper's three-argument form). It
failed ONLY for the directory shape the convention exists to name: a
mid-migration bracket repo carrying legacy `01-one` dirs resolved fine, which is
why nothing caught it. Measured, bracket vs its flat-legacy twin:
`[["01","no_directory",0,0],["02","no_directory",0,0]]` against
`[["01","complete",1,1],["02","planned",1,0]]`.

The oracle is the twin, computed in the same test run, plus exact literals —
`grep disk_status tests/adr-612-*` was zero hits before this, so neither the fix
nor a future regression had any gate at all.

Disclosed: a ROADMAP written in bracket form before config.json is switched
reads as empty rather than mis-counted. Silent invisibility during the migration
window is the deliberate trade against claiming phases on projects that never
opted in.

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

* feat(#2761): validate.cts selects its grammar; gated directory recognition

The W006/W007 feeders take the resolved convention as a threaded parameter.
These sites carry the letter-tolerant `[\w][\w.-]*` capture, which makes them
where an ungated widening does the most damage: `### [RFC.2119] 5:` enters
roadmapPhases as a phantom and becomes a W007 "in ROADMAP.md but no directory on
disk" on a project that never opted in.

buildRoadmapPhaseVariants also surfaces the tokens borne ONLY by sentinel-bracket
headings. Surfaced rather than filtered in place because roadmapPhases feeds both
a membership check and a missing-directory warning, and only the latter should
ignore an icebox item.

That set is OCCURRENCE-AWARE, and the subtlety is load-bearing: roadmapPhases is
a TOKEN set, so `[GSD.999] 01` and `[GSD.02] 01` collapse to one entry. Keying
suppression on the token alone let an icebox heading silence a REAL phase that
happens to share its number — a false negative strictly worse than the warning it
removed. A token is suppressed only when no non-sentinel heading bears it.

Directory recognition is added as gated FUNCTIONS beside the exported RegExp
constants, which stay byte-identical: the `{CODE}.{MM}-` prefix is
string-indistinguishable from the letter-prefixed-decimal family this repo
documents as ambiguous, and folding a branch in changes those constants' answers
on exactly that family. A RegExp constant has nowhere to attach a gate.

The recognizer mirrors the emit grammar and delegates the token to the canonical
owner, so recognizer and resolver agree on rejected input as well as accepted.
Both functions throw on a non-string, matching the call pattern they replace.

buildRoadmapPhaseVariants' CHECKLIST scan is capturing, like its heading twin
and like the sibling checklist scan in roadmap.cts, and for the reason that one
states: the bracket id has to ride along or the sentinel filter is blind to
`- [ ] **[GSD.999] 01: Icebox**`. Left un-capturing, the scan called every
checklist token REAL, and the occurrence-aware un-suppression loop then deleted
the icebox token the HEADING scan had correctly marked sentinel — so `validate
consistency` warned that a bracket ICEBOX phase had no directory, in the HOUSE
ROADMAP shape where an icebox appears as both a bold bullet and a detail
heading. `validate health` stayed silent on that same repo, so the two verbs
disagreed — which is the disagreement `sentinelPhases` exists to close.

Both directions are pinned, because the failure mode of a careless fix here is
the opposite one: a real phase sharing a sentinel's token must still warn. It
does, in all four shapes that attack it (sentinel heading + real bullet,
lowercase sentinel, sentinel after the real heading, colon-less bullet).

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

* fix(#2761): count bracket headings, and retire them, in both derivations

Both `total_phases` derivations select their grammar from the resolved
convention, in one commit — cmdStateSync already carries the comment that it
mirrors buildStateFrontmatter "so both report consistent percents (#3242 Bug B)",
so teaching one and not the other ships that divergence.

The #1514 retirement filter widens WITH the counter it protects. The canonical
gesture strikes the checklist BULLET and leaves the detail heading intact, so a
bracket-form retirement went undetected and the phase stayed in the denominator
forever. That is half a fix alone: the retired key is compared against
phaseKeyFromDir, which called extractPhaseToken with no convention. Both halves
land here.

Under bracket the sentinel token rule composes as the full engine set {0, 999},
so this counter agrees with `roadmap analyze`, which has always excluded both —
otherwise the two derivations report different numbers for one ROADMAP and the
changeset's "excluded from every count" is false as written. The LEGACY path
keeps its pre-existing 999-only rule: widening it there would move legacy totals,
so the two stay split off the bracket path exactly as they are today.

The sync-side assertion reads the PERCENT sync writes into the STATE.md body, not
the frontmatter total_phases. Sync's own counter never reaches that field — the
read derivation writes it — so asserting the frontmatter after a sync measures the
read path twice and lets a mutation to the write-path guard survive.

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

* feat(#2761): verify.cts bracket-coherence W021 + selected milestone-complete read

The shipped milestone-prefixed W021 gate keeps its ROOT-only config read,
verbatim base semantics. Federating it silently moved a legacy convention's
answer in BOTH directions on workstream repos — a W021 that fires at base
vanishing, and one that is silent at base firing. resolvePhaseIdConvention
governs the new bracket-selection reads only.

B6, the milestone-complete check, keeps its ungated POSTURE (bug-557 pins it
with an empty config) but selects its grammar from the convention. Inferring
'bracket' from the shape of a matched bracket ran a repo-failing check against a
legacy ROADMAP that merely contained `### [RFC.2119] 5:`. Directory resolution
widens with the heading read, so a bracket repo whose phases are on disk stays
silent, and a bracket sentinel is not reported as unstarted.

checkBracketCoherence is advisory and gated. Anchored to tokenizeHeadings so
fenced examples cannot warn and heading level is structural. Its scope rules each
close a way it silently did nothing or fired wrongly: only a genuine MILESTONE
heading opens or closes a section (a `### Notes` used to reset scope and disable
both sub-checks); a legacy `## v3.0` DOES close it; an M-NN or letter-suffixed
phase heading raises missing-bracket and CONTINUES; a bare `#### 2026:` is not a
phase; the full h2-h6 range is processed. Its section recognizer shares the one
milestone width, so an unpadded `### [GSD.3] 05:` can no longer be a phase to the
id grammar and a section to the section grammar at once, silently re-scoping
every warning after it.

validate consistency suppresses bracket sentinels in its missing-directory
warning — the two verbs disagreed, health suppressing via notStartedPhases while
consistency did not. The legacy reading is untouched, including its pre-existing
wart that `### Phase 999:` still warns there.

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

* fix(#2761): scope the milestone by its bracket; select the disk-side filter

Two roadmap-parser reads, both of which made a bracket project's totals track
the disk instead of the ROADMAP.

The ADR pins the bracket milestone heading as `## [GSD.02] Foundation` — a name,
no version — but scoping matched STATE's `milestone: v2.0` STRING against a
heading, so the canonical form matched nothing and total_phases fell back to the
directory count. The rule was re-derived in THREE places: extractCurrentMilestone
plus two `milestoneBounded` guards; fixing one left the others falling back
regardless, so they are now one gated helper. It matches the CANONICAL padded
spelling only — accepting `0*N` bounded a milestone whose phases were invisible,
which un-suppressed a progress percent computed off an unscoped disk count.

getMilestonePhaseFilter's heading scan becomes the 14th selected read. On a
bracket ROADMAP it collected nothing, so the filter degraded to pass-all and
buildStateFrontmatter counted every other milestone's directories — making the
bracket convention strictly worse than the M-NN one it supersedes on the property
that matters most: totals must track the ROADMAP, not the disk.

The DIRECTORY side of that same filter is selected with it. Teaching only the
heading scan was half a fix and a worse one: `milestonePhaseNums` became
non-empty, so the pass-all degrade stopped firing, but no bracket directory could
satisfy the three legacy dir checks (numericRe fails on `GSD.02-05-five`, the
custom-id match captures the project code `GSD`, and stripProjectCodePrefix does
not strip a dotted prefix). Every bracket directory was rejected, and
completed_phases / total_plans / completed_plans / percent all collapsed to 0
while `state sync` went on writing a percent off the unfiltered disk — `state
json` reporting 0% on the same repo, in the same second, that STATE.md's body
called 67%. That is the #3242 Bug B divergence this PR exists to avoid, and
total_phases could not show it: `Math.max(phaseDirs.length, roadmapPhaseCount)`
floors it at the ROADMAP count no matter how many directories are rejected.

The dir side matches on the milestone-QUALIFIED id, delegated to the owner's
gated `phaseTokenMatches(dir, id, 'bracket')`, not on the bare token: READING-B
puts the milestone in the bracket, so `GSD.01-01-old-one` and `GSD.02-01-one`
share the token `01` and only the qualified key separates them. The qualified ids
are kept in their own set — a hyphen in `milestonePhaseNums` would flip
`roadmapUsesHyphenedIds` and silently move the LEGACY dir path on a bracket repo
— and the branch is ADDITIVE: on a miss it falls through to the three legacy
checks, so a bracket project carrying legacy-shaped directories reads unchanged.

Both are resolved lazily and gated, so the legacy path pays neither a config read
nor a second scan and cannot change answer. The scoping call is also GUARDED:
resolvePhaseIdConvention reaches planningDir, which throws a plain Error for a
GSD_PROJECT/GSD_WORKSTREAM segment carrying `/`, `\` or `..`. At base the only
planningDir call in extractCurrentMilestone sits inside the STATE-read try, so
the function returned normally on such an environment; an unguarded one here let
that escape and broke the never-throws invariant that getRoadmapPhaseInternal and
getMilestoneInfo three hundred lines below carry #2245 / ADR-227 notes about.
Unreachable through the CLI — GSD_WORKSTREAM is rejected up front by the
workstream-name policy and GSD_PROJECT throws identically at base — but reachable
by any in-process embedder, which is precisely who that invariant is for. The
filter's own resolve call was already inside its try and is unaffected.

The milestone-qualified key is formed only for a token that is itself a bracket
phase token. `${bracketId}-${token}` is a string SPLICE, so a mid-migration
heading carrying an M-NN label — `### [GSD.02] Phase 02-01:` — spliced to
`GSD.02-02-01`, which the qualified-key grammar reads as milestone 02 / phase 02:
the `-01` truncated, both such headings collapsing to one key, and the heading
claiming `GSD.02-02-two`, the directory it does NOT name, while rejecting
`GSD.02-01-one`, the one it does. The guard drops those headings back to the
unqualified legacy path, restoring the base ACCEPTANCE VECTOR exactly — pinned
against the milestone-prefixed reading of the same ROADMAP, which is
base-identical on this shape.

Scoped precisely, because the fixture moves one number that the guard does not
touch: `total_phases` on it reads 1 at base and 2 here. That is the bracket
heading COUNT this PR exists to add, not the splice — measured identical with and
without the guard, and identical to what the canonical `### [GSD.02] 01:`
spelling does on the same fixture (both read 2 with zero directories on disk,
where base reads 0). The claim is base-equivalent ACCEPTANCE, not a
base-equivalent reading.

One consequence is stated rather than fixed: a heading whose token carries a
hyphen still puts that hyphen into milestonePhaseNums and so still flips
`roadmapUsesHyphenedIds`. Base does the same for that spelling, so preserving it
is what keeps the shape base-equivalent; excluding the token would have moved
answers versus base on malformed input. The comment at the qualified-set
declaration is corrected to claim only what is true — it keeps QUALIFIED IDS out
of that flag's input, not hyphens in general.

The oracles ship with it, and they are the five numbers, not the one: the parity
gate now asserts total_phases, completed_phases, total_plans, completed_plans AND
percent, on both derivations, on two fixture shapes (one milestone; two
milestones with stale prior-milestone directories on disk). The oracle is the
flat-legacy twin, built in the same test run and compared number for number,
plus exact literals so a shared wrong answer cannot pass.

The oracle SUBSTITUTION is itself pinned. The M-NN spelling of these shapes could
not serve, because buildStateFrontmatter's #2445 de-dup key captures only a
directory's leading integer and collapses `02-01-one` / `02-02-two` /
`02-03-three` to one — measured [3,0,1,0,0] against the flat-legacy twin's
[3,2,3,2,67], identically at base and before this fix, and structurally
unreachable from the bracket key space. That reasoning is only sound while it
stays true, so a characterization test holds the M-NN reading down on the two
numbers that do not depend on which directory wins the mtime race. Widen the
de-dup key and it fails, instead of quietly invalidating the changeset's
disclosure.

Also adds the call-site pin. The structural table pins transcription against the
selector; it cannot see a call site whose BASELINE ARGUMENT is wrong. Flipping
verify.cts's milestone-complete site to the wider baseline grants a
fires-on-every-repo check tolerance it has never had, and every behavioural test
still passed. The pin reads the shipped sources and asserts the mode at each of
the 14 sites, count-exact.

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

* test(#2761): pin the bracket read surfaces in the parity gate

This gate exists because #2043 fixed one bug across five hand-edited copies of a
rule and #2232 was the residual that survived, because a later reader could not
tell the copies were one rule. PR-2 adds two consumers, so they belong here.

Surface 7 — the heading read and the directory read must agree about WHICH phase
a `MM-<seg>` pair names, across the shared width corpus, and the bracket and
legacy spellings of one heading must yield the same token.

Surface 8 — the two bracket directory readers, in BOTH directions. Agreement on
ACCEPTED input was already pinned; agreement on REJECTED input is where they
actually diverged.

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

* chore(#2761): changeset

Disclosures for the PR body (deliberate, not defects):

- phase_id_convention is not a CONFIG_DEFAULTS key, so loadConfig drops it and
  cannot serve as the convention resolver however the file is federated. This PR
  ships its own workstream->root resolver; adding the key and its value enum is
  later-slice work.
- Convention matching is strictly === 'bracket'. A misspelled value reads as
  not-configured and the project keeps legacy behaviour silently.
- An UNPADDED bracket milestone (`[GSD.2]`) is malformed: it scopes nothing,
  bounds nothing, sections nothing, and is not a phase id. W005 on its
  directories is the surfacing signal.
- WIDTH UNIFICATION MOVED FOUR MERGED PR-1 EXPORT ANSWERS on non-canonical
  inputs, none of which toDir can emit and none of which had a bracket caller at
  base:
    isSentinelPhaseId('GSD.0-01',    'bracket')  true  -> false
    isSentinelPhaseId('GSD.0999-01', 'bracket')  true  -> false
    getMilestoneFromPhaseId('GSD.2-01',   'bracket')  'v2.0' -> null
    getMilestoneFromPhaseId('GSD.002-01', 'bracket')  'v2.0' -> null
  The canonical pad2 sentinel spelling `[GSD.00]` still tests true.
- FLAG TO MAINTAINER: docs/adr/612:132 reads "Sentinel behavior (0.x / 999.x ->
  milestone null) is preserved". After the unification that holds for the
  canonical `00` spelling only, not for a bare `[GSD.0]`. ADR wording is yours;
  flagging the tension rather than editing it.
- The bracket sentinel rule COMPOSES with the legacy one — a bracketed heading is
  a sentinel when its bracket milestone OR its token is reserved. Under bracket
  the state-side token rule is the full {0, 999} set so both derivations agree;
  the LEGACY path keeps its pre-existing 999-only rule, unchanged.
- validate consistency's legacy reading is untouched, including the pre-existing
  wart that `### Phase 999:` warns there while validate health suppresses it.
- find-phase still cannot resolve a bracket phase directory. phase-locator.cts is
  outside this PR's module set. Sibling PR #2559's matchPhaseDirs calls
  phaseTokenMatches without a convention, so whichever slice lands second must
  thread it through.
- Four of the five bracket readers scan raw ROADMAP content, so a bracket heading
  inside a fenced code block is read as a phase. Pre-existing for the legacy
  spelling; parity, not a new class.
- roadmapPhaseLookupSources gained no bracket source: nothing emits a
  milestone-qualified query into it yet.
- roadmap validate remains a separate, unfederated convention reader.
  Pre-existing and base-identical, but two verbs can disagree about the active
  convention on one project.
- _diskScanCache keys on cwd while the values it caches are now
  convention-dependent. Not reproducible through the CLI; pre-existing for the
  workstream dimension, widened here. Stated as inconclusive.
- A ROADMAP written in bracket form before config.json is switched reads as empty
  rather than mis-counted — the deliberate migration-window trade.
- THE READ AND WRITE PERCENTS STILL DIVERGE ON A MULTI-MILESTONE REPO, and that
  divergence is MIRRORED under bracket rather than closed. buildStateFrontmatter
  applies the milestone filter; cmdStateSync does its own fs.readdirSync and never
  calls it, so on a repo carrying prior-milestone directories the read path
  reports the SCOPED percent and the sync body reports the WHOLE-DISK one.
  Measured on the true base build (d04592de), flat-legacy spelling, 3 in-scope
  phases with 1 complete plus 2 stale prior-milestone dirs: `state json`
  [3,1,3,1,33], sync body 60%. The bracket twin of that repo now reads the same
  two numbers — 33 and 60. Scoping the sync counter would move every legacy
  repo's percent, which a bracket read-path PR must not do. The gate pins both
  sides, so the mirror cannot silently become a one-sided fix.
- THE PARITY ORACLE IS THE FLAT-LEGACY TWIN, NOT THE M-NN ONE, and that is a
  measurement finding rather than a preference. buildStateFrontmatter's #2445
  de-dup key captures only a directory's LEADING integer, so the M-NN dirs
  `02-01-one` / `02-02-two` / `02-03-three` all key to `2` and two of the three
  are dropped before they are ever counted: base reads [3,0,1,0,0] where the
  flat-legacy twin of the same repo reads [3,2,3,2,67]. Present identically at
  base and at HEAD, untouched here, and structurally unreachable from the bracket
  key space — `GSD.02-01-one` does not match that pattern at all, so every bracket
  directory keys to its own name. The source line already carries a
  `phase-id-owner:` sanction recording the divergence. Mirroring it under bracket
  would mean manufacturing a collision that cannot occur, so the gate compares
  against the flat-legacy spelling, which is uncontaminated. This paragraph is
  itself pinned: a characterization test holds the M-NN reading on the two
  numbers that do not depend on which directory wins the mtime race, so widening
  the de-dup key in a later slice fails the suite rather than silently making
  this disclosure false.
- THE `phaseTokenMatches` CALL-SITE CENSUS, stated so the remaining gaps are
  auditable rather than implied. 13 call sites outside the owner (phase-id.cts).
  THREE are three-argument: verify.cts:2229 (the W021 milestone-complete read,
  already was), roadmap.cts:436 (`roadmap analyze`'s directory lookup, threaded
  by this PR) and roadmap-parser.cts:792 (the disk-side milestone filter, added
  by this PR). The other TEN are two-argument and stay that way — phase.cts ×5
  (220, 277, 444, 585, 1547), phase-locator.cts:62, smart-entry.cts:243,
  init.cts:1414, milestone.cts:551 and verify.cts:2467. All ten are untouched by
  this PR and base-identical.
  One of them sits in a file this PR DOES edit, so it is named rather than left
  to a reader's grep: verify.cts:2467, `verify schema-drift <phase>`. Measured on
  a bracket repo across base / pre-fix branch / this HEAD, all three agree on all
  three argument forms — `verify schema-drift GSD.02-01` and `… 01` both report
  "Phase directory not found" on every build, and `… GSD.02-01-one` resolves on
  every build through the exact-directory-name fallback. So the user-visible
  shape of what stays broken is: a bracket phase is addressable there by full
  directory name only, exactly as at base. Threading the convention into a
  function this PR never touched, in the last round before ship, is the wrong
  trade; it is where the same one-argument fix goes next, alongside
  milestone.cts:551 and init.cts:1414.
- A BRACKET HEADING WHOSE TOKEN CARRIES A HYPHEN (`### [GSD.02] Phase 02-01:`,
  a mid-migration spelling) forms NO milestone-qualified key, and therefore
  scopes through the unqualified legacy path — base-equivalent ACCEPTANCE, which
  is the claim, and not a base-equivalent reading: `total_phases` on that shape
  moves 1 -> 2 for the same reason it moves on the canonical `### [GSD.02] 01:`
  spelling, because counting bracket headings is what this PR does. Such a token
  still flips `roadmapUsesHyphenedIds`, as it also does at base. The comment at
  the qualified-set declaration now claims only that narrower, true thing.

The `pr:` field carries the sub-issue number as a placeholder — it must be
updated to the real PR number when the PR is opened.

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

* chore(#2761): point the changeset at PR #2867

* test(#2761): fast-check properties for the convention-selection layer

CONTRIBUTING.md mandates a generative property test for parser/bijective-contract
changes; PR-2 shipped six example-based files and none. This adds the missing
layer, scoped to what PR-2 actually contracts — WHICH pattern each reader
compiles, decided by the resolved `phase_id_convention` — rather than restating
PR-1's grammar round-trip properties, which already live in
tests/adr-612-bracket-grammar.test.cjs.

Four properties: P1 an opted-in repo reads the ADR-canonical label-less bracket
heading/dir and a non-opted-in repo is byte-blind to the identical input; P2
every non-bracket convention agrees with the hand-transcribed BASE source over
generated content, including bracket-DOTTED legacy prose (`[RFC.2119] 5:`) that
must never be claimed as a phase; P3 nine per-field mutations are rejected and
the one case variation folds instead; P4 both sides of a phase comparison derive
the same key under the same convention.

Generators template every input from raw primitives — nothing is seeded through
renderPhaseId/toDir, the p2() tautology that made #2258 round 1's property test
structurally unable to find B1. Domain reaches past 99 into the 3+-digit branch
(round 2's numArb-capped-at-99 miss), forces sub-phases in at weight, and pins
both sentinel milestones.

Falsified against the COMPILED lib, not the source: five deliberate mutants
(gate never fires; gate always fires; milestone width widened to \d+; the #612
convention forwarding dropped from phaseKeyFromDir; extractPhaseToken's bracket
branch ungated) each fail the specific property that should catch them —
16/2, 16/2, 15/3, 17/1, 17/1 pass/fail — and the lib restores byte-identical.

An earlier draft of P2 held vacuously: its base regex omitted the markdown
furniture the selected one carried, so every realistic `### Phase NN:` line
matched neither side. The gate-always-fires mutant did not kill it. Both are now
compiled through one function, and that mutant kills P2.

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

* test(#2761): adversarial malformed bracket tokens across the tolerant readers

The existing boundary coverage stopped at shapes the emit grammar rejects
(unpadded `[GSD.3]`, wrong-case, `12A`). It never exercised a STRUCTURALLY
broken token — a non-numeric milestone, a bracket that never closes, a bracket
nested in another — which is the input a tolerant reader is most likely to
half-read, and the one the PR's own regex commentary is explicit about.

read-tolerance (roadmap heading scan + validate's dir and variant builders):
ten malformed headings, each asserted to be read as a phase by NO convention and
to give the opted-in repo the same answer as the legacy one; the corpus driven
through `roadmap analyze` end to end; malformed DIRECTORY names asserted
unrecognized and non-throwing on all four conventions; and the two variant
builders asserted to agree, since a widening that reaches only one splits
`validate consistency` from `validate health` (the #3242 Bug B shape).

coherence (verify.cts W021): the same six broken shapes asserted to raise no
W021 of their own AND not to re-scope the W021 that follows them — the G2
failure mode reached from a different shape, where a heading that is not a phase
but IS read as a section silently moves later warnings onto the wrong milestone.

Both files gain a pathological-input time bound. Nested quantifiers over a long
unclosed bracket are the classic ReDoS shape and two commits on next (#2828,
#2944) were CodeQL-flagged for exactly that, so the bound is asserted rather
than argued from reading the pattern. The probes themselves parse no regex.

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

* docs(#2761): document "bracket" as a phase_id_convention value

The row listed only `"milestone-prefixed"` and `null`, so after two shipped
slices (#2258 grammar, this PR's read path) the convention had no documented
enum value. CONFIGURATION.md is also a top-10 historical co-changer of both
src/verify.cts and src/state.cts and was absent from this PR.

The row states the boundary rather than the ambition: `"bracket"` changes the
READ path only, there is no migrator and no emit yet, and a project on any other
value compiles the patterns it compiled before. That keeps the docs honest for
the two releases before PR-3 and PR-4 land, instead of describing a convention a
user cannot yet migrate to.

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

* chore(#2761): retype the changeset Added, drop the docs-exempt marker

`Fixed` was wrong by CONTRIBUTING.md's own definition — a fix restores
documented behavior, and bracket read tolerance is the second slice of a
capability that did not exist before #2258. The type also carried a
`docs-exempt` marker, and `Fixed`/`Security` are exempt from the docs-required
lint, so the typing had the effect of routing around a gate this change should
pass. It now passes it: `lint-docs-required` returns ok_docs_updated on the
CONFIGURATION.md row added in the previous commit.

Body gains one sentence pointing at that row and restating that `"bracket"` is a
read-path opt-in until the migrator and write path land.

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

* test(#2761): pin the version-less bracket milestone scoping gap; narrow the claim

The changeset asserted that milestone scoping "recognises the ADR-canonical
`## [GSD.02] Foundation` heading and applies to the phase DIRECTORIES too." A
CLI probe on that exact heading form falsifies the second half: with no `vN.N`
in the milestone heading the directory side does not scope, and directories from
BOTH the prior and the later milestone are admitted. Measured 4 dirs counted
where the milestone declares 2.

Every bracket fixture in the suite writes `## [GSD.02] v2.0: …`, so nothing
covered the form the ADR actually specifies — and the state.cts doc comment
calls that version-less form canonical.

Mechanism, in extractCurrentMilestone: the bracket scope branch selects the
right currentSection, but `preambleCutoff` keys off a pattern requiring a
version or status emoji, so a version-less roadmap falls back to the current
milestone's own offset and every PRIOR milestone lands in the preamble — whose
phase-stripping regex only strips `Phase N:`-labelled headings, so bracket phase
headings survive it. Independently, `computeSectionEnd` accepts a boundary only
on a version/emoji heading, so the section runs to EOF and every LATER milestone
is swept in. Two sites, bidirectional.

Not fixed here: it changes milestone scoping, which is shared with the legacy
path. Five characterization tests pin today's reading plus a versioned CONTROL
proving the version string is the only difference, and the changeset sentence is
narrowed to what the code does. The DEFECT assertions are written to be
INVERTED by the fix, not deleted — that inversion is its regression proof.

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

* docs(#2761): re-anchor the branch's own cross-file line citations after the rebase

Three of this branch's code comments cite sibling call sites by line number, and
the rebase onto 178ec000 moved two of the three targets:

  roadmap-parser.cts  validate.cts:210 -> :218   (const g = capturing ? 1 : 0)
                      state.cts:1715   -> :1752  (const bg = … 'bracket' ? 1 : 0)
  roadmap.cts         verify.cts:2229  -> :2355  (phaseTokenMatches 3-arg form)

`state.cts:1715` had drifted 37 lines and now lands on the retirement skip, not
the capture-offset idiom the sentence is about — the citation read as evidence
for a claim the cited line does not support.

planning-workspace.cts's `config-loader.cts:618/:649` was checked and is still
correct; left alone.

Comment-only. Build, drift guard and the bracket suites re-run unchanged.

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

* fix(#2761): scope the version-less bracket milestone heading too (B1)

computeSectionEnd and the preambleCutoff scan in extractCurrentMilestone
(roadmap-parser.cts) only recognized a milestone boundary heading that
carried a vN.N token or a status emoji. The ADR-canonical bracket
heading (## [GSD.02] Foundation) carries neither, so on that shape
computeSectionEnd fell through to content.length (sweeping every LATER
milestone into scope) and preambleCutoff fell back to the current
milestone's own offset (leaking every PRIOR milestone's bracket phases
into the preamble, whose Phase-N: strip regex never matches them).

Under the bracket scope branch, both sites now also accept a
`#{1,2}\s+\[CODE.MM\]` boundary, built from phase-id.cts's BRACKET_ID_SRC
(single owner of the bracket-id grammar) rather than a re-typed literal.
`#{1,2}` is the deliberate discriminator: a bracket PHASE heading is
level 3 and shares the same `[CODE.MM]` prefix, so a `#{1,3}` boundary
would swallow it too. Reachable only when bracketScopeConvention ===
'bracket' was already resolved (i.e. the bracket scope branch actually
fired), so version-bearing/emoji headings and non-bracket conventions
take the exact pre-existing code path byte-identically — confirmed by
the full adr-612 suite staying green.

Inverts the four DEFECT assertions in the
"#612 PR-2 CHARACTERIZATION: a version-less bracket milestone does not
scope" describe block (tests/adr-612-bracket-phase-counting.test.cjs)
into their regression-proof form, per the block's own doc comment, and
reframes the describe title/comments accordingly. Corrects the
.changeset/2761-bracket-read-tolerance.md fragment, which described the
directory-side version-less gap as an open, un-closed bound.

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

* fix(#2761): thread sentinelPhases into validate health's W006 loop (B2)

cmdValidateHealth's W006 loop (src/verify.cts) destructured only
roadmapPhases from buildRoadmapPhaseVariants, not sentinelPhases —
unlike cmdValidateConsistency, which already skips sentinelPhases with
the identical guard a few hundred lines up. A heading-only bracket
icebox/pre-milestone entry ([GSD.999] / [GSD.00]) therefore gained a
false W006 "no directory on disk" from validate health while validate
consistency correctly stayed silent on the very same ROADMAP — the two
validators contradicting each other.

Threads sentinelPhases through and skips it before the existsOnDisk
check, mirroring the consistency guard exactly. Gated the same way
sentinelPhases already is (empty unless phase_id_convention is
'bracket'), so a legacy repo's W006 reading — including its own
pre-existing wart where a legacy `### Phase 999:` still warns on both
verbs — is untouched; confirmed by the existing "INHERITED WART,
unchanged" test staying green.

Adds the paired-agreement regression test (#612 PR-2 B2 describe block
in tests/adr-612-bracket-read-tolerance.test.cjs): a sentinel-only
bracket roadmap must produce no missing-directory warning from EITHER
validator, plus a CONTROL proving a real phase with no directory still
warns on both. Confirmed red (health false-W006) against the pre-fix
code before applying the fix.

Corrects the .changeset/2761-bracket-read-tolerance.md fragment, which
described the asymmetry as already closed and in the wrong direction
(it credited validate health with already staying silent, when health
was the one falsely warning).

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

* test(#2761): pin mixed-shape preamble cutoff and boundary heading levels

Closes two self-flagged coverage gaps in the B1 fix (commit 08d5b0c4)
ahead of adversarial review. No src change — all three new tests are
green against the code as committed.

1. earliest-of-either preambleCutoff comparison: only exercised where
   the version/emoji match and the bracket match happen to land on the
   same heading. Adds the mid-migration mixed shape (version-bearing
   PRIOR + version-less CURRENT) and asserts scoping outcomes (accepts
   booleans + total_phases), not internals.

2. `h.level <= 2` conjunct in computeSectionEnd: provably redundant
   whenever the selected milestone heading is level 2 (every existing
   fixture), since `h.level > level` alone already implies it there —
   a mutant deleting the conjunct would have survived every prior test
   in this file. Adds a level-3 CURRENT-heading fixture (with a real
   PRIOR milestone so the preamble side-channel can't independently
   rescue the truncated phases) that makes the conjunct's deletion
   test-visible, confirmed by hand-mutating a throwaway copy of the
   compiled output (never touching tracked src or the real build) and
   observing the assertion flip. Also pins a level-1 companion case
   (#{1,2} tolerance, not just level 2).

NOT included here: the other mixed-shape direction (version-less PRIOR
+ version-bearing CURRENT) turned out to be a genuine, currently-unfixed
gap — reported separately rather than silently patched or weakened, per
instruction not to touch src while a probe run is in flight.

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

* fix(#2761): engage bracket boundaries when the current milestone heading is version-bearing (B3, self-caught)

Found during round-2 self-verification of B1 (commit 08d5b0c4), while
closing the mixed-heading-shape coverage gaps flagged in my own review
notes. The B1 fix resolved `bracketScopeConvention` only inside the
`if (headingMatches.length === 0)` gate that also drives SELECTION's
own bracket fallback (which heading counts as "current"). That gate is
correct for selection, but `bracketScopeConvention` also feeds
computeSectionEnd's and preambleCutoff's boundary detection further
down — which accidentally inherited selection's gate instead of having
its own.

Trigger shape: the CURRENT milestone heading is itself version-bearing
(`## [GSD.02] v2.0: Current Milestone`), so the primary version-string
match succeeds immediately — headingMatches.length !== 0 from the very
first check — and the entire bracket-resolution branch was skipped. A
sibling milestone (PRIOR or LATER) that is version-less then got
neither the version/emoji boundary rule (it has none) nor the bracket
boundary rule (never resolved), reproducing the original #612 defect
(total_phases falling back to the whole-disk count) through a
structural shape B1's own fixtures never exercised — every one of them
is uniformly version-bearing or uniformly version-less across all
three milestones, never mixed with CURRENT specifically being the
version-bearing one.

Fix: resolve `bracketScopeConvention` unconditionally, decoupled from
`headingMatches.length`. SELECTION is deliberately left untouched — the
`if (headingMatches.length === 0 && bracketScopeConvention === 'bracket')`
fallback that picks which heading is "current" keeps its original gate
byte-for-byte (confirmed by diff: that line is unmodified). Only the
convention *resolution* moved out from behind it, so boundary detection
can consult it regardless of which branch selected the heading. The
extra `resolvePhaseIdConvention` call this now costs on every
invocation (previously paid only when the version match found nothing)
is the accepted cost: a non-bracket repo still resolves to something
other than 'bracket' (or null on a poisoned env, caught exactly as
before), so `bracketMilestoneHeadingRe` stays null and every downstream
branch is byte-identical to today — confirmed by the full adr-612 +
roadmap-parser + state + verify + health-validation suite staying green
(1260/1260) and the all-version-bearing/legacy fixtures showing no
behavior change.

TDD: tests/adr-612-bracket-phase-counting.test.cjs describe block
"#612 PR-2 B3: bracket boundaries engage even when CURRENT is
version-bearing but a sibling is not" — 4 tests, confirmed red against
pre-fix code (leak-in booleans true/true, total_phases 4) before this
change, green after.

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

* fix(#2761): reject same-milestone continuation headings as boundaries (B1)

Gate-2 adversarial review Blocker 1: the B1/B3 boundary fired on ANY
as the one currently selected — a version-less checklist/detail split
(`## [GSD.02] Foundation (Phase Details)`, or an ad-hoc continuation
heading) truncated the current milestone's own section instead of
being recognised as a continuation of it. The `(Phase Details)`
re-append only searches VERSION-STRING matches, so a version-less
continuation heading was cut out and never re-appended — a confidently
wrong, non-degraded phase count for a still-incomplete milestone
(repro8 case 1: 1/1/100 instead of 2/1/50; repro5: same, on a fully
version-less roadmap with no sibling milestones at all).

Introduces one shared helper, isBracketMilestoneBoundary(headingText,
level, selectedBracketId), used by both computeSectionEnd and the
preambleCutoff bracket scan, replacing the ungated `h.level <= 2 &&
bracketMilestoneHeadingRe.test(...)` inline check. `selectedBracketId`
(case-folded via phase-id.cts's foldBracketId, matching the branch's
own fold-before-identity convention) is derived from `selected[0]`,
which is the full matched heading line on BOTH selection paths
(version-string and bracket-fallback), so one extraction covers both.

Level cap stays at `level > 2` for now (temporary — ADR-612's content
discriminator replaces it in the next commit); same-milestone rejection
is the change this commit is scoped to.

DEVIATION from the reviewed plan, caught empirically: applying the
same-milestone rejection at the preambleCutoff site (as literally
specified) regressed an existing pin ("boundary heading level: a
level-1 CURRENT milestone heading also scopes correctly") and a
fenced-heading case (repro10 A3) — because preambleCutoff's job is
"where does the earliest milestone-shaped heading sit, scanning from
the TOP of the document," and the selected heading's own occurrence is
always a correct answer to that question regardless of same-id-ness;
rejecting it let the earliest-of-either comparison fall through to a
stray LATER heading instead. `selectedBracketId` is threaded through as
`null` at the preambleCutoff call site for this reason — bracket-shaped
(and, from the next commit, phase-tail) discrimination still applies
uniformly at both sites; only the same-milestone component is
call-site-specific, since it encodes a "keep scanning past this
heading" instruction with no counterpart in a top-of-document search.

Tests: new describe block "#612 PR-2 B1 round-2: a same-milestone
continuation heading is not a boundary" — RED-turned-GREEN fixtures for
repro8 case 1 and repro5, plus PINs for repro8 case 3 (trailing
different-id icebox still terminates) and repro10 A1 (all-version-
bearing + icebox + Phase Details stays exactly 2/1/50 — no double-count
from the same-milestone exclusion interacting with the pre-existing
detailsMatch re-append). syncedTotal()/syncedPercent() assertions
omitted from the repro10 A1 pin: that fixture carries dirs outside the
current milestone, which exposes the SEPARATE Major 1 defect
(cmdStateSync's body percent from an unfiltered disk scan) — asserted
once Major 1 is fixed, not here.

Full suite green (796/796 across the targeted adr-612 + roadmap-parser
+ state files); node scripts/lint-phase-id-drift.cjs clean; eslint
clean on both changed files.

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

* fix(#2761): bracket boundary discriminates by content, not heading level (B2)

Gate-2 adversarial review Blocker 2: three sites disagreed about which
heading levels are a bracket milestone. The selector
(roadmap-parser.cts's bracket-fallback SELECTION branch,
`^#{1,3}\s+\[CODE.MM\]`) and `isMilestoneBounded` (state.cts) both
admit level 1-3, but isBracketMilestoneBoundary's level cap only
admitted level 1-2 (`h.level <= 2`, from the B1 commit). A `###`-level
bracket milestone heading was therefore SELECTED and BOUNDED but never
TERMINATED: computeSectionEnd ran with level=3, a level-3 SIBLING
milestone survived the pre-existing `h.level > level` (not-deeper)
filter, failed the version/emoji test (version-less), then failed
`h.level <= 2` — falling through to `return content.length` and
sweeping the sibling milestone's own phases into the current one.
Reproduces trek-e's original #612 defect verbatim ("a safe degrade
became a confidently-wrong persisted number") on a heading level the
selector and bounding predicate both already admit (repro2 case C:
4/75% instead of 2/100%; mechanism confirmed directly via repro7 —
extractCurrentMilestone returned the whole 214-byte document).

ADR-612 Decision 1 (docs/adr/612-bracket-phase-id-convention.md:56)
specifies the discriminator as CONTENT, not level: "a phase heading is
a bracket followed by a digit-then-colon ([GSD.02] 05:); a milestone
heading is a bracket followed by a name." Replaces the `level > 2`
rejection with BRACKET_PHASE_TAIL_RE — built by interpolating
phase-id.cts's single-owner phaseHeadingPrefixSrcFor(ANY_BRACKET,
'bracket', false) plus the digit + optional-tag + colon tail every
phase-heading counter in this file already spells, not a re-typed
grammar — and widens the level check to a depth-sanity cap of 3
(mirroring the selector's own `#{1,3}` ceiling; NOT itself a
phase/milestone discriminator). Covers the dotted sub-phase heading
form (`[GSD.02] 05.03:`) via the same `[\w][\w.-]*` token, pinned by a
new fixture — the shape where a regex slip in the tail grammar would
hide.

preambleCutoff's own raw-scan regex is widened from `^(#{1,2})` to
`^(#{1,3})` in lockstep: the outer pattern's level ceiling must track
the helper's cap, or a level-3 PRIOR milestone heading is invisible to
that scan and its own phase heading leaks into the preamble
un-stripped (a real double-count this widening closes, verified
against repro2 case C directly).

The existing "boundary heading level: a level-3 CURRENT milestone
heading still scopes correctly" pin (3e562f12) now passes via a
DIFFERENT mechanism than before — its own neighbours are version-
bearing, so it previously passed via the version/emoji rule (the level
cap was never actually exercised by that fixture, per the round-2
review's own finding); with the content discriminator, the SAME
fixture's level-3 phase headings are now correctly excluded because
they are phase-tail-shaped, not because they are too deep. A
deliberate mechanism change, confirmed by re-running that test green
after this commit.

Also updates the "every selector call site declares the right
baseline" governance pin (adr-612-bracket-heading-selection.test.cjs):
BRACKET_PHASE_TAIL_RE is a new, legitimate ANY_BRACKET call site in
roadmap-parser.cts (always passing the literal 'bracket' convention,
since its only caller is already gated on bracketBoundaryActive) —
EXPECTED count bumped 1->2, with a matching BASE_SITES transcription
entry (identical src to every other ANY_BRACKET site, since the
function is pure).

Tests: new describe block "#612 PR-2 B2 round-2: the bracket boundary
is a CONTENT discriminator, not a level cap" — RED-turned-GREEN for
repro2 case C (exact total AND truthful percent, since
isMilestoneBounded already returns true at #{1,3}) and repro7's
mechanism, a PIN for the dotted sub-phase form, and a re-pin of repro8
case 3 (icebox) under the new mechanism.

Full suite green (907/907 across the targeted adr-612 + roadmap-parser
+ state + phase-id files); node scripts/lint-phase-id-drift.cjs clean;
eslint clean on all changed files.

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

* fix(#2761): fence-aware preamble cutoff on the bracket branch (Blocker 3)

Gate-2 adversarial review Blocker 3: preambleCutoff's bracket scan used
a raw content.match/matchAll — blind to fenced code blocks — while its
sibling computeSectionEnd (a few lines above it) already consumed
tokenizeHeadings(content), which strips fences. The two halves of one
boundary semantic disagreed about what a heading is.

A fenced markdown example in the preamble containing a bracket heading
(ADR-612's own docs do exactly this) was textually the earliest
`#{1,3} [CODE.MM]` match: preambleCutoff landed INSIDE the fence,
`preamble = content.slice(0, preambleCutoff)` ended with an unclosed
opener, and the unbalanced fence then blinded
getMilestonePhaseFilter's own tokenizeHeadings(scope) call — every
heading in the returned scope vanished, phaseCount degraded to 0, and
the pass-all filter admitted every directory on disk (repro11's
mechanism, confirmed directly: fence count 1/odd, tokenizeHeadings(scope)
-> only "Roadmap"). Regression vs round-1, which had no bracket pattern
to blind and so fell back to the correct heading (repro12 bracket row:
2/1/50 at round-1, 4/3/75 at HEAD).

Fixed by hoisting one tokenizeHeadings(content) call
(currentMilestoneHeadings) shared by computeSectionEnd and the
preambleCutoff scan, which now iterates that same fence-aware token
list instead of a raw regex. HeadingToken.text is already hash-stripped
and trimmed, so isBracketMilestoneBoundary needs no `^#{1,3}\s+`
re-derivation at this site (that spelling would not match h.text — a
note the round-2 review called out explicitly, confirmed while
porting). selectedBracketId stays `null` here, unchanged from the B1
commit's same-milestone-exclusion reasoning.

DISCLOSED, not fixed (explicitly out of scope per the round-2 review's
own minimal-fix note): the LEGACY (non-bracket) anyMilestonePattern
raw-match path shares the identical fence-blindness hazard and stays
byte-identical — a bracket repo whose preamble has a fenced
VERSION-BEARING heading still has the legacy raw-match win the
earliest-of-either min() (repro12's LEGACY control: 4/3/75, unchanged
across base/round-1/HEAD/this commit). Pinned here so a future reviewer
files this as a known, pre-existing gap rather than a new regression.

Tests: new describe block "#612 PR-2 Blocker 3 round-2: preambleCutoff
is fence-aware (bracket branch only)" — RED-turned-GREEN for repro12's
bracket row and repro11's mechanism (fence balance + non-degraded
phaseCount + correct per-directory admission), a PIN for repro12's
LEGACY control (the disclosed gap, explicitly unchanged), and a PIN for
repro10 A3 (a fenced heading INSIDE the current section must still not
terminate it).

Full suite green (1072/1072 across the targeted adr-612 + roadmap-
parser + state + phase-id + markdown-sectionizer files); node
scripts/lint-phase-id-drift.cjs clean; eslint clean.

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

* fix(#2761): scope cmdStateSync's disk scan by milestone under bracket (Major 1)

Gate-2 adversarial review Major 1: `state sync` wrote a Progress
PERCENT computed from an UNFILTERED whole-disk scan, beside the
milestone-scoped total_phases/completed_phases it writes into the same
STATE.md via the refreshed frontmatter (syncStateFrontmatter ->
buildStateFrontmatter, which has always applied getMilestonePhaseFilter
for the READ path). cmdStateSync's own `fs.readdirSync` chain (the
WRITE-path scan) never called the milestone filter at all, unlike
buildStateFrontmatter's identical-purpose scan. One command therefore
wrote two contradictory numbers into one file: on the ADR-canonical
version-less bracket fixture (4 dirs, 3 complete; asserted milestone =
2 phases, both complete), base wrote total_phases:2/completed_phases:2
(correct, from the READ derivation) alongside body Progress 75% (wrong
— from the unfiltered WRITE derivation; repro3).

Fixed by threading `getMilestonePhaseFilter(cwd)` through the same
`.filter()` chain buildStateFrontmatter already applies, gated on
`syncConvention === 'bracket'` (falling back to a pass-all predicate
otherwise) — so totalDiskPlans/totalDiskSummaries/diskCompletedPhases/
syncTotalPhases become milestone-scoped under bracket, byte-identical
under legacy.

DEVIATION (approved, stated plainly): an earlier phrasing of this fix
called for mirroring buildStateFrontmatter's filter UNCONDITIONALLY.
Implemented GATED instead — an unconditional filter would ALSO move
every LEGACY repo's persisted percent, since the milestone-scoping-vs-
whole-disk divergence this closes is engine-wide, not bracket-specific.
The gate keeps legacy byte-identical, which is the binding constraint:
this is a bracket read-path PR, not a legacy behavior change.

Nit 2 (informational, no code change): 10 calls to
extractCurrentMilestone on a legacy repo cost 10 config.json
existsSync + 10 readFileSync (0 before B3); accepted, unmemoized cost,
unaffected by this commit.

Also folds in two minors from the round-2 review:
- Corrects .changeset/2761-bracket-read-tolerance.md: the sibling-
  exclusion sentence now states it holds at any heading level 1-3 and
  across a milestone split over two headings (true again now that
  Blockers 1 and 2 are fixed); the percent sentence states plainly that
  `state sync`'s body percent is now milestone-scoped under bracket,
  and unaffected under legacy.
- Records the read/write scoping divergence at currentMilestoneRawRanges
  (src/roadmap-parser.cts) in a comment: it did not receive B1/B2's
  bracket boundary fixes, currently harmless (its only consumer falls
  back to whole-content mutation, and every mutation there is still
  Phase-labelled-only, not bracket-widened), but live the moment the
  write path is bracket-widened — flagged so a future PR closes it in
  lockstep with that work, not after.

Tests: 6 pre-existing tests in tests/adr-612-bracket-phase-counting.test.cjs
needed fixture updates, not logic changes — they used the default
single directory (`GSD.02-01-setup`, phase "01"), which the SENTINEL/
retirement/mixed-heading fixtures in those tests never declare as a
real phase (only 04/05/06/999/etc are declared). Before this fix,
cmdStateSync's unfiltered scan counted that off-roadmap directory
anyway; after this fix the milestone filter correctly excludes it,
which for several of these fixtures made `state sync` a no-op (the
computed 0% coincided with STATE.md's initial template default) and
broke `syncedTotal()`/`syncedPercent()`'s ability to observe anything.
Updated each to pass an EXPLICIT directory naming one of the fixture's
REAL declared phases, preserving each test's original numerator/
denominator intent. One test — "shape 2 WRITE" — was substantively
rewritten: it was a CHARACTERIZATION of the Major 1 bug itself ("the
DISCLOSED legacy gap, mirrored — not closed"), and now correctly pins
bracket closing to 33% (agrees with the read path) while legacy stays
at the disclosed 60% (unchanged, deliberately, per the gating decision
above).

Full suite green: `npm test` 1449/1449 (0 fail, 0 skipped, 0 todo,
single-shard "all" run — includes issue-2765-brace-expansion-lockfile
passing); `npm run lint:ci` clean (0 errors; 2 pre-existing timing-
assertion warnings in files this PR does not touch); node
scripts/lint-phase-id-drift.cjs clean; node scripts/changeset/lint.cjs
ok.

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

* fix(#2761): preambleCutoff identity is offset- and child-aware (round-3 Blocker 1)

Gate-2 round-3 re-verify Blocker 1 (NEW): the round-2 B1 deviation
(39c42a89) threaded `selectedBracketId` as the real value at
computeSectionEnd but as bare `null` at the preambleCutoff scan. The
deviation's rationale — "the selected heading's own occurrence is
always a correct earliest answer" — was right, but `null` disables the
same-milestone check for EVERY candidate, not just the selected one.
Any bracket-shaped heading earlier than the selected milestone was
accepted as a boundary regardless of identity: a same-id checklist/
overview heading preceding the version-bearing selected heading (cases
A, B — the version lands on the LATER half of a split, or a plain
overview heading with no "(Phase Details)" spelling), or a DIFFERENT-id
bracket-shaped PROSE heading with no children of its own sitting above
the current milestone's content (case D — `## [ADR.612] Heading
convention used by this roadmap`). In every case the region between
that false boundary and the real sectionStart was silently dropped —
a completed phase vanished and `state sync` persisted a confident 0%
where base and round-1 both correctly wrote 50%. Regression vs base
AND round-1 (not merely "under-fixed", per the round-3 review's own
severity note).

Fixed with two changes, both scoped to the preambleCutoff scan only
(computeSectionEnd already threads the real `selectedBracketId` and is
untouched):

(a) `h.offset === sectionStart` now bypasses BOTH the same-milestone
    check inside isBracketMilestoneBoundary (passing the REAL
    `selectedBracketId` for every other candidate) and the new child
    rule below — the selected heading's own position is definitionally
    the correct answer, so neither discriminator should run against it
    (rejecting it would mean rejecting the heading against ITSELF).
    Closes cases A and B — verified by the reviewer's own one-liner,
    reproduced here.

(b) New `bracketHeadingHasMatchingChild`: an otherwise-accepted
    candidate (bracket-shaped, not phase-tail-shaped, not the same id
    as the selected milestone) must ALSO have a next-strictly-deeper
    heading carrying its OWN bracket id to count as a boundary. This is
    what a genuine sibling milestone has (its own phase children share
    its bracket id — `## [GSD.01] Setup` / `### [GSD.01] 01: …`) and an
    unrelated bracket-shaped prose heading does not. A candidate with
    no such child at all (childless — e.g. an empty prior milestone, or
    one immediately followed by a same-or-shallower heading) degrades
    to NOT a boundary — over-inclusive, the safe direction: its own
    heading text stays in the preamble, contributing nothing to any
    phase count (not phase-shaped). Closes case D, which (a) alone does
    not — verified: without this rule, `[ADR.612]`'s prose heading is
    indistinguishable from a genuine prior sibling at this site.

As a side effect, also neutralizes Nit 2 (a colon-less `[GSD.02] 05`
heading spuriously terminating the preamble): a colon-less bracket
heading is not phase-tail-shaped so isBracketMilestoneBoundary alone
would accept it, but it is — precisely because it is malformed/
incomplete rather than a real milestone — childless, so the child rule
rejects it too. Pinned.

Known interaction with the fence-blind SELECTION path (disclosed by
the reviewer, not introduced here, tracked for the next commit): when
`sectionPattern` selects a FENCED version-bearing heading (an
extremely pathological shape — a fenced example whose text happens to
match STATE's asserted version), no token exists at `sectionStart`, so
the `h.offset === sectionStart` bypass never fires and the loop falls
through to the ordinary same-id / child-rule checks. This composes
with the round-3 Major 1 fix (next commit) rather than introducing a
new defect — SELECTION itself is untouched by any of this — but is
worth stating plainly rather than rediscovering.

Tests: new describe block "#612 PR-2 Blocker 1 round-3: preambleCutoff
identity is offset- and child-aware" — RED-turned-GREEN for cases A, B
(rv-attack1) and D (rv-attack1b) with syncedTotal()/syncedPercent()
assertions (the persisted 0% is the point), a PIN for a genuine prior
sibling with real children (still excluded), a PIN for a childless
prior sibling (degrades to not-cutting, over-inclusive/safe), a PIN
for the colon-less Nit 2 shape, and the reviewer's rv-mech1 mechanism
re-run as a proper test (scope now equals the full input document,
phaseCount 2, both dirs accepted).

Targeted suite green: `node --test tests/adr-612-*.test.cjs
tests/roadmap-parser.test.cjs tests/state.test.cjs tests/verify.test.cjs`
-> 937/937 pass (930 baseline + 7 new). node
scripts/lint-phase-id-drift.cjs clean; eslint clean on both changed
files.

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

* fix(#2761): fence-aware version/emoji half of preambleCutoff on the bracket branch (round-3 Major 1)

Gate-2 round-3 re-verify Major 1 (NEW): ff6bf0a8 (round-2 Blocker 3)
made the BRACKET half of preambleCutoff's "earliest milestone-shaped
heading" search fence-aware, but left the VERSION/emoji half a raw
`content.match` even on the bracket branch. A fenced VERSION-BEARING
example heading in a bracket repo's preamble (ADR-612's own docs
illustrate the LEGACY heading shape exactly this way, inside a fenced
authoring-guide block) was still textually the earliest match for that
raw regex, winning the min() and un-suppressing a wrong persisted 75%
that base correctly suppressed (rv-attack3c fixture C1: base
suppressed the percent entirely — `isMilestoneBounded` false — HEAD
wrote 75% where truth is 50%).

Fixed by deriving the version/emoji half from the SAME fence-aware
`currentMilestoneHeadings` token list as the bracket half, on the
bracket branch only — the exact `/^Phase\s+\S/i` / `/v\d+\.\d+|✅|📋|🚧/i`
pair `computeSectionEnd` already uses against `h.text`. The non-bracket
(legacy) path is untouched: it keeps the raw `content.match`, byte-
identical to before, including its own fence-blindness (repro12's
LEGACY control, pinned unchanged in the round-2 Blocker-3 test block —
not re-pinned here to avoid duplicating an already-covered assertion).

Not rated Blocker (per the review) because it is not a regression vs
round-1 and the fixture (a version-BEARING fenced example in a bracket
repo) is rarer than the already-fixed bracket-heading case; still
fixed now rather than disclosed, per this arc's own precedent (every
prior "disclose instead of fix" call in this PR has been overturned on
re-review).

Tests: new describe block "#612 PR-2 Major 1 round-3: preambleCutoff's
version/emoji half is fence-aware on the bracket branch" — RED-turned-
GREEN for case C1 (readTotal + syncedPercent, so the persisted 75% is
directly observed, not just the read-path total), PIN for case C2 (the
already-fixed fenced-bracket-heading shape, unchanged).

Targeted suite green: `node --test tests/adr-612-*.test.cjs
tests/roadmap-parser.test.cjs tests/state.test.cjs tests/verify.test.cjs`
-> 939/939 pass (937 + 2 new). node scripts/lint-phase-id-drift.cjs
clean; eslint clean on both changed files.

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

* chore(#2761): correct changeset claims + mark runtime-gated BASE_SITES row (round-3 minors)

Gate-2 round-3 re-verify Minor 2: three changeset sentences in
.changeset/2761-bracket-read-tolerance.md were overstated in a new
direction after round-2:

- The split-milestone claim ("across a milestone split over two
  headings") was true only when the version-bearing heading came
  FIRST (repro8 case 1); false when it came LATER (round-3 Blocker 1
  case A). Now restated to say plainly "with the version-bearing
  heading in EITHER position" — true again now that round-3's Blocker
  1 fix lands earlier in this range.
- The "counted from the phases... rather than from every directory on
  disk" claim was false on cases A/B/D (a strict subset of the
  milestone's own phases). Restated as "ALL of the phases... not a
  subset", and extended to state that an unrelated bracket-shaped
  heading with no phase children of its own (case D's `[ADR.612]`
  shape) does not truncate the milestone either — true now, not before.
- "Each widened read is SELECTED by the project's phase_id_convention"
  was literally false for BRACKET_PHASE_TAIL_RE, which is RUNTIME-gated
  (via its only caller, isBracketMilestoneBoundary, itself only
  consulted when bracketBoundaryActive) rather than selector-gated.
  Restated behaviourally: "every widened read ENGAGES only when the
  project's resolved phase_id_convention is bracket" — true for both
  gating mechanisms, so it no longer implies a selector call this site
  does not make.

Minor 1: the STRUCTURAL IDENTITY test's BASE_SITES row for
BRACKET_PHASE_TAIL_RE (added in the B2 commit) asserts a property of
`phaseHeadingPrefixSrcFor` — the function — not of the call site; it
would pass unchanged even if the site were deleted. Safety at that
specific site rests entirely on a runtime gate the test cannot see.
Added `runtimeGated: true` to the row and threaded it into the
generated test's own title (`… [runtime-gated, not selector-covered]`),
so the gating mechanism is visible in test OUTPUT, not only in a source
comment that could drift silently.

No production code changed. Targeted suite green (48/48 in the
affected file); `node scripts/changeset/lint.cjs` ok; eslint clean.

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

* fix(#2761): harden round-3 preamble cutoff — subtree child scan + level cap

Team-lead review of f87bba0e found two edges in the round-3 preamble-
cutoff code, both hardened here.

AMENDMENT 1 — bracketHeadingHasMatchingChild (2e06aef5) checked only
`headings[index + 1]`, the IMMEDIATE next heading, not the candidate's
whole subtree. A genuine prior sibling milestone whose section opens
with a non-bracket subsection before its first phase heading
(`## [GSD.01] Setup` / `### Notes` / `### [GSD.01] 01: Old`) was
therefore wrongly rejected as a boundary — its real phase heading sits
TWO headings deep, not one — leaking its entire section into the
preamble unstripped.

CONFIRMED RED, not merely theoretical (built and ran the fixture
against f87bba0e before touching the fix, per instruction): scope
membership DOES drive the disk-side filter on this shape.
`GSD.01-01-old`'s directory was wrongly admitted into the CURRENT
milestone's filter via the leaked heading's qualified key
(`GSD.01-01`) — 3/2/67% where truth is 2/1/50%.

Fixed by scanning the candidate's full SUBTREE: continue past a
non-matching deeper heading instead of returning false on the first
one; only a same-or-shallower heading actually closes the subtree and
yields "no match found". A candidate whose entire subtree closes with
no same-id hit (including a genuinely childless one) still degrades to
`false` — over-inclusive, safe, unchanged from before.

AMENDMENT 2 — c483552a ported the version/emoji half of preambleCutoff
to the token-based scan with no level cap; the raw
`content.match(anyMilestonePattern)` it replaced was anchored
`^#{1,3}\s+`. A level-4+ version-bearing heading in the preamble
(`#### v2.0 notes`) therefore won the scan on the bracket branch where
the raw pattern — and the legacy path, unaffected — ignores it
outright. Fixed with `if (h.level > 3) continue;`, mirroring the
depth-sanity cap isBracketMilestoneBoundary already applies to the
bracket half of this same scan.

Tests: new describe block "#612 PR-2 round-3 hardening: subtree child
scan + level cap on preambleCutoff" —
- RED-turned-GREEN for the Notes-intervening fixture: exact 2/1/50 (was
  3/2/67), plus the disk-filter observable (`GSD.01-01-old` now
  correctly excluded).
- PIN for the level-4 preamble heading: the scope now PRESERVES the
  heading's text (was silently dropped before this fix — harmless in
  this minimal fixture's total_phases specifically, since the dropped
  text carries no phase-shaped content, but a real correctness gap
  against the raw pattern's own ceiling) — asserted via scope content,
  not total_phases, since that number is invariant here either way.
- PIN for the LEGACY control on the same level-4 shape — unchanged,
  confirming the raw content.match path is untouched.

Re-verified the existing genuine-prior-sibling and childless-sibling
pins (round-3 Blocker 1 commit) still pass under the subtree scan —
both fixtures' outcomes are unchanged since their same-id hit (or its
absence) was already at the first deeper heading.

Targeted suite green: `node --test tests/adr-612-*.test.cjs
tests/roadmap-parser.test.cjs tests/state.test.cjs tests/verify.test.cjs`
-> 942/942 pass (939 + 3 new). node scripts/lint-phase-id-drift.cjs
clean; eslint clean on both changed files. Full `npm test` + `npm run
lint:ci` deferred to the team lead's own run per instruction.

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

* fix(#2761): bracketHeadingHasMatchingChild requires a same-id PHASE child (round-4 Blocker 1)

Gate-2 round-4 re-verify Blocker 1 (NEW): the round-3 hardening's
subtree scan (fbfd0fca) proved SAME-ID-NESS but never asked whether
the matching child was PHASE-shaped. Case F1 re-opens round-3's case D
one heading later: `## [ADR.612] Heading convention` is followed by
its OWN sub-heading `### [ADR.612] Examples` — same bracket id as the
candidate, but MILESTONE-shaped (a name, no digit-then-colon), not a
phase. Same-id-ness alone satisfied the subtree scan and re-cut the
preamble at exactly the shape the round-3 hardening was written to
close.

Failing input: `## [ADR.612] Heading convention` / `### [ADR.612]
Examples` (prose) / `### [GSD.02] 01: One` (the current milestone's
own first phase, now unreachable) / `## [GSD.02] v2.0: Foundation` /
`### [GSD.02] 02: Two`. Truth 2/1/50. HEAD read 1/0/0, and `state sync`
reported "nothing to do" (exit 0, `{synced:true,changes:[]}`) because
its wrong 0% happened to equal the STATE.md seed — a half-done
milestone read as untouched with no write-path signal at all.

Fixed with the reviewer's one-conjunct addition: a same-id child only
counts if it is ALSO phase-tail-shaped (`BRACKET_PHASE_TAIL_RE`) — the
same single-owner discriminator `isBracketMilestoneBoundary` already
uses one level up for the identical distinction (phase vs milestone),
reused here rather than re-derived. This is exactly what the
changeset's own wording already claimed ("no phase children of its
own") — the code now matches the sentence rather than the other way
around.

Docstring updated at the function itself: the rule is "same-id PHASE
child", not "same-id child".

Tests: new describe block "#612 PR-2 round-4 Blocker 1: the same-id
child must be PHASE-shaped" — RED-turned-GREEN for F1 with
syncedTotal()/syncedPercent() (the persisted 0% — and the
report-nothing-to-do write-path silence — is the point), PINs for F11
(colon-less same-id child) and F11b (bullet-only phase list): both
correctly stay excluded either way, and the leak the phase-shape
requirement newly creates for these two shapes is INERT — a colon-less
heading forms no qualified key (getMilestonePhaseFilter's own
phase-heading pattern requires the colon too) and a bracket bullet
never matches the legacy-only BULLET_PHASE_LINE_PATTERN — confirmed
directly via getMilestonePhaseFilter, not merely inferred. Re-verified
the four existing child-rule pins (F2 subtree-closure, F3 deep-nested
same-id, F4 level-4 same-id, F5 childless-at-EOF) are unaffected by the
phase-shape requirement, since every one of them already used a
colon-bearing same-id child.

Targeted suite green: `node --test tests/adr-612-*.test.cjs
tests/roadmap-parser.test.cjs tests/state.test.cjs tests/verify.test.cjs`
-> 946/946 pass (942 + 4 new). Zero drift measured across the full F1-F12
corpus except F1 itself (F9/F10/F12 remain red, deferred to the
separate Major 1 fix). node scripts/lint-phase-id-drift.cjs clean;
eslint clean on both changed files.

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

* fix(#2761): fence-aware phase counting, milestone bounding, and bracket-fallback selection (round-4 Major 1)

Gate-2 round-4 re-verify Major 1 (NEW): roadmapPhaseCount is a
fence-blind raw `.exec()` over the scope string, duplicated in TWO
independent copies (buildStateFrontmatter's read path, cmdStateSync's
write path). With the bracket alternative now compiled into it (#612),
a fenced EXAMPLE phase heading in the preamble inflates total_phases
and persists a wrong percent that base got right. Two further
fence-blind sites participate: isMilestoneBounded (a raw
`.test(roadmapRaw)`) and the bracket-fallback SELECTOR inside
extractCurrentMilestone (a raw `content.matchAll`, only reachable when
version-string selection finds nothing).

Failing inputs:
- F10 (clean isolate, version-bearing selection): a fenced
  `### [GSD.02] 05: Example phase` in the preamble inflates
  total_phases 2->3, persisting 33% where truth is 50%. LEGACY control
  on the same shape is correct on every build — not a pre-existing
  hazard being inherited, bracket-only.
- F9 (version-less selection): a fenced example carrying the project's
  OWN milestone id additionally confuses the bracket-fallback selector
  (the fenced heading gets SELECTED), compounding with the same
  fence-blind counter. base suppressed the percent; round-1 and HEAD
  both wrote 33%.
- F12 (isMilestoneBounded isolate): the ONLY `[GSD.02]` heading in the
  document is inside a fence, and the asserted milestone genuinely has
  no section at all — HEAD persisted 67% where base correctly
  suppressed the percent (the milestone is absent from the roadmap).

Fixed at the CONSUMER level, not the producer — extractCurrentMilestone's
returned scope string is deliberately UNCHANGED, since every other
consumer of that string needs its full content fidelity and legacy
identity forbids touching the shared string (this branch's own
precedent, ff6bf0a8/c483552a, was producer-level; here the ruling is
consumer-level because the string is shared far more broadly than the
two round-3 fixes' narrower producer edits):

(a) New `countRoadmapPhaseHeadings` (src/state.cts, immediately above
    extractRetiredPhaseNumbers) — ONE shared implementation for both
    call sites, replacing two independently-maintained copies. BRACKET
    convention counts via `tokenizeHeadings(scope)` at levels 2-4,
    testing each heading's hash-stripped text directly — fence-aware by
    construction, since tokenizeHeadings never produces a token for a
    fenced line. LEGACY convention keeps the exact pre-existing raw
    `.exec()` loop, byte-for-byte. A pre-existing, deliberately
    PRESERVED asymmetry between the two original call sites — the read
    path always excluded a bare `/^999\b/` token, the write path never
    did — is threaded through as an explicit
    `includeUnconditional999Check` parameter per call site, so sharing
    the implementation does not silently unify (and thereby move)
    either total.
(b) isMilestoneBounded's bracket branch now scans
    `tokenizeHeadings(roadmapRaw)` for a matching heading (level <= 3)
    instead of a raw regex test. Legacy version-string branch untouched.
(c) The bracket-fallback SELECTOR now builds its candidate set from
    `tokenizeHeadings(content)` instead of `content.matchAll`,
    reconstructing a match-shaped array so every downstream consumer of
    `headingMatches` sees the identical shape the raw-regex path always
    produced. This is the ONE site in this entire arc where SELECTION
    itself changes — selection SEMANTICS are otherwise unchanged (same
    pattern, same first-match-wins by document order); only the
    candidate set is now fence-aware. Pinned that unfenced selection is
    byte-identical.

Zero drift measured across the full historical corpus (repro2-13,
rv-attack1/1b/3c, rv-mech1, rv2-amend1/2, and F1-F11b) except the three
target fixtures.

Tests: new describe block "#612 PR-2 round-4 Major 1: four fence-blind
sites on the bracket path" — RED-turned-GREEN for F10 (with
syncedTotal()/syncedPercent()) and F9 (both layers), PINs for F10's
LEGACY control and F10c (non-phase-shaped fence, unaffected either
way), RED-turned-GREEN for F12 (asserts the percent KEY is absent from
`state json`'s output and that `state sync`'s body stays at its
unmodified seed — the persisted-suppression signal, not merely a
total_phases number), and an explicit PIN that unfenced bracket-fallback
selection (first real milestone-shaped heading wins, no fences
involved) is unaffected.

Targeted suite green: `node --test tests/adr-612-*.test.cjs
tests/roadmap-parser.test.cjs tests/state.test.cjs tests/verify.test.cjs`
-> 952/952 pass (946 + 6 new). node scripts/lint-phase-id-drift.cjs
clean; eslint clean on all three changed files.

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

* chore(#2761): correct docstring overstatement + stale consumer-count sentence (round-4 minors)

Gate-2 round-4 re-verify Minor 1 + Nit 1. No production code changed.

Minor 1: bracketHeadingHasMatchingChild's own docstring said a
rejected (no-same-id-PHASE-child) candidate's degrade "contributes
nothing to any phase count" — true of the candidate's OWN heading
text, but not of its SUBTREE, which is what actually stays in the
preamble. F7 (`## [GSD.01] Setup` / `### [GSD.07] 01: Foreign`) shows
a DIFFERENT-id bracket PHASE heading inside a rejected candidate's
subtree DOES form a qualified key and CAN admit a foreign directory —
3/2/67%, stable across base, round-1 and HEAD (base via its own
pass-all degrade). Not a regression, still the declared over-inclusive
/ never-under-inclusive safe direction — the comment now says that,
with F7's numbers cited, at the call site that actually decides
`isBoundary` (roadmap-parser.cts's preambleCutoff loop) rather than
only at the helper's own definition.

Minor 2 (changeset) — VERIFIED, no wording change needed: re-ran F1,
F9, F10, F12 at this HEAD. The "no phase children of its own... does
not truncate it either" sentence (naming the `[ADR.612]` shape
directly) is now literally true — F1 reads 2/1/50. The "counted from
ALL of the phases... not a subset" sentence is now true on every
measured shape — F1/F9/F10 all read 2/1/50, F12 correctly suppresses
the percent. No carve-out for F9/F10 is needed since round-4 Major 1
(3be5c412) closes both; per the fix-round instruction to "only carve
out anything genuinely left," nothing is.

Nit 1: tests/adr-612-bracket-heading-selection.test.cjs's runtimeGated
row claimed `BRACKET_HEADING_INTRO_RE` has "no other consumers" — true
when round-3's f87bba0e wrote it, stale since 2e06aef5 (round-3's own
earlier commit) had already added two more uses inside
bracketHeadingHasMatchingChild. Corrected to state the true count
(three consumers) and re-confirm the conclusion is unaffected: all
three are still nested inside the same bracketBoundaryActive runtime
gate, and BRACKET_HEADING_INTRO_RE is built from BRACKET_ID_SRC, not
phaseHeadingPrefixSrcFor, so it was never a selector site regardless of
consumer count.

Targeted suite green: `node --test tests/adr-612-*.test.cjs
tests/roadmap-parser.test.cjs tests/state.test.cjs tests/verify.test.cjs`
-> 952/952 pass (comment-only changes, no count movement). eslint
clean; node scripts/changeset/lint.cjs ok.

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

* fix(#2761): restore the bracketId guard in countRoadmapPhaseHeadings (round-5 Blocker 1)

Gate-2 round-5 re-verify Blocker 1 (NEW, introduced by 3be5c412): the
merge that created the shared countRoadmapPhaseHeadings helper dropped
the `bracketId && ` guard both original inline loops carried before
calling isSentinelPhaseId. Every other isSentinelPhaseId call site in
src/ (roadmap-parser.cts, roadmap.cts, validate.cts x2, verify.cts)
keeps the guard; state.cts's shared counter was the only one of seven
without it.

When the phase-heading-intro grammar's LEGACY alternative matches (a
`### Phase 00:` heading in a `phase_id_convention: "bracket"` repo —
the mid-migration shape this PR exists for), the bracket capture group
is `undefined`, so the unguarded call became
`isSentinelPhaseId("undefined-00", 'bracket')` — measured TRUE, so
phase 00 (and 000, 0a, 0.5, 999.1 — any token whose splice with the
literal string "undefined" happens to fall in a sentinel range) was
silently dropped from the denominator. `getMilestonePhaseFilter` (which
still carries its own guard) counts the phase and admits its directory
regardless, so the filter and the counter disagree — a half-done
milestone reads as 100% complete, persisted.

One-line fix, restoring the guard every sibling call site already has:

    if (bracketId && isSentinelPhaseId(`${bracketId}-${token}`, 'bracket')) continue;

Line count: `git diff --stat src/state.cts` -> 1 file changed, 1
insertion(+), 1 deletion(-).

Tests: new describe block "#612 PR-2 round-5 Blocker 1:
countRoadmapPhaseHeadings restores the bracketId guard" — RED-turned-
GREEN for G3 (3/2/67, was 2/2/100) and G3d (the mixed bracket+legacy
mid-migration shape, same numbers) with syncedTotal()/syncedPercent(),
PIN for G3's legacy control (unaffected), PIN for G3b (isolates the
counter with no directory to admit), PIN for G3c (legacy 01/02 only,
no sentinel-shaped token present).

Targeted suite green: `node --test tests/adr-612-*.test.cjs
tests/roadmap-parser.test.cjs tests/state.test.cjs tests/verify.test.cjs`
-> 957/957 pass (952 + 5 new). node scripts/lint-phase-id-drift.cjs
clean; eslint clean.

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

* fix(#2761): extractRetiredPhaseNumbers is fence-aware on the bracket path (round-5 Major 1)

Gate-2 round-5 re-verify Major 1 (NEW) — a FIFTH fence-blind site on
the bracket path, missed by 3be5c412's own enumeration of "four".
extractRetiredPhaseNumbers' line scan (`scope.split(/\r?\n/)`) has no
fence awareness. This PR compiles the bracket alternative into
`introSrc` ("the retirement filter has to widen with the counter it
protects" — the function's own pre-existing comment), so a FENCED
authoring EXAMPLE showing the #1514 retirement gesture in bracket
spelling is now indistinguishable from a real one: it retires a
genuine phase, shrinking the denominator and persisting a confident
100% where base correctly read 50%.

Fixed with the SAME consumer-level ruling this arc has used at every
other fence-blind site, reusing markdown-sectionizer's existing
exported `stripFencedCode` rather than hand-rolling a second fence
parser (single-owner rule) — retirement lines are BULLETS, not
headings, so `tokenizeHeadings` doesn't serve here; `stripFencedCode`
is the general-purpose fence stripper the tokenizer itself is built on.
Gated on `convention === 'bracket'`; the LEGACY line scan stays the raw
`scope` string, byte-identical — its own fenced-example hazard is
pre-existing (wrong at base too) and out of scope.

Line count: `git diff --stat src/state.cts` -> 1 file changed, 9
insertions(+), 2 deletions(-) — one import added, four lines inside
the function (a comment + the `scanScope` computation + the changed
`.split()` call).

Tests: new describe block "#612 PR-2 round-5 Major 1:
extractRetiredPhaseNumbers is fence-aware on the bracket path" —
RED-turned-GREEN for G2 (fenced example in the preamble) and G2b (the
same example placed INSIDE the milestone section, ruling out a
preamble-scoping artifact — the site itself was fence-blind wherever
the fence sits), PIN for the LEGACY control (unchanged, pre-existing,
out of scope — base is wrong on this shape too).

Also folds in the "four fence-blind sites" correction: 3be5c412's
commit message and any restatement of it should read FIVE going
forward; this commit's own message states the count correctly.

Targeted suite green: `node --test tests/adr-612-*.test.cjs
tests/roadmap-parser.test.cjs tests/state.test.cjs tests/verify.test.cjs`
-> 960/960 pass (957 + 3 new). node scripts/lint-phase-id-drift.cjs
clean; eslint clean.

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

* fix(#2761): state sync's counter excludes the bracket 999 icebox token (round-5 Major 2)

Gate-2 round-5 re-verify Major 2 (NEW) — `includeUnconditional999Check`
left `state json` and `state sync` reporting different totals for one
bracket repo. Under bracket, READING-B puts the sentinel in the
bracket, so `isSentinelPhaseId("GSD.02-999", 'bracket')` is false —
the `/^999\b/` TOKEN rule is the only thing excluding a
`### [GSD.02] 999:` icebox heading, and it ran on the read path
(buildStateFrontmatter, `true`) and on getMilestonePhaseFilter
(unconditional), but not on cmdStateSync's own counter (`false`). One
`state sync` call could leave a single STATE.md with its own
frontmatter (percent 50, from the read-path re-sync inside
writeStateMd) and body (percent 33, from the write-path counter that
alone still counted the icebox heading) disagreeing — falsifying this
PR's own stated invariant that sharing countRoadmapPhaseHeadings made
"the two counters must see the same phases" structural.

Functional change is one argument, exactly as specified: the write
call site now passes `syncConvention === 'bracket'` instead of the
literal `false`. `syncConvention === 'bracket'` is `false` for every
non-bracket value, so the LEGACY path resolves to the exact same
`false` it always did — this file's own pre-existing, deliberately-
unchanged read/write divergence on that path is untouched. The READ
site (`:1860`) is NOT touched — its historical behaviour applied
`/^999\b/` to legacy and bracket alike, so changing it would move
legacy READ totals, exactly the class of mistake this arc's own
Blocker 1 (this round) was.

Line count: the functional change is ONE argument
(`false` -> `syncConvention === 'bracket'`); the surrounding comment
was rewritten because the previous one asserted the now-superseded
behaviour ("preserving this file's pre-existing... divergence... a
bare 999 token is not excluded here") and leaving it would mislead the
next reader — not a structural change.

Tests: new describe block "#612 PR-2 round-5 Major 2: state sync
excludes the bracket 999 icebox token like the read path" —
RED-turned-GREEN for G1, asserting `state sync`'s body percent equals
`state json`'s own percent (both 50, not 33 vs 50), PIN for G1's
legacy control (33 vs 50 unchanged, deliberately).

Targeted suite green: `node --test tests/adr-612-*.test.cjs
tests/roadmap-parser.test.cjs tests/state.test.cjs tests/verify.test.cjs`
-> 962/962 pass (960 + 2 new). node scripts/lint-phase-id-drift.cjs
clean; eslint clean.

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

* chore(#2761): restore indent parity at the two line-start-anchored bracket-heading reconstructions (round-5 Minor 1)

`HeadingToken.offset` (tokenizeHeadings) is the LINE-START character offset,
not necessarily the `#` character's own offset — a ≤3-space-indented ATX
heading has both. Two round-4 reconstructions built on tokenizeHeadings
inherited this gap and accepted indented headings their raw, line-start-
anchored predecessors (`^#{1,3}\s+\[...`) never matched:

  1. roadmap-parser.cts's bracket-fallback SELECTOR (extractCurrentMilestone,
     ~line 377) — an indented, version-less `[GSD.02]` milestone heading
     could be reconstructed into headingMatches, then mis-parsed downstream
     (selectedBracketId null, level fallback to 1), leaking a SIBLING
     milestone's phases into the counted scope (G6: HEAD read 3/2/67 instead
     of 2/1/50 — a real phase heading's own directory belonging to the NEXT
     milestone got counted).

  2. state.cts's isMilestoneBounded (~line 1571) — the same gap let an
     indented-only `[GSD.02]`-shaped heading wrongly bound a milestone
     absent from the roadmap, un-suppressing a percent that should stay
     suppressed (mirrors round-4's F12 fenced-only case, but via indentation
     instead of a fence).

Fix: one added conjunct per site — `content[h.offset] === '#'` (source-named
`roadmapRaw` in state.cts) — filtering to tokens whose LINE-START offset IS
the `#` character, i.e. exactly the set the raw line-start-anchored regex
would ever have matched. Restores byte-for-byte raw parity; no other logic
in either function changes. computeSectionEnd and the preamble version/
emoji-token scan are untouched, as instructed — they consumed tokenizeHeadings
output before this arc and are out of scope here.

Also corrects roadmap-parser.cts's now-provably-false docstring claim that
`h.offset` is unconditionally "the same `#`-character coordinate space
`content.match().index` used" — true only for the survivors of the new
filter, not for every token tokenizeHeadings produces.

Line count: the FUNCTIONAL change is exactly 2 lines (one added `&&` conjunct
per call site — `git diff --stat` on the two source files shows 20
insertions/7 deletions, but only those 2 lines change behavior; the rest is
docstring/comment rationale, per this round's "state the line count" ask).

TDD: both fixtures verified RED at HEAD before this commit, GREEN after,
via /tmp/pr612rev/rv5-attack.cjs G6 and a locally-authored isMilestoneBounded-
isolating probe (G6 alone doesn't distinguish the two sites — its unindented
phase headings already satisfy isMilestoneBounded's loose prefix regex
either way, so a second, indentation-only fixture was needed to prove that
site's fix is not a no-op; verified by temporarily reverting just that one
conjunct, confirming 100%-wrongly-bounded RED, then restoring it, confirming
suppressed-percent GREEN).

New tests (adr-612-bracket-phase-counting.test.cjs):
  - RED (G6): indented version-less bracket milestone heading — 2/1/50, not
    the pinned-before-fix 3/2/67.
  - PIN (G6c unindented control): identical document, no indent — 2/1/50
    unaffected on every build.
  - RED (isMilestoneBounded site, indented-ONLY): mirrors round-4's F12
    shape (fenced-ONLY → indented-ONLY) — percent stays suppressed instead
    of the pinned-before-fix wrongly-bounded 100%.

Full G1-G10 (rv5-attack.cjs) + G2b/G3c/G3d/G6c (rv5b.cjs) re-verified
zero-drift against TRUTH after this change. Targeted suite (adr-612-*,
roadmap-parser, state, verify): 962 -> 965 (+3), 0 fail.

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

* chore(#2761): changeset discloses counting-set narrowing + correct stale consumer-count sentence (round-5 minors)

FIX 5 (Minor 2): .changeset/2761-bracket-read-tolerance.md did not disclose
that bracket phase-heading counting is narrower than the raw-regex
predecessor in two ways the review's G4/G5 fixtures surfaced: a level-5
heading (`##### [GSD.02] 05: ...`) is no longer counted (the counter's
tokenizeHeadings scan caps at level 4, matching the selector/isMilestoneBounded
ceiling), and a space-less heading (`###[GSD.02] 05: ...`) is no longer
counted (CommonMark requires ≥1 space/tab after the hashes, which
tokenizeHeadings correctly enforces and the old raw regex did not). Both
G4 and G5 moved from round-1's wrong (inflated) values back to base's
original values as an incidental side effect of routing through
tokenizeHeadings — never a deliberate feature of this PR, and previously
undocumented. One clause added to the existing run-on paragraph; no other
wording in the changeset touched.

FIX 6 (Nit 1): tests/adr-612-bracket-heading-selection.test.cjs:76 —
`BRACKET_PHASE_TAIL_RE` has TWO consumers as of round-4's 65d257ce
(isBracketMilestoneBoundary's own use, plus bracketHeadingHasMatchingChild's
same-id-PHASE-child conjunct), not the "no other consumers" the comment
claimed. Same correction pattern round-4 already applied to this row's
BRACKET_HEADING_INTRO_RE neighbor (that sentence's own staleness was fixed
in 4d7184b8): note the true consumer count, confirm both stay nested inside
the same bracketBoundaryActive runtime gate (verified at
src/roadmap-parser.cts:178 and :250, both reached only through the
`if (bracketBoundaryActive)` block starting at :529), and record which
commit and which fix introduced the drift. Comment-only; no assertion
logic changed.

Line count: 2 files, 8 insertions / 2 deletions total — one added clause
in the changeset (1 line changed) and one comment block replacing the
single stale line in the test file (6 comment lines replacing 1).

Verified: targeted suite (adr-612-*, roadmap-parser, state, verify)
unchanged at 965/965 pass (comment/prose-only diffs, no test count change).
`node scripts/changeset/lint.cjs` and `npx eslint` on the touched files both
clean.

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

* fix(#2761): restore the bracketId guard on the bracket-only /^0\b/ sibling rule (round-6 Blocker 1)

Round-5's Blocker 1 was `isSentinelPhaseId("undefined-00")` — the merge
that introduced the shared counter dropped the `bracketId &&` guard on the
sentinel check. This is the identical failure one line further down, in the
sibling rule this PR itself added: when the phase-heading grammar's LEGACY
alternative matches (`### Phase 0:` in a `phase_id_convention: "bracket"`
repo — the mid-migration shape this PR exists for), `bracketId` is
`undefined`, and the unguarded `/^0\b/` fires on the bare token anyway.
Neither the LEGACY branch of this same function nor
`getMilestonePhaseFilter` has a `/^0\b/` rule at all, so the filter counts
the phase and admits its completed directory while the counter refuses to
count its heading — a milestone with an unstarted phase 02 persists as a
confident 100%.

`/^0\b/` matches `0` and `0.5` (word boundary before the `.`) but not `00`
(no boundary between the two zeros), which is exactly why round-5's G3/G3d
fixtures (`### Phase 00:`) never tripped this one — same defect class,
different token spelling.

Fix: one word, mirroring the guard round 5 restored two lines above —
`if (/^0\b/.test(token)) continue;` -> `if (bracketId && /^0\b/.test(token)) continue;`.

Expected and intentional side effect: `roadmap analyze` and `state json`
now disagree again on this bracket-repo shape (analyze phase_count=2, json
total_phases=3) — exactly as they already do under the legacy convention
today (verified via /tmp/pr612rev/rv6c.cjs on both conventions). That is
the counter regaining agreement with `getMilestonePhaseFilter` (the tighter
constraint — it is what actually decides `completed_phases`), not a new
break; the counter/filter disagreement is what was wrong.

Out of scope, deliberately NOT fixed here (Minor 1, disclosed via a PIN
test only): the bracket-SPELLED `### [GSD.02] 0:` shape has the same
counter/filter disagreement, but reads 2/2/100 on base too — never closed
by any build in this arc, so it is a pre-existing gap rather than a
regression this commit could introduce.

Line count: src/state.cts is exactly 1 insertion / 1 deletion (one word,
`bracketId && ` prepended to the existing condition).

TDD: T0 (`Phase 0:`) and T05 (`Phase 0.5:`) verified RED at HEAD before this
commit (json 2/2/100, sync body 100%) via /tmp/pr612rev/rv6b.cjs, GREEN
after (3/2/67 on both derivations, matching TRUTH). Zero-drift verified by
diffing the FULL corpus (rv5-attack, rv5b, rv4-attack, rv-attack1,
rv-attack1b, rv-attack3c, rv-mech1, rv2-amend1, rv2-amend2, rv6-attack
H1-H19, rv6b) between a pre-fix and post-fix build: the only differing
lines in the entire diff are T0, T05, and H14 — the three target fixtures.

New tests (adr-612-bracket-phase-counting.test.cjs):
  - RED (T0) + PIN (T0L legacy control)
  - RED (T05) + PIN (T05L legacy control)
  - PIN (B0, bracket-spelled `0` — base-parity characterization, Minor 1,
    not fixed this round)
  Round-5's own G3 test (`### Phase 00:`) already serves as the T00 pin —
  `/^0\b/` never matched `00`, so it is unaffected and untouched.

Targeted suite (adr-612-*, roadmap-parser, state, verify): 965 -> 970 (+5),
0 fail. `lint-phase-id-drift.cjs` and `npx eslint` on touched files clean.

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

* chore(#2761): changeset re-attributes the phase-count level cap + discloses the bare-0 carve-out; correct two stale test comments (round-6 Minor 1 + Nits)

FIX 2 (Minor 1 — disclosure only, NOT a code fix): the bracket-SPELLED
`### [GSD.02] 0:` / `0.5:` shape has the same counter/filter disagreement
round-6's Blocker fixed for the legacy spelling, but it reads 2/2/100 on
BASE too — never closed by any build in this arc, so it is a pre-existing
gap rather than a regression this round could introduce. Two changes:

  - Pinned as a base-parity characterization: new PIN test (B0) in
    04b5a95f's describe block asserting the exact unchanged value with a
    comment stating why it's deliberately not touched.
  - .changeset/2761-bracket-read-tolerance.md: one qualifying clause added
    to the "counted from ALL of the phases … not a subset" sentence — a
    bare `0`/`0.x` phase token (however spelled) keeps `roadmap analyze`'s
    own pre-existing sentinel reading under bracket too, so it stays
    excluded from these counts. Carried over, not newly introduced by this
    PR — `roadmap analyze` has always read it this way.

FIX 3(a) — same changeset paragraph misattributed the phase counter's
2-4 level cap to "the milestone-boundary machinery in this PR" (the
selector, `isMilestoneBounded`, the preamble scan) — that machinery caps
at `###` (level ≤3), not 2-4. The 2-4 cap belongs to
`getMilestonePhaseFilter`'s own phase scan. Re-pointed the attribution;
the paragraph's earlier, correct ≤3 claim (selector/isMilestoneBounded)
is untouched.

FIX 3(b) — tests/adr-612-bracket-heading-selection.test.cjs:76-82 (added by
1395bd89, round-5's own Nit-1 correction) claimed both
`BRACKET_PHASE_TAIL_RE` consumers are reached "only through the
`if (bracketBoundaryActive)` block starting at :529". Verified at HEAD:
`bracketHeadingHasMatchingChild` is (only caller :593, inside that block).
`isBracketMilestoneBoundary` is not — it has two callers, an inline
`bracketBoundaryActive &&` conjunct at `:473` (inside `computeSectionEnd`)
and the `:529` block at `:567` — the very fact this row's own earlier
"exactly two callers" paragraph already stated correctly. Corrected the
mechanism claim; the CONCLUSION (every consumer is still gated on the same
flag) is unchanged, exactly as round-5's own Nit-1 fix left round-4's
conclusion unchanged when it corrected the consumer count.

FIX 3(c) — tests/adr-612-bracket-phase-counting.test.cjs:2311,2340 still
said "four fence-blind sites" after round-5 (357ba671) added a fifth
(the retirement scan) in its own block below. Reworded both the section
comment and the `describe()` label to read as historical scoping of
round-4's own fix ("the four sites known at round 4 … a fifth was found at
round 5, see its own block below") rather than a live exhaustive claim.

Line count: 3 files, changeset 1/1, heading-selection test 17/8,
phase-counting test 6/2 (comment/prose-only; B0's own PIN test landed in
04b5a95f alongside T0/T05 since it was investigated as part of that same
Blocker's defect class, not in this commit — noted here for the record).

Verified: targeted suite (adr-612-*, roadmap-parser, state, verify)
unchanged at 970/970 (comment/prose-only diffs, no test count change).
`npx eslint` and `node scripts/changeset/lint.cjs` both clean.

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

* fix(#2761): W021's bracket remediation hint stops pointing at a command that hard-errors (round-7 Minor 2)

`checkBracketCoherence`'s W021 (added by 94abf5df) attached the fix hint
`Run \`gsd-tools roadmap upgrade --convention bracket\` to migrate
(dry-run by default)` to every bracket-convention W021 — both sub-checks
(`missing-bracket` and `mismatch`) share the single `addIssue` call at
src/verify.cts:2255-2256. But `roadmap-command-router.cts:204` throws
unconditionally for any `--convention` value other than
`milestone-prefixed`:

  $ gsd-tools roadmap upgrade --convention bracket
  Error: Only --convention milestone-prefixed is supported

This contradicts the PR's own two disclosures: the changeset ("`bracket`
is a READ-path opt-in until the migrator and write path land") and
docs/CONFIGURATION.md:188 ("There is no bracket migrator and no bracket
emit yet"). Reachability is the exact mid-migration repo this PR targets —
any bracket project with one un-migrated heading gets an unfollowable
instruction on every `validate health`.

Fix: one string. Replaced the hint with what a user can actually do today —
manually align the heading's bracket milestone to its section — and named
the tracked future landing (#612 PR-3) instead of a command that errors.
The milestone-prefixed sibling hint at src/verify.cts:2240 (a different,
already-functional convention/command pair — verified against
roadmap-command-router.cts:65) is untouched.

Line count: src/verify.cts is exactly 1 insertion / 1 deletion (one string
literal).

No test in the suite previously asserted this string's content
(`grep -rn "upgrade --convention bracket" tests/` was empty), so the
unfollowable hint shipped unpinned — `lint-fix-has-regression-test.cjs`
would not have caught a string-only change without a new test. Added one:
asserts the fix string both (a) does not match `--convention bracket`
(the specific pinned-before-this-fix hazard) and (b) equals the new string
exactly, covering the invariant a future edit must not re-break: no
unsupported `--convention` value named in remediation text users are
expected to run verbatim.

Verified end-to-end (not just unit-level) via /tmp/pr612rev/rv7f.cjs: the
W021 issue's `fix` field now reads the new string; `gsd-tools roadmap
upgrade --convention bracket` (and its --dry-run variant) still correctly
hard-error — that command remains unsupported, only the hint text changed.

Targeted suite (adr-612-*, roadmap-parser, state, verify): 970 -> 971 (+1),
0 fail. `lint-phase-id-drift.cjs` clean; `npx eslint` on touched files:
0 errors (1 pre-existing unrelated no-elapsed-assertion warning, same file,
same line this arc's prior rounds already disclosed).

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

* chore(#2761): correct the bare-0 changeset disclosure + disclose W021's second sub-check (round-7 Minor 1 + Nit 1)

FIX 1 (Minor 1) — round-6's bare-0 changeset clause (1395bd89) was wrong in
three falsifiable ways:

  (a) Its own illustration (`### Phase 0:`) is exactly the LEGACY spelling
      04b5a95f made COUNTED. The residual exclusion after that fix applies
      only to the BRACKET-spelled token (`### [GSD.02] 0:`) — the clause
      named the wrong shape as its example.
  (b) "excluded from these counts" over-scoped the carve-out. The phase-0
      directory is admitted into `completed_phases`/`total_plans` in BOTH
      spellings (measured: B0 at HEAD has `completed_phases=2`,
      `accepts {"GSD.02-0-bootstrap":true}`). The exclusion that survives
      lives in `total_phases`/`phase_count` (heading counting) only.
  (c) The pre-existing "so `roadmap analyze` and `state json` report the
      same number" sentence (present since before this arc's bracket work)
      is now false for the bare-0 LEGACY-spelled shape: 04b5a95f's own
      commit message discloses this exact re-divergence as expected —
      `roadmap analyze` phase_count=2 vs `state json` total_phases=3 on T0
      — matching the disagreement legacy already carries today. The
      changeset still asserted unconditional agreement.

Rewrote both sentences: dropped the `### Phase 0:` example, scoped the
carve-out to `total_phases`/`phase_count`, named the bracket-spelled token
as the one that keeps analyze's sentinel reading, stated plainly that the
legacy-spelled form in a bracket repo IS counted (the mid-migration guard),
and qualified the "report the same number" claim to the `999` token, with
the bare-0 legacy-spelled shape named as the one exception and why.

FIX 3 (Nit 1) — `checkBracketCoherence` has always had two sub-checks
(its own docstring: "Two sub-checks, both surfaced as W021") but both the
changeset and docs/CONFIGURATION.md:188 described only the `mismatch`
sub-check. The `missing-bracket` sub-check — which fires on every
legacy-spelled heading in a bracket repo, the noisier of the two on a
mid-migration project — was undisclosed in both places. One clause added
to each.

Line count: 2 files, 1 line changed each (both are single-paragraph/
single-row files; git diff --stat reports 1/1 per file though several
distinct clauses were edited within that one line each).

Verified: `node scripts/changeset/lint.cjs` clean. Targeted suite
(adr-612-*, roadmap-parser, state, verify) unchanged at 971/971
(prose-only diffs, no test count change, no code touched).

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

* chore(#2761): resolvePhaseIdConvention's docstring no longer claims loadConfig drops the key

Upstream #2997 (aa7697fe, landed in `next` during this PR's final
verification) added `phase_id_convention: get('phase_id_convention') ?? null`
to `_baseConfig` in config-loader.cts — `loadConfig` now surfaces
`phase_id_convention` in its resolved config. This function's docstring
gave "loadConfig merges against CONFIG_DEFAULTS and drops keys it does not
know, and `phase_id_convention` is not among them" as the reason for
reading config.json directly instead of calling `loadConfig(cwd)`. That
rationale is now stale against live `next`.

Corrected the comment to state the two reasons that actually survive #2997:

  1. The workstream->root federation this function performs is a standalone
     resolution run against a GIVEN cwd, not necessarily the same base a
     `loadConfig(cwd)` call elsewhere in the codebase would federate from.
  2. Convention-ENUM validation is still #612 PR-4 work — this function
     returns the raw string unvalidated, exactly as the now-surfaced
     resolved key would.

Noted that #2997 surfacing the key makes consuming it from resolved config
(instead of re-reading config.json here) a natural PR-4 consolidation —
not this PR's scope. The "cycles were never the obstacle" close and every
other paragraph in the docstring (federation rationale, SCOPE note) are
untouched; they still hold.

Comment-only — no code behavior changed. This worktree's own history does
not contain aa7697fe (git merge-base --is-ancestor confirms neither branch
is an ancestor of the other; not rebasing per instruction), so this is a
textual correction against a documented external fact, not a functional
sync with upstream.

git diff --stat:
 src/planning-workspace.cts | 17 ++++++++++++-----
 1 file changed, 12 insertions(+), 5 deletions(-)

Verified: `npm run build:lib` clean, `node scripts/lint-phase-id-drift.cjs`
clean, `npm run lint:ci` exit 0 (same 2 pre-existing unrelated eslint
warnings as every prior round this arc, 0 errors; lint-fix-has-regression-test
PASS). Full suite not re-run per instruction (comment-only diff).

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

* fix(#2761): resolve phase_id_convention against the caller's workstream

`resolvePhaseIdConvention` took no workstream, so it resolved from
`planningDir(cwd)` — which falls back to `GSD_WORKSTREAM` only when its `ws`
argument is `undefined`. Every caller that passes a workstream by ARGUMENT
(they cannot set the env var per iteration) therefore read the convention from
the ROOT config while reading that workstream's ROADMAP.

Two reproduced consequences:

- A workstream that explicitly declares its own `phase_id_convention` had it
  ignored. Flipping ONLY the root config between bracket and milestone-prefixed
  changed which milestone that workstream extracted.
- `--workstream foo` and `GSD_WORKSTREAM=foo` disagreed on the same repo: the
  arg form fell through to the root config, the env form did not.

`resolvePhaseIdConvention(cwd, ws?)` now forwards `ws` to `planningDir`, and
both roadmap-parser call sites pass theirs — `extractCurrentMilestoneScoped`
(which already reads STATE from `planningDir(cwd, ws)`) and
`getMilestonePhaseFilter`'s lazy branch. `undefined` keeps the env fallback, so
convention-less call sites are byte-identical; `null` still means "explicitly no
workstream". The `undefined` vs `null` discriminator on `phaseIdConvention` is
untouched — only the base the `undefined` branch resolves FROM moves.

workstream-inventory's two sites (`countRoadmapPhases`, `inspectWorkstream`)
passed a literal `null` where they meant `undefined`, pinning every workstream
to the legacy grammar. On a bracket workstream whose milestone declares 3
phases that returned phaseCount 0 and fell back to the on-disk directory count;
it now returns 3. A non-bracket workstream is unchanged (legacy control pinned).

The workstream -> root federation is preserved: a workstream that declares no
convention still inherits the root, as config-loader does. That inheritance is
pinned so the fix cannot be over-applied into isolation.

* fix(#2761): classify missing phase details per occurrence, not per token

Under READING-B a phase's sentinel status lives in the BRACKET, so
`[GSD.999] 01` and `[GSD.02] 01` share a token and are not the same phase.
Both sides of the missing-detail check were keyed by the bare token anyway, and
both produced false negatives in `missing_phase_details`:

- The checklist scan built a token -> bracket-id Map, FIRST-WINS. Of two entries
  sharing a token, whichever the author wrote first classified both. With
  `- [ ] **[GSD.999] 01: Icebox**` above `- [ ] **[GSD.02] 01: ...**` the real
  phase inherited the icebox's sentinel verdict and vanished from the report;
  swapping the two bullets — same document, same phases — reported it. Pinned
  with a test asserting BOTH orders.

- The detail set was `new Set(phases.map(p => p.number))`, also token-keyed, so
  `[GSD.02] 01`'s heading marked token `01` present and satisfied
  `[GSD.03] 01`, which has no heading anywhere. Order-independent, same class.

Both sides now key on an occurrence key: the owner's `bracketQualifiedKey`
(fold- and padding-insensitive, so `[gsd.2] 01` and `[GSD.02] 01` are one
phase), falling back to a fold-normalized composite for the two shapes that
grammar refuses — a token carrying its own hyphen, which splices to an id whose
trailing segment the qualified-key grammar truncates (the hazard
`getMilestonePhaseFilter` guards with its own `!token.includes('-')`), and any
id it does not accept. With no bracket id the key IS the bare token, so the
legacy path keeps its exact keys and dedupe order.

The emitted value is unchanged — `missing_phase_details` stays an array of bare
tokens, matching `phases[].number`. Only the classification moved to the
qualified key, so two brackets' `01` both missing report `01` once instead of
one silently covering for the other.

* test(#2761): replace the two wall-clock ReDoS assertions with algorithmic bounds

Both guards asserted elapsed wall-clock time — `Date.now()` against a 20s
ceiling in the coherence suite, `process.hrtime.bigint()` against 1s in the
read-tolerance suite. Those measure the host machine rather than the SUT and
flake on a loaded CI runner (RULESET.TESTS.no-timing-assertion). Per
RULESET.TESTS.delete-bad-tests they are REPLACED, not skipped, and the
behavioral property each one guarded is preserved.

The property is "the widened bracket patterns do not backtrack
catastrophically", which is a claim about growth, so it is now stated by
scaling the input instead of by timing it:

- validate health runs the pathological unclosed bracket at 4,000 and 16,000
  characters and must return the same correct result (no W021) at both.
- The four reader entry points run five ReDoS shapes at 5,000 and 20,000 and
  must name exactly the phases each shape should name at each size, with the
  variant cardinality unchanged across the two.

Catastrophic backtracking is superlinear, so a regression cannot complete the
4x leg under any ceiling, while a bounded matcher is indifferent to the
scaling. The one attack that is well-formed-but-oversized now pins its reading
precisely (`['1'.repeat(n)]`) rather than being lumped in with the malformed
ones. A positive control asserts the readers still extract a well-formed
bracket heading, so "names no phase" cannot pass by the readers being inert.

`{ timeout }` is a hang backstop, not an assertion: it turns a runaway into a
deterministic failure instead of a suite that never returns.

* fix(#2761): give the bracket grammar one owner and teach the drift guard to see it

The bracket milestone-intro grammar was re-typed verbatim in three readers —
roadmap-parser's bracket-fallback selector, state's `isMilestoneBounded` and
verify's `checkBracketCoherence` — which is exactly what #2761's own gate
forbids ("no token literal outside src/phase-id.cts"). `check:phase-id-drift`
reported clean the whole time: its detector only ever knew the phase-NUMBER
token grammar, so the bracket class `[A-Z][A-Z0-9_]*` was invisible to it.

Ownership. `src/phase-id.cts` now exports the class as
`BRACKET_PROJECT_CODE_SRC` and the intro in the two shapes its readers need:
`bracketMilestoneIntroSrcFor(milestone)` (pinned to one milestone) and
`BRACKET_MILESTONE_INTRO_CAPTURING_SRC` (milestone captured). The pinned
builder owns the pad2 spelling rule too — "canonical spelling only, not `0*N`"
was previously restated in prose beside each copy, a convention two files had
to keep agreeing on by hand. `BRACKET_ID_SRC`, `BRACKET_ID_PREFIX_RE` and
`BRACKET_QUALIFIED_KEY_RE` now compose the class rather than re-spelling it.
All three call sites consume the owner; the regex sources are byte-identical to
what they spelled, asserted against hand transcriptions of the pre-fix lines.

Guard. `scripts/lint-phase-id-drift.cjs` gains a bracket rule
(`findBracketGrammarDrift`), wired into `scanRepo` and tagged `kind`. It
deliberately does NOT copy the token rule's `line.includes(CANON_REF)` escape:
that escape is line-level, and verify's copy referenced the owner for the
MILESTONE field on the same line as the re-typed PROJECT-CODE class — so a
bracket rule with that escape would have kept passing on the very site under
review. Partial ownership is the drift; only a dedicated `// phase-id-owner:`
comment suppresses it.

Proof, end to end: planting the shipped verify.cts literal back into src/ makes
`npm run check:phase-id-drift` exit 1 naming `[bracket] src/verify.cts:1475`;
restoring it returns the gate to ok.

Tests. phase-id-drift-guard carries all three shipped literals as negative
fixtures, the same-line-owner-reference case, the case-widened evasion variant,
the sanction rules, and a temp-tree scan proving the rule is wired into
scanRepo rather than merely exported. continuation-grammar-parity drives the
pinned and capturing shapes over a 12-entry corpus and requires the same
verdict from both plus the same captured milestone — widening either alone
fails there.

* test(#2761): drop the out-of-scope source-grep exemption from the selector pin

The baseline-selector pin in tests/adr-612-bracket-heading-selection.test.cjs
read `src/*.cts` with readFileSync and regex, claiming the no-source-grep
escape with a source-text-is-the-product reason. CONTEXT.md's documented scope
(RULESET.TESTS.no-source-grep.exemption) reserves that escape for tests whose
subject is a runtime CONTRACT FILE — STATE.md, config.toml, hooks.json, agent
.md — and `src/*.cts` is none of those.

It was also broader than it looked: eslint-rules/no-source-grep.cjs matches the
marker in ANY comment in the file, so one block's claim disarmed the rule for
the whole ~700-line suite.

The pin itself is worth keeping — the BASELINE ARGUMENT at each call site is a
fact no behavioural test can recover (flipping verify's milestone-complete site
from LABEL_ONLY to ANY_BRACKET grants a tolerance it has never had, and every
behavioural test still passes), so pinning it does require reading the authored
source. That reading moved to `scripts/lint-phase-id-drift.cjs` — the seam's
own guard, where source scanning is sanctioned (`warn` scope) and already
happens for the grammar rules — as `countSelectorBaselines` /
`scanSelectorBaselines`, which return a structured census. The test asserts on
the returned data and touches no file text.

CONTEXT.md is unchanged: the exemption scope was not widened to fit the test.
No marker remains in the suite, so the rule is live across all of it again, and
eslint passes with the escape removed rather than relocated. A floor assertion
pins that the census actually found the five consumers, so the "no other file
consumes the selector unpinned" check cannot pass on an empty scan.

* docs(#2761): rewrite the changeset as a lead + deltas instead of one paragraph

The fragment was a single ~6,400-character paragraph that opened on internal
mechanics, buried the user-visible change, and named an internal test path
(tests/adr-612-bracket-phase-counting.test.cjs) that means nothing to a reader
of the CHANGELOG.

It now leads with what a user sees — bracket-style phase IDs are recognized on
the read path across roadmap, validate, verify and state — followed by compact
bullets for the behavioral deltas, the opt-in caveat, and the upstream
consequences. Both merge-added disclosures are kept: the #3185 legacy-sentinel
Phase-0 delta with the reason the bracket counter keeps the narrower rule, and
the enumerator's convention-argument default flip with the four newly-scoped
read surfaces and the note that archival and milestone-completion paths are
unchanged. Two deltas from this review round are stated as their own bullets:
per-occurrence classification in `missing_phase_details`, and workstream-scoped
convention resolution including the workstream-rollup count change.

The internal test path is gone and the body is down to ~3,850 characters. The
bullets render as sub-bullets under the CHANGELOG entry and the `(#NNNN)`
suffix still lands on a trailing paragraph rather than mid-list.
`npm run lint:changeset` passes.

* test(#2761): use helpers.cleanup in the drift-guard temp-tree test

`local/no-raw-rmsync-in-tests` rejects a bare `fs.rmSync` in tests — the shared
helper carries the Windows-EBUSY retry budget. The temp-tree scan added with
M3's guard coverage test used the raw call.

* docs(#2761): correct the occurrenceKey comment on qualified-key normalization

The comment claimed `bracketQualifiedKey` is "fold- and padding-insensitive, so
`[gsd.2] 01` and `[GSD.02] 01` are one phase". The fold half is right; the
padding half is not. `BRACKET_MILESTONE_NUMERIC_SRC` is `(?:[1-9]\d{2,}|\d{2})`,
so `bracketQualifiedKey('GSD.2-01', 'bracket')` returns null and that input
takes the composite fallback instead.

No behavior change — the two key spaces use different separators and cannot
collide — but the claim was checkable and wrong. Restated: the owner case-folds,
and padding-tolerance is not a property it has or needs, because each accepted
milestone has exactly one canonical spelling and `[GSD.2]` is malformed rather
than an alternate spelling of `[GSD.02]`.

* ci(#2761): give the coverage-gate job the heap floor the shard jobs already have

The gate's report steps parse ~2.5GB of merged raw V8 dumps in one
process at the runner's implicit ~4GB ceiling, so pass/fail comes down
to GC timing (run 31338081337 OOM'd; next passes the same volume at
28s). Deterministic locally: crashes at --max-old-space-size=4096,
completes in 11s at 8192. PR shard data is within 0.15% of green next
runs — the load is pre-existing, only the ceiling was missing. #2952
set 6144 on the shard-collection step; the gate job was missed.

* Revert "ci(#2761): give the coverage-gate job the heap floor the shard jobs already have"

This reverts commit 03c74a97c911e9ddc53675aded9c9bbb89f14cd1.

* fix(#2761): thread the resolved convention into cmdStateValidate's phase-directory lookup

#3208 replaced cmdStateValidate's `startsWith` prefix test with the canonical
`phaseKeyFromDir(...) === selectedPhaseKey` comparison. That is the right
surface, and it is why the lookup now needs the resolved `phase_id_convention`,
which the rewrite does not pass.

`phaseKeyFromDir` deliberately refuses to read a bracket directory without an
explicit signal (ADR-2121: a bracket dir is string-indistinguishable from the
legacy letter-prefixed-decimal family), so un-threaded it returns the whole dir
name as the key — `GSD.02-05-real-work` -> `GSD.02-5-REAL-WORK` — while the
STATE side is the bare `05` that `parsePhaseFromProse` yields. Both sides of one
comparison derived under different conventions is #2562's defect class, and this
file's other three `phaseKeyFromDir` call sites already thread against it.

Observable: a bracket repo whose phase directory plainly exists reported
`valid: false` and "no phase directory matches phase 05", and the drift scan
(plan-count mismatch, verification status) never ran. The pre-#3208 `startsWith`
missed the same directory but skipped silently, so this is a visible-failure
regression on bracket repos, not a new miss.

Non-bracket conventions are byte-identical by construction: `extractPhaseToken`
branches only on `=== 'bracket'`, so null / 'milestone-prefixed' / unresolvable
compile the same path as the un-threaded call. The flat-legacy twin assertion
pins that.

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

* docs(#2761): note the cmdStateValidate convention threading in the changeset fragment

The fragment described the bracket read path as of the pre-merge branch. 504c64ff
added a shipped behaviour change — `state validate` now resolves bracket phase
directories — that the fragment did not mention, so the rendered changelog would
have under-described what ships.

Body text only; `type` and `pr` are untouched.

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

* test(#2761): invert the inherited-wart characterization — #3225 fixed it upstream

Required by the merge of next @ 86101ee6; test-only, no src delta.

This branch disclosed rather than fixed a pre-existing upstream wart: on a
LEGACY repo, `### Phase 999:` warned from `validate consistency` while
`validate health` suppressed it — the two verbs contradicting each other.
The case was pinned as a characterization test ("INHERITED WART, unchanged")
precisely so it would INVERT if upstream ever closed it, rather than rot
silently.

ae7dc529 (#3225) closed it, by adding the `isSentinelPhaseId` guard to this
very loop. So the assertion inverted on the merge — as designed. Flipped to
assert the FIXED behaviour rather than deleted: it is the negative-space
proof that this branch's `sentinelPhases` guard never had to grow a legacy
reading of its own, and it reds if a future conflict resolution keeps our
guard while dropping upstream's.

Added a scope control alongside it (a legacy NON-sentinel `### Phase 09:`
with no directory still warns), so deleting the loop outright cannot pass.

Union proven load-bearing in BOTH directions against the merged tree —
neither guard subsumes the other:
  - drop `isSentinelPhaseId(p)` (ours only) -> 1 red, exactly this case.
    Note upstream's own #3225 tests stay GREEN there: they cover the
    disk-side loops (sentinel dir on disk) and the gap-numbering filter,
    not the ROADMAP-side loop. This case is that site's only coverage,
    which is the second reason to keep it rather than delete it.
  - drop `sentinelPhases.has(p)` (upstream only) -> 4 red bracket suites
    (icebox-not-missing, health/consistency agreement, occurrence-aware
    suppression, checklist-index suppression).

tests/adr-612-bracket-read-tolerance.test.cjs 79 -> 80, all green.

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

* test(#2761): pin the three re-homed W006/W007 reads the merge left unfalsifiable

#3309/#3310 deleted the helpers this PR threaded (collectDiskPhases,
collectDiskPhaseEntries, collectArchivedPhaseDirNames,
forEachArchivedPhaseToken) and rebuilt their reads inside
src/planning-snapshot.cts + src/health-diagnostic-rules/*.cts. The convention
threading moved with them in the merge commit — but a mutation sweep over the
re-homed sites found three where reverting the convention argument changed real
CLI output and NOT ONE existing test went red. The pre-migration sites were
covered indirectly, through helpers that no longer exist, so the coverage did
not survive the relocation even though the behaviour did.

Each case is pinned at the CLI with its flat-legacy twin as the byte-identity
control, plus a non-vacuity control in the opposite direction:

  archivedPhaseTokens      revert -> "Phase 05 in ROADMAP.md but no directory on
  (planning-snapshot.cts)            disk" for a phase whose only directory is
                                     under .planning/milestones/v1.0-phases/
  roadmapPhaseCheckboxes   revert -> "Phase 09 in ROADMAP.md but no directory on
  (planning-snapshot.cts)            disk" for an unstarted `- [ ]` phase
  W007's extractPhaseToken revert -> "Phase GSD.02-77-orphan exists on disk"
  (roadmap-disk-consistency)         instead of "Phase 77 exists on disk"

Red/green: each single-line revert reds exactly its own case and nothing else;
every legacy control stays green in all three runs.

NOT pinned, and disclosed as droppable rather than given an unfalsifiable test:
buildValidPhaseSet's extractPhaseToken(dir, convention) in W002. That rule
unions disk tokens with roadmapDeclaredPhases and archivedPhaseTokens, and any
STATE.md reference a bracket disk token would rescue is already rescued by the
ROADMAP half — probed directly, the argument makes no observable difference. It
is threaded because it restores collectDiskPhases(planBase, convention)'s
derivation exactly, not because a test needs it.

Test-only; revertable independently of the merge commit.

* test(#2761): pin the two reads the #3165 extraction left unfalsifiable

DROPPABLE, offered as such. Test-only; the merge commit is correct without it.

#3428's extraction gave `collectAnalyzePhases` TWO call sites — the scoped
milestone window and the truncated-window recovery path. The merge threads
`convention` into both and swaps `detailKeys` with `phases` across the recovery.
Neither of those was falsifiable by the suite as it stood:

  - passing a NULL convention at the FALLBACK site, with the scoped site still
    threaded, is green across all 518 tests of the bracket, roadmap and
    milestone-window files. Every pre-existing bracket assertion reaches the
    enrichment through the scoped window, so none of them can observe the
    fallback's reading at all;
  - dropping `detailKeys = fallbackScan.detailKeys;` — the line this merge
    authored — is likewise green, because no fixture that reaches the recovery
    path carries a checklist bullet, so `missing_phase_details` is `null`
    either way.

That is the same hole class lap 3 closed in 9914359c: an argument whose revert
changes real CLI output with zero reds.

Pinned at the CLI on the shape that reaches the fallback on a bracket repo — a
MID-MIGRATION ROADMAP: bracketed ACTIVE milestone, its phase-detail sections
and checklist bullets sitting after an intervening CLOSED legacy milestone (so
the window closes over prose only), plus one legacy `### Phase N:` section of
its own. Six tests:

  - a NON-VACUITY control that removes the phase directories, so #3428's own
    precondition fails and the result is the empty one the recovery exists to
    replace — this is what proves the rest read the FALLBACK's output rather
    than the scoped scan's;
  - the directory assertion (`disk_status`/`plan_count`/`summary_count` for
    canonical `{CODE}.{MM}-{PP}-slug` dirs) + a flat-legacy twin asserting
    byte-equal shape, and that the twin is the right answer rather than a
    shared wrong one;
  - `missing_phase_details: null` for phases the recovery just found, and its
    companion direction — a bullet with no heading anywhere is STILL reported,
    so a fix that merely suppressed the report does not pass;
  - a non-bracket repo taking the same path unaffected.

Red/green, each mutation reverted afterwards:
  - fallback call site -> `null` convention: exactly 2 reds, both directory
    assertions in this block; 307 other tests green, including upstream's own
    #3428 tests in tests/milestone-window-single-owner.test.cjs.
  - `detailKeys` swap deleted: exactly 2 reds, both `missing_phase_details`
    assertions; 414 other tests green.
  - `matchPhaseDirs` 3rd argument dropped (the lap-2 site): 4 reds — the 2
    already on record plus this block's 2, which is the point: one seam, now
    reached by two paths.

DISCLOSED IN THE TEST BODY WITH MEASURED OUTPUT, not fixed: `hasPhaseEntries`
(src/roadmap-parser.cts) is convention-blind, so on a PURE-bracket ROADMAP the
window classifies COMPLETE and #3428's recovery is gated off entirely. That
document returns `{"scope":"complete","phase_count":0,"next_phase":null,
"phases":[]}` — the "genuinely empty milestone" answer, the indistinguishability
#3184 introduced `scope` to remove — where the flat-legacy twin returns
`{"scope":"truncated","phase_count":2,...}`. Widening it changes the value of an
upstream-owned output field on bracket repos, so it is a behaviour slice (the
PR-2.5/PR-4 convention-less-readers question), not a merge-round change. The
mid-migration shape pinned here is the reachable half.

* fix(#2761): mirror the bracket terminator in the milestone-scope write guard

parser's terminator vocabulary — "a level 1-3 heading that is not a Phase
heading and carries a milestone signal". On this branch that vocabulary is
convention-SELECTED: `computeBracketSectionEnd` adds `isBracketMilestoneBoundary`
as a terminator arm, and the ADR-canonical `## [GSD.09] Hidden` carries NO
vN.N token, NO ✅/📋/🚧/🔄 marker, and not the word "Milestone". Left
unmirrored, the guard accepted exactly the description it exists to reject.

NOT a defect on clean next — a bracket heading terminates nothing there. The
branch widens the terminator set, so the branch owns the mirror.

MEASURED at the CLI seam before the fix (bracket fixture, one milestone,
one phase):

  $ gsd-tools phase add $'Sneaky\n## [GSD.09] Hidden'   -> exit 0, written
  $ gsd-tools phase add 'Innocent follow up'            -> exit 0, written
  $ gsd-tools roadmap milestone-scope
    { "scope": "complete", "phases": ["01","1"], "phase_count": 2 }
  $ grep '^### Phase' .planning/ROADMAP.md
    ### Phase 1: Sneaky
    ### Phase 2: Innocent follow up      <- in the document, out of the window

After the fix the first add exits 1, ROADMAP.md is byte-unchanged and no
phase directory is created. The legacy twin (same text, no
`phase_id_convention`) still exits 0 — opt-in only, base behaviour preserved.

SHAPE
- `findMilestoneScopeHeadingLines(text, convention)` — REQUIRED, the same
  tripwire `scanMilestonePhaseIds` carries in the merge commit and for the
  sharper reason: a blind call here fails OPEN (the guard quietly ACCEPTS a
  window-narrowing description), so a future call site must fail to COMPILE.
  Census is one caller, which pays nothing for it. A non-bracket value takes
  the pre-existing path byte-identically. The bracket arm routes through
  `isBracketMilestoneBoundary`, the same single-owner phase-vs-milestone
  discriminator `computeBracketSectionEnd` consults; no second bracket-heading
  grammar is spelled here.
- `selectedBracketId` is deliberately `null`, so the same-milestone
  CONTINUATION exemption never fires and a value naming the ACTIVE milestone
  is flagged too. That is this predicate's third stated conservatism and
  rests on its own existing argument: which milestone is active is a property
  of the document at write time, not of the text being validated, and
  over-rejecting is one-directional.
- `assertDescriptionPreservesMilestoneScope` takes `cwd` and resolves through
  the branch's tolerant try/catch — this guard runs BEFORE `loadConfig` and
  before the ROADMAP existence check, so an unresolvable convention must
  degrade to the legacy vocabulary, never turn a rejection into a crash.
- The error's marker list gains the bracket form only when the project is on
  the bracket convention.

RED/GREEN
- Revert the bracket arm -> 2 reds (phase add; insert + add-batch), the
  non-bracket control and both no-false-positive cases stay green.
- Revert the probe threading in the merge commit -> 1 red (the probe case).
- 6 new cases in the #3262 file, each with a non-bracket or
  no-false-positive control: fenced bracket milestone heading and bracket
  PHASE heading are both non-violations.

Droppable: revert this commit and the merge stands on its own; the branch
then ships the gap as a disclosure instead of a fix.

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

* docs(changeset): narrow the enumerator claim to the directory set — per-entry rendering in progress/stats/init-manager is display-slice scope

Round-7 review confirmed 3 of the 4 surfaces named by the closing claim
parse each directory or heading with legacy-only patterns that live in
files this PR does not touch (commands.cts:1766, commands.cts:2116,
init.cts:2241). The claim now states exactly what this slice delivers:
the scoped directory set. Their per-entry conversion is display work,
deferred to the epic's display PR with the statusline/progress-card
gates it belongs beside.

* fix(#2761): de-accident the unmatched-milestone bracket fixture, re-pin to #3480's withhold contract

tests/adr-612-bracket-phase-counting.test.cjs:515 ("a milestone that
does NOT match STATE is not scoped in") asserted total_phases === 0.
Since today's merge brought in 70b5c1a1 (#3354/#3480, already in
`next`), buildStateFrontmatter withholds total_phases (omits the key,
read back as null) for a milestone that is genuinely sectioned but
matches no ROADMAP heading, instead of substituting a computed number
— the fixture's `state json` read now returns null, failing the
strictEqual(0) assertion.

The original single-section fixture's 0 only ever survived by
accident: hasMilestoneSectioning requires >=2 milestone-vocabulary
headings to call a ROADMAP sectioned, and its isPhaseHeading helper
recognizes only the legacy `Phase N:` text form — so the bracket
phase heading `### [GSD.03] 09: Not this milestone` (title containing
the word "milestone") miscounted as a second milestone heading,
tipping hasMilestoneSectioning true and routing to the disk-count
branch. With that miscount removed, the same one-section fixture
reads 1 (the non-matching milestone's phase count leaking into v2.0's
total) — proving the pinned 0 was never validating the scoping this
test claims to exercise.

Replaced the fixture with two genuine milestone sections (GSD.03,
GSD.04), neither matching STATE's v2.0, with phase titles carrying no
incidental vocabulary — the real #3354 shape. Re-pinned the assertion
to null, matching upstream's own tested contract (tests/state-
document.test.cjs, "#3354 with nothing stored, the key is omitted
rather than written from the dir count").

Verified: file green 3/3 runs (104/104), full state-suite regression
771/771 green. The isPhaseHeading gap that made the old fixture
accidental is a real, pre-existing, upstream-owned limitation
(hasMilestoneSectioning only guards >=2-section conflation, not a
single non-matching section leaking through) — not introduced by

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

* chore(#2761): exempt two self-authored-fixture regexes from #3441's unbounded-quantifier rule

Both sites parse the STATE.md the test itself just wrote to its own
tmpdir — fixed-size fixture output, not adversarial or document-scale
input. Exempted with the justification-comment pattern the tree's own
tests use for this exact case (settings-integrations, copilot-install,
research-agent-profiles). The sites predate the rule; #3441 landed on
next this morning and this branch picked it up in the catch-up merge.

* fix(#2761): fix forward two next-movement test regressions from the round-11 rebase

origin/next's #3573 newly threads STATE's stored `milestone:` value into
`state json`'s buildStateFrontmatter call (previously always `undefined` on
that read surface). Two pre-existing PR-2 fixtures reach code paths that
call never exercised before that merge:

- RED (repro2 case C): a version-less, all-bracket-id ROADMAP now hits
  getMilestonePhaseFilter's pre-existing (unaffected by this branch) row-5
  `versionResolved && !headingFound => SCOPE.UNSCOPED` rule, which withholds
  `progress.percent` even though the scoped total_phases/completed_phases
  are still correct. That row-5 rule is load-bearing for six other
  version-less-document pins in this same file; narrowing it broke seven of
  them in testing, so production code is untouched here. Reassert the
  test's real claim (scoping, via total_phases/completed_phases) and
  disclose the now-withheld percent instead of silently dropping it.

- PIN (repro12 LEGACY control): a fenced-example-vs-real version heading
  fixture. #3573 routes this read through sliceMilestoneWindow (fence-aware)
  instead of the legacy anyMilestonePattern raw scan (fence-blind) this pin
  was disclosing as out of scope, so the fixture no longer exercises the
  blind path — total_phases moves from the whole-doc 4 to the correctly
  scoped 2. Re-pinned with an upstream-attributed comment, matching this
  file's existing house style for prior origin/next movements (see the
  sibling "PIN (G3 LEGACY control)" comment).

Both are test-only fix-forwards: no production code changed. The remaining
12 failures in this file at 27363e9d0 (dropped seam commits' VERIFICATION.md
naming + fixture updates) are pre-existing and deferred to the stacked
follow-up per the task's own scope.

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

* fix(#2761): thread phaseIdConvention explicitly at the two write-adjacent enumerator call sites (round-11 BLOCKER)

listMilestonePhaseDirs' phaseIdConvention param lost its `= null` default
(phase-locator.cts), so an omitted convention now means "resolve from
config" instead of "explicitly not bracket" — a deliberate flip, but
milestone.cts and state.cts were not in the PR-2 diff and both call the
enumerator without threading it:

- milestone.cts cmdMilestoneComplete (the single #3597 shared derivation
  feeding the stats loop, --dry-run preview, and the real archive/rename
  pass) now resolves phase_id_convention once and threads it explicitly,
  so a bracket project's `milestone complete` archives its real
  bracket-declared phase directories instead of silently inheriting
  whatever the enumerator's lazy default resolves to.
- state.cts cmdStateUpdateProgress's own enumerator call threads the same
  resolved convention. Empirically this does not change the #3217 withhold
  gate (scope is assigned before headingConvention resolves in
  getMilestonePhaseFilter, so it's convention-independent either way) or
  the reported percent (already correctly threaded via
  computeUpdateProgressPreview -> buildStateFrontmatter); it closes a
  second, silently-resolved answer to the same question this file's own
  ONCE-and-THREAD rule (~:2300) already states as policy.
- state.cts's other listMilestonePhaseDirs call site (the state-sync
  scope-only read, ~:4791) is left unthreaded on purpose, with an inline
  note explaining why: only `.scope` is consumed, and `.scope` is set
  before convention resolution in getMilestonePhaseFilter, so it cannot
  disagree with a threaded convention.

Both enumerated sets are pinned by new tests (round-11 BLOCKER block in
tests/adr-612-bracket-phase-counting.test.cjs), including a mutation-style
regression check on the milestone.cts fix (forcing the un-threaded default
back on makes the pinned test fail, catching a regression to the
pass-all-degrade legacy reading).

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

* fix(#2761): correct the changeset's false archival claim and amend ADR-612 for the round-11 M2(2) split scope

The changeset (.changeset/2761-bracket-read-tolerance.md) asserted "The
archival and milestone-completion paths are unchanged" — false: both
paths reach the widened enumerator, and this PR now threads their
convention explicitly (previous commit). Replaced the closing paragraph
with an accurate description of what changes for a bracket project at
those two call sites, and notes both enumerated sets are pinned by tests.

ADR-612 (docs/adr/612-bracket-phase-id-convention.md) amended per M2(2):
the 2026-08-03 PR-2/PR-4 boundary proposal is added in-body (PROPOSED,
not stamped — mechanics per docs/contributor-standards.md:143 reserve ADR
ratification to maintainers), rescoped to what actually ships in PR-2
(#2761) now that the round-11 M1 split moved the completion-seam
threading (isPhaseArtifact/scopeToPhase, phase-id.cts:964-1090) out into
a separate, stacked follow-up PR referenced generically via epic #612:

- state.cts read-tolerance (both total_phases derivations, the #1514
  retirement filter) moves into PR-2's row, alongside milestone.cts,
  reflecting the round-11 fix above.
- the write-observability point is restated in terms of what actually
  ships: explicit threading at two named call sites, pinned by tests,
  rather than silent inheritance.
- the completion-seam threading is named and explicitly excluded from
  PR-2's scope, with a new ratify item (5) and a Negative consequence
  bullet covering the sequencing cost.

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

* fix(#2761): repair round-11 response gaps — disk-side sentinel bug, test-fixture bug, scanMilestonePhaseIds caller drift

Four independently-verified gaps in the round-11 repair response:

1. isSentinelPhaseId (src/phase-id.cts) treated a bare, untagged phase
   directory under phase_id_convention: "bracket" (`0-bootstrap`, no
   `{CODE}.{MM}-` prefix) as sentinel milestone 0 by falling through to the
   legacy leading-int rule when the bracket-tag match failed. This silently
   dropped a real, on-disk, milestone-declared phase directory from
   `listMilestonePhaseDirs` (phase-locator.cts:424, the only unguarded call
   site) and undercounted completed_phases/percent. Mirrors the carve-out
   already present on both heading-side counters (state.cts's
   countRoadmapPhaseHeadings guards its bare-0 exclusion with `bracketId &&`;
   roadmap-parser.cts's scanMilestonePhaseIds composes the bare-token rule as
   999-only) — under bracket convention, milestone 0 is expressed only via an
   explicit bracket tag, so an untagged leading 0 is a real phase token.

2. tests/adr-612-bracket-phase-counting.test.cjs's writeProject fixture
   hardcoded `01-VERIFICATION.md` for every "complete" phase dir regardless
   of the dir's real phase token. Under legacy convention this file already
   correctly failed #3511's strict isPhaseArtifact match for any dir other
   than phase 01 (the fixture never modeled what it claimed to); under
   bracket convention the pre-existing ambiguity fail-safe admitted it
   regardless. The two readings' disagreement was mistaken for a missing
   completion-seam threading (isPhaseArtifact/scopeToPhase convention
   awareness, correctly split out to the stacked #3644 per round-11's M1).
   Replaced the hardcoded name with verificationNameFor(dir), deriving the
   real per-phase filename from the production normalizePhaseName the same
   way cmdScaffold does — the 7 failing "flat-legacy-twin" / CHARACTERIZATION
   assertions pass on #2867's own code with no seam threading required, and
   the genuine seam-dependent cross-phase-stray-exclusion test the fixture
   fix would otherwise have hidden lives on the stacked branch instead.

3. tests/roadmap-parser.test.cjs's two #3577 tests still called
   scanMilestonePhaseIds with the old single-Set return shape; this PR
   changed it to `{ ids, qualifiedIds }` for every other caller but missed
   these two, which don't touch #2761/bracket code at all. TypeErrors at
   runtime, not silently-wrong assertions. Updated both call sites.

4. .changeset/2761-bracket-read-tolerance.md gains a paragraph disclosing
   fix 1 above, so the changeset stays accurate to what actually ships (the
   round-11 BLOCKER was exactly this changeset going stale once).

Verified: tests/adr-612-bracket-phase-counting.test.cjs +
tests/continuation-grammar-parity.test.cjs + tests/roadmap-parser.test.cjs =
354/354. adr-612-{coherence,grammar,heading-selection,read-tolerance,
selection.property}.test.cjs + collision-characterization = 287/287
(CONFIRMED-CLEAN set, unaffected). Full unfiltered suite run separately.

PR #3643 (round-11's M1 split, opened draft per that round's explicit
requirement) was auto-closed by this repo's own draft-PR policy 11 seconds
after opening — draft PRs are unconditionally closed here. Reopened as
non-draft #3644 (same branch, same commits, DO NOT MERGE / stacked-on-#2867
marker in the body, enhancement template) since the repo's own bot confirms
non-draft contributor PRs are tolerated even off-template.

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

* fix(#2761): narrow the state-update-progress pin claim to what mutation testing actually proved, add the test that covers the rest

Round-11 BLOCKER response gap (verified, not a guess): the changeset and
ADR-612 amendment point 2 both claimed the round-11 tests pin
`cmdStateUpdateProgress` so "a future change to the enumerator's default
cannot silently move ... what state update-progress reports without failing
a test." Mutation testing disproves this. Reverting BOTH the state.cts
explicit `phaseIdConvention` thread AND simulating the phase-locator.cts
pre-#612 hardcoded-null default (`phaseIdConvention: null` at that one call
site) leaves the existing PIN test ("writes a real percent for a
bracket-scoped milestone") green, because that percent comes from
`computeUpdateProgressPreview` -> `buildStateFrontmatter`, a separately and
already-correctly-threaded derivation the state.cts inline comment at ~:965
already candidly documents.

What the reverted thread DOES change, empirically, is `phaseDirs`/`totalPlans`
— the enumerated `.value` this call site feeds into the #3233 zero-plans
no-op gate a few lines below. That is the one place a regression at this call
site is observable in the command's output. Added a test that pins exactly
that: a bracket milestone whose declared phases carry no plans on disk,
alongside a decoy directory that plainly does not belong to the milestone
(no bracket tag, no phase token) but does have a plan. Correctly scoped, the
decoy is excluded and the #3233 no-op fires (`updated: false`). Degraded to
the pass-all legacy reading, the decoy is swept in, `totalPlans` flips
nonzero, and the no-op never fires (`updated: true`).

Mutation-tested against both scenarios:
- Reverting ONLY the state.cts explicit thread (falls back to `undefined`,
  which `getMilestonePhaseFilter` resolves via the identical
  `resolvePhaseIdConvention(cwd, undefined)` call the explicit thread also
  makes): new test stays green — confirms the single-hunk thread really is
  pure single-derivation hygiene, exactly as the existing inline comment
  claims, for this test too.
- Forcing `phaseIdConvention: null` at that call site (the combined-revert
  scenario the round-11 mutation testing actually exercised): new test FAILS
  (`updated: true, percent: 0` instead of the expected `updated: false`).
  Restoring the real code makes it pass again.

Narrowed the changeset and ADR-612 point 2 prose to match: `cmdMilestoneComplete`'s
enumerated set is pinned against a pass-all-degrade regression (genuine,
already verified in round 11); `cmdStateUpdateProgress`'s own call site is now
pinned against the #3233 zero-plans no-op specifically, not against the
reported percent, which the prose no longer claims. Added a one-line pointer
to the new test in the state.cts inline comment. No production behavior
changes — test and documentation only.

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

* docs(#2761): reconcile ADR PR-2 module map

* fix(#2761): restore #3639's dir-aware W007 sentinel exclusion lost in the rebase replay

The rebase replayed this file's pre-#3639 patch over next, reverting the
isSentinelPhaseId(token) -> isSentinelPhaseDir(dirName) fix: the extracted
token is milestone-stripped, so a bracket sentinel (GSD.999-07-icebox,
GSD.00-01-backlog) was invisible to the id predicate and false-fired W007.
Restores upstream's call and comment verbatim; the branch's convention-aware
token remains for the diagnostic message only.

Caught by next's own #3639 regression tests (CI shard 2). Local: the file's
18/18, health-diagnostic-rules 165/165, full npm test exit 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MeAsZNfhiUQioFPA4ygEGS

* docs(#2761): ratify scope and correct surface claims

* test(#2761): strengthen legacy selection and drift claims

* docs(#2761): correct the enumerator claim, scope list, and ratification receipt

Three documentation corrections, none touching production code.

The changeset claimed the shared phase-directory enumerator "now defaults its
convention argument to 'not yet resolved' rather than 'resolved, and not
bracket'". That is false: `phase-locator.cts:375` still destructures
`phaseIdConvention = null`, and the lazy resolve-from-config fires only on
`undefined` (`roadmap-parser.cts:941`, `:1928` — whose own comment records that
"explicit null still means 'resolved and non-bracket'"). Measured: only 4 of 17
`listMilestonePhaseDirs` call sites thread a resolved convention (`milestone
complete` and `state`'s three). The changeset went on to name `progress`,
`stats`, `phase list` and the init manager view as now receiving a correctly
scoped set — those are precisely callers that omit it. Replaced with what the
code does, and the deferral stated plainly.

The ADR's PR-2 row omitted `scripts/lint-phase-id-drift.cjs` (+141/-14) and
`scripts/lint-phase-enumeration-drift.cjs`, both changed by this PR. An
under-claim rather than an over-claim, but the row is the epic's
scope-of-record.

Ratify item 5 asserted maintainer ratification while citing only the review that
raised the question. It now cites the review that granted it (#2867 review
`5012940978`, 2026-08-24) and quotes its terms, so the claim carries its receipt.

Found by an adversarial review pass over the round-12 diff. (#2761)

* fix(#2761): drop the no-op --json that strict argv now rejects

Surfaced by the rebase onto next, not by a change in this PR's subject.

#3884 ("failure is a value — strict argv", e20744eac) made an unrecognized
flag a hard error: `state validate --json` now exits 1 with
`unknown flag "--json"; accepted: --strict` on stderr and EMPTY stdout,
where the token was previously accepted and ignored. `state validate` never
had a `--json` flag — JSON is its only output shape — so the argument was a
silent no-op from the start.

The helper parsed that empty stdout, so all five subtests in the
"state validate resolves bracket phase DIRECTORIES" suite failed identically
with `SyntaxError: Unexpected end of JSON input` at the JSON.parse, masking
what they actually assert.

Dropping the token restores the same envelope the helper already parsed. No
assertion changes. Verified against the fixture the suite builds: exit 0, and
the output carries the S005 plan-count warning the drift assertions require
with no S004 phase-directory warning — i.e. the bracket directory resolves,
which is the behaviour these tests exist to pin. 94/94 in the file.

* fix(#2761): exempt the phase-counting suite from the docs-guard lane

Surfaced by the rebase onto next. #3753 (107eb8c1d) added
lint-docs-guard-registration, which requires every test file that reads a
docs/ path to be either registered in the docs-guard lane or carry an
explicit marker. It flags adr-612-bracket-phase-counting.test.cjs, which
reads no docs/ path at all.

The file's only docs/ occurrence is the ADR-612 Decision 1 citation in a line
comment; every read call it makes targets a tmpdir .planning fixture. It trips
Detector 3, whose DOCS_TEMPLATE_LITERAL_RE sees an odd prose backtick in the
comment block above that citation as opening a template literal and reads the
span between them — citation included — as a docs/ path expression. That
detector documents this trade in its own header: it favours recall, and says a
false positive costs one docs-guard-exempt marker with a reason. The baseline
records the same class ("comment-only mentions") for 46 of its entries.

Registration was the wrong side of the trade here: it would run this suite in
the docs-guard lane on docs/ changes whose content it never reads.

Three pieces, matching what the gate requires and what its 54 existing entries
already do:
  - the marker in the file's header window, written without backticks so it
    cannot itself disturb the parity tracking findExemption does;
  - the basename in DOCS_GUARD_EXEMPT_BASELINE, since the ratchet fails a new
    marker until the baseline is deliberately updated — the reviewable diff is
    the point;
  - the FIX 3 per-file fingerprint, derived with the lint's own
    extractDocsPathReferences rather than retyped, so the exemption fails loudly
    if the set of docs/ paths this file mentions ever changes.

Gates: lint-docs-guard-registration 0 violations, ci-docs-guard-registry 51/51.

* docs(#2761): correct the bare-0 comment and state what opting into bracket costs

Round-13 review items Minor 2 and Minor 3, both still live on the previous head.

Minor 2 — src/phase-id.cts. The comment block claimed "Bare `0` is admitted
alongside it because a 0.x sentinel is a legitimate identity that predates
padding", which contradicted both the shipped constant and its own next
paragraph. BRACKET_CANONICAL_NUMERIC_SOURCE is `(?:[1-9]\d{2,}|\d{2})`;
measured against it, `0` is rejected while `00`, `05`, `99`, `100` and `999`
are admitted. The paragraph four lines below already recorded the removal
("the earlier `(?:\d{2,}|0)` ... admitted ... a bare `0` that pad2 never
produces"), so the block asserted and denied the same fact. The stale sentence
is replaced with what ships: `00` is the backlog sentinel's canonical padded
identity, `\d{2}` already covers it, and nothing needs the unpadded spelling.
No behaviour change — the constant is untouched.

Minor 3 — docs/CONFIGURATION.md. The phase_id_convention row described the
read-path widening but never said what a repo GIVES UP by opting in. Added:
on a bracket repo a heading whose bracket is followed directly by a digit is
read as a phase heading, so `### [RFC.2119] 5:`, `### [v1.0] 2024:` and
`### [ADR.612] 3:` — legal prose headings under any other convention — are
claimed as phases and move phase_count, total_phases and W006. Those three
shapes are the ones phase-id.cts's own selector comment names as the reason
the widened read is selected at construction time from this value rather than
applied everywhere; the row now carries that trade instead of only its
upside.

Gates: tsc --noEmit exit 0, npm run lint:ci exit 0, npm run test:unit
33890/33891 with the single failure being emitted-attribution's base drift
against a next that moved after the rebase (133/133 against the rebase base;
this push re-bases onto the current tip, which resolves it).

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-08-31 14:03:48 -04:00
Tom Boucher
6eea00b707 enhance(#3301): tell reviewers the plan ids and total count, grade coverage (#4084)
* test(#3301): add failing-first plan coverage manifest tests

Failing-first regression tests for the plan-id manifest, the updated Review
Instructions, and the mechanical per-reviewer coverage check, ahead of the
review.md implementation. RED baseline before the fix lands.

* test(#3301): raise allow-test-rule-refs unverified ceiling for new marker

Adding tests/review-plan-coverage-manifest.test.cjs's source-text-is-the-product
marker grows the unverified-exemption pool by one (282 -> 283), the same
documented growth path scripts/lint-allow-test-rule-refs.cjs's own failure
output names. Confirmed clean via 'npm run lint:allow-test-rule-refs' locally.

* feat(#3301): tell reviewers the plan ids and total count, grade coverage

build_prompt now derives a plan-id manifest from each *-PLAN.md filename
(stripping the -PLAN.md suffix) and appends it, with the total plan count,
to both gsd-review-instructions.md and gsd-review-prompt.md. The Review
Instructions prose requires one heading-verbatim section per id before any
cross-plan or overall-risk content.

write_reviews grades each dispatched lane's real (non-stub, non-empty)
review against that same manifest and records an optional plan_coverage:
frontmatter block, present only when a lane is incomplete. The match
escapes regex metacharacters in the id and excludes a preceding/trailing
hyphen or word character as a boundary, closing the two traps named in the
issue (a decimal phase like 12.6 satisfied by 12X6-01; a threat id like
T-04-07 registering as coverage of plan 04-07). CodeRabbit is exempt, since
it never receives the source-grounding prompt carrying the manifest.

This closes the gap where a review that silently covers only some plans in
a multi-plan phase is indistinguishable from one that covers all of them.

* docs(#3301): add changeset fragment

* test(#3301): use t.after() instead of try/finally for cleanup

CONTRIBUTING.md bans try/finally inside test bodies. Code review caught
this in the new coverage-manifest test file; switch every fixture-cleanup
site to the approved t.after() pattern.

* test: use t.after() instead of try/finally in #3300's build_prompt tests

Pre-existing try/finally-for-cleanup pattern in this file (landed for
#3300) violates CONTRIBUTING.md's explicit ban on try/finally inside test
bodies. Surfaced incidentally while reviewing #3301's diff, which cites
this file as its extraction-pattern precedent; fixed inline per the
no-defer rule rather than deferred to a separate PR.

* test(#3301): anchor coverage-check extraction on the fence line, not prose

`.plans-manifest.md` also appears in write_reviews' own prose ahead of the
```bash fence, so indexOf found that occurrence first and the
backward-walk-to-fence-open landed on the earlier, unrelated gate-check
block instead. gsd-test caught this: coverage-check tests expecting a real
verdict got null, because the wrong block ran and never writes
.plan-coverage-<slug>.json. Anchor on the fence-only bash assignment line
instead.

Emitted-Drift-Ack-Growth: review.md — #3301 adds the plan-coverage manifest and mechanical coverage check to build_prompt/write_reviews.

* fix(#3301): route id escaping through the canonical pattern seam

ADR-3212 (epic #3212) consolidated ~44 hand-rolled regex-escape copies into
one owner, src/pattern.cts's escapeRegex, specifically to stop this exact
class of duplication. My coverage-check node -e script hand-rolled the
identical metachar-escape regex — invisible to eslint-rules/no-adhoc-regex-escape.cjs
only because it lives inside a workflow markdown file, not a .cts/.cjs
source file the shape-matching guard scans. Require the compiled seam
(gsd-core/bin/lib/pattern.cjs) instead, matching the established
node -e-requires-a-compiled-lib idiom already used elsewhere in this
workflow (code-review.md's code-review-flags.cjs/code-review-depth.cjs
calls). Verified both named traps from the issue still resolve correctly
under escapeRegex's RegExp.escape-backed implementation, which differs in
escaped-text shape (hex-escapes hyphens/leading chars) but not match
result.

* test(#3301): run coverage-check block with cwd at the repo root

The block's node -e now requires ./gsd-core/bin/lib/pattern.cjs, a path
relative to the repo root (correct for production, which always runs
from there). The test harness ran it with cwd at the fixture's own temp
dir instead, so the require failed. Add an optional cwd param to
runScript (default: root, unchanged for the plan-copy-block tests) and
pass the real repo root for every coverage-check call site. Manually
verified end-to-end before spending another remote run: the extracted
block now produces the expected {complete:true} verdict.

* docs(#3301): backfill changeset pr number (pr:0 -> pr:4084)

---------

Co-authored-by: sim <sim@local>
2026-08-30 14:25:24 -04:00
Tom Boucher
86452da7cb fix(#4070): reserve shard 1's aux-suite cost out of the LPT unit-test packer (#4072)
* test(#4070): failing-first regression for shard1 aux-suite budget imbalance

- selectShard has no way to reserve virtual weight on a bin, so the LPT
  unit-test packer cannot account for shard 1's fixed aux-suite cost
  (integration/security/install/slow all pinned to shard 1/3).
- test.yml wires no such reserve into the workflow.
- ci-test-job-timeout-budget.test.cjs's LANE_COSTS entry for job `test`
  was stale (7m12s from run 30677442953, predating the aux-suite growth);
  corrected to the real evidence cited in #4070 (13m48s / cancelled at
  ~14m51s), which now honestly fails the file's own 1.5x headroom policy
  against the current 15-minute cap.

All three are expected RED on this commit; see
.gsd/bug/fix-4070-shard1-aux-suite-budget/50-test-matrix.md.

* fix(#4070): reserve shard 1's aux-suite cost out of the LPT unit-test packer

selectShard now accepts an optional initialWeights array giving one or more
bins a virtual head start before any file is placed, so LPT converges each
bin's FINAL total (assigned weight + head start) toward equal instead of
raw assigned weight alone. test.yml wires RUN_TESTS_SHARD_RESERVE=1:77 into
the full-scope unit-test step (gated on matrix.scope == 'full', so the
unrelated windows lane is unaffected) -- 77 weight units is the empirical
conversion of the aux suites' ~220s measured fixed cost, derived against
the real tests/test-timings.json (see the diagnosis artifact for the full
computation).

Also corrects two pieces of now-stale bookkeeping this issue exposed:
- ci-test-job-timeout-budget.test.cjs's LANE_COSTS entry for job `test`
  carried a 7m12s figure that predated the aux-suite growth; corrected to
  the real pre-fix evidence (13m48s / cancelled at ~14m51s, issue #4070),
  which requires raising timeout-minutes from 15 to 21 (1.5x headroom over
  the real worst-case measurement) to satisfy the file's own policy.
- test.yml's job-header and matrix comments, which still claimed the aux
  suites cost "~1m35s combined" (they now measure ~216-224s).

Closes the gap the previous commit's failing-first tests proved: selectShard
had no reserve-capacity mechanism and test.yml wired none in.

* fix(#4070): correct the reserved-weight property bound; cover main()'s reserve bounds check

Isolated code review found a genuine gap and gsd-test's real run confirmed a real
test bug it exposed:

- The fast-check property "no shard exceeds average(+reserve) + heaviest file" was
  falsified by gsd-test itself (weights=[1,1,1], total=2, reserve=6 on bin 0):
  selectShard is correct, the BOUND was wrong. A reserve large enough that its bin
  never receives a real item stays at exactly that reserve forever -- no amount of
  routing real items elsewhere can dilute a fixed head start below itself -- so the
  true bound is max(reserve, the classic Graham term), not the Graham term alone.
  Verified the corrected bound against the exact counterexample plus 20,000
  additional random trials (zero violations) before re-running gsd-test.

- Isolated review (MAJOR): the shard-total bounds check on RUN_TESTS_SHARD_RESERVE
  and its console.error fallback in main() were untested end-to-end --
  parseShardReserve itself has no concept of the shard total, so only main()
  enforces that guard, and nothing exercised it through the subprocess seam. Added
  an E2E harness test that sets RUN_TESTS_SHARD_RESERVE to an out-of-range index
  via the real CLI, asserts the fallback warning fires, AND asserts the resulting
  file selection is byte-identical to a no-reserve control run against the same
  injected timings table -- proving the reserve was actually ignored, not just
  that a warning printed.

* chore(#4070): backfill changeset PR number

pr:0 -> pr:4072

* fix(#4070): strip leaked RUN_TESTS_SHARD_RESERVE from the harness test's child env

Real GH Actions CI on this PR (run 33288554040, ubuntu shard 2/3) failed 7
tests in the shard-partitioning describe block, all with the same symptom:
`run-tests: no tests in suite "all"` where a real file count was expected.
gsd-test's own dockerized bench run never showed this, and ubuntu shards 1/3
and 3/3 (which run the same test.yml step) passed clean -- the discrepancy is
the tell: only shard 2/3 happened to schedule this specific test FILE for
that run, and the outer CI job's own environment is where the leak lives.

Root cause: test.yml's "Run unit tests" step now sets
RUN_TESTS_SHARD_RESERVE=1:77 (this issue's own reserve mechanism) on the
OUTER job that runs `npm run test:coverage:unit:raw -- --shard N`. The
harness test file's runHarness() helper spawns run-tests.cjs as a CHILD of
that same job via `{...process.env, ...extraEnv}`, so every pre-existing
--shard test in this describe block silently inherited the ambient reserve
-- even though none of them know it exists. A reserve of 77 weight units
utterly dwarfs the ~0.3 total weight of the 9-file synthetic fixtures these
tests use (none are in the real timings table, so all fall back to the same
tiny median weight), so shard index 1 is routed zero files every time --
exactly the observed "no tests" failures, and exactly the skewed 5/4 split
observed on the shard-2 test that expected a plain 3/3/3 round-robin.

Reproduced locally end to end (set RUN_TESTS_SHARD_RESERVE=1:77, spawn the
old runHarness against a synthetic 9-file fixture, --shard 1/3 -- reproduces
the exact "no tests in suite \"all\"" stderr) and confirmed the fix (env
stripped unless a test opts in via extraEnv, as the #4070 E2E bounds-check
test already does) resolves it, before re-running gsd-test.

This is a genuine bug this PR introduced -- a new ambient env var that a
pre-existing subprocess-spawning test helper didn't know to isolate against
-- not a pre-existing flake and not resource contention.

---------

Co-authored-by: sim <sim@local>
2026-08-30 00:43:02 -04:00
Tom Boucher
8793d307f6 fix(#4060): drive repo-baseline lint check in-process, not via a subprocess timeout race (#4065)
* test(#4060): failing-first regression for repo-baseline subtest timeout race

The "repo baseline passes" subtest in lint-allow-test-rule-refs.test.cjs
drives the script under test via a spawnSync subprocess with a fixed
30s timeout, which races the script's real wall-clock completion
against unbounded CI-load contention -- it has already died at this
race twice (#4060, and once before at a lower bound). Rewrites the
subtest to call the script's `main` directly, in-process, removing the
subprocess timeout race entirely. This commit only changes the test
(main is not yet exported), so it fails first with
`TypeError: scriptUnderTest.main is not a function`.

* fix(#4060): export lint-allow-test-rule-refs main() for in-process drive

The "repo baseline passes" subtest previously drove this script via a
spawnSync subprocess with a fixed 30s timeout, racing the script's
real completion time against unbounded CI-load contention -- it has
now died at that race twice (#4060, and once before at a lower
bound). A fixed timeout racing unbounded contention has no value that
is both tight and safe, so raising it again would not fix the
mechanism, only its odds.

Parameterizes main() to accept an explicit argv (defaulting to
process.argv.slice(2) only when omitted, so the CLI entrypoint is
unaffected) and exports it, so the test can call it directly,
in-process -- removing the subprocess and its spawnSync timeout kill
race entirely for this one row.

* fix(#4060): capture stderr too in the in-process repo-baseline subtest

Code-review finding: the in-process rewrite captured only console.log,
but main()'s real failure path throws a bare, messageless ExitError --
all diagnostic detail goes to process.stderr.write. The old
subprocess-based assertion embedded both stdout and stderr in its
failure message; this silently dropped that debuggability. Captures
process.stderr.write the same way (restored in finally) and surfaces
both streams in assertion failure messages and in a wrapped re-thrown
error on an unexpected throw from main().

---------

Co-authored-by: sim <sim@local>
2026-08-29 18:20:24 -04:00
0xdhx
472f585f7c fix(#3726)!: require --confirm before milestone complete mutates (#3774)
* fix(#3726): require --confirm before milestone complete mutates

`milestone complete <version>` is a one-way door — ROADMAP.md and
REQUIREMENTS.md archived, every phase directory in the milestone MOVED,
STATE.md rewritten — and ran unconditionally on first invocation through
every invocation path, including `query milestone.complete <version>`,
whose `query` meta-prefix reads as a read-only namespace but performs no
filtering (#167's invocation-compatibility shim + #3243's dotted-form
normalization).

The gate lives on the destructive command itself, not on the `query`
prefix (the prefix is an intentional invocation mechanism, not a
permission boundary — restricting it would break dozens of shipped
workflow callers). Without --confirm and without --dry-run the command
now refuses via error() before reading anything beyond its arg checks,
so an unconfirmed invocation is a guaranteed no-op on disk. --dry-run
still previews with no confirmation needed and is now documented in the
usage block (it was only documented for the sibling archive-quick).
--force keeps its narrow meaning — bypassing the TRUNCATED-scope and
unstarted-phase guards — and does not double as the mutation opt-in.
--confirm follows the existing `phases clear --confirm` idiom in the
same module.

complete-milestone.md's two invocations pass --confirm (the workflow has
gathered explicit user intent by that step). Existing tests get
--confirm appended — pre-change behavior is exactly confirmed behavior —
and a #3726 regression block covers: refusal + full-tree byte-identity
on both invocation forms, --force not satisfying the gate, --dry-run
still passing without confirmation, and --confirm proceeding. The
refusal tests fail against pre-fix code (negative control run).

Fixes #3726

* docs(#3726): document the --confirm requirement in CLI-TOOLS and COMMANDS

Cross-AI review of the fix diff (codex, pre-create) caught three shipped
doc sites still instructing the now-refused bare invocation: the
CLI-TOOLS.md milestone-complete synopsis + flag table, and COMMANDS.md's
two guard-override instructions (`--force` alone now refuses without
--confirm). Localized CLI-TOOLS copies already lag the English synopsis
(no --force/--dry-run either) and follow the translation pipeline, not
this fix.

* chore(#3726): set changeset fragment pr to 3774

* test(#3726): confirm-gate CI repairs — QA scenario caller + growth ack

Two CI reds from the --confirm gate, both this branch's own misses:

- tests/qa/scenarios/milestone-rollover.json invoked `milestone complete
  1.0 --force` as a JSON arg-array fixture — a caller shape the test
  sweep (which grepped runGsdTools/runSdkQuery in tests/*.cjs) never
  enumerated. Adds --confirm; the scenario's boundary-crossing contract
  is otherwise untouched.
- complete-milestone.md's +420-byte --confirm note trips the
  emitted-attribution growth ratchet. Acknowledged as a #3726 append to
  the existing complete-milestone.md entry in
  3409-unreachable-guard-arms.json (two ack sources may never name the
  same path, per that fragment's own precedent).

Local: lint-emitted-drift-ack ok; loop-walk.qa 115/115 green sandboxed.

* docs(#3726): CLI-TOOLS.md guard-override sentences say --force --confirm

Review Major 1: the truncated-window and unstarted-phase guard paragraphs
still told the reader to "Pass `--force` to override", which now refuses
(--force alone does not satisfy the confirmation gate), while the flag
table 470 lines later said the opposite. Mirror the docs/COMMANDS.md pair
so the file no longer contradicts itself.

* docs(#3726): synopsis renders --confirm and --dry-run as alternatives

Review Nit 1: `milestone complete <version> --confirm [--dry-run]` read as
"a dry run still needs --confirm", the opposite of AC 3. Render the pair
as `(--confirm | --dry-run)` in the CLI-TOOLS.md synopsis and the usage
docblock, and let the flag rows carry the rule.

* test(#3726): pass --confirm in base-added milestone fixtures; re-file the growth ack

Rebase onto next (26 commits) surfaced three tests the gate now refuses:
the #3685 write-flag contract pair in tests/milestone.test.cjs and the
`milestone complete` boundary fixture in tests/state-contract.test.cjs
all invoke the command bare. Each now passes --confirm (a mutating run is
exactly what they assert on).

The +420 byte complete-milestone.md growth ack rode on
3409-unreachable-guard-arms.json, which #3078 swept from next as fully
spent — hence the modify/delete conflict. Re-filed under a fresh fragment
named for this issue, never resurrecting the swept one.

* test(#3726): pin the present-but-falsy arm of the confirmation gate

Review Minor 1: the boundary triple covered absent and present but not
present-but-falsy. The gate is an exact-token match, so --confirm=false
and --confirm=0 refuse today — pinned (canonical + query forms, whole
.planning/ tree byte-identical) so a future `=`-aware or prefix-matching
parser cannot silently turn --confirm=false into a confirmed run of an
irreversible command.

* test(#3726): drop --confirm from dry-run-only invocations

Review Nit 2: --confirm was mass-appended to 14 pre-existing --dry-run
invocations that never needed it, so each stopped standing as incidental
proof that a preview needs no confirmation. Reverted to the pre-PR form;
the dedicated AC-3 test carries the explicit assertion.

* docs(#3726): sync the localized CLI-TOOLS synopsis with the confirm gate

REQ-I18N-02 (docs/features/internationalized-documentation.md) requires
translations to stay synchronized with the English source. The four
localized CLI-TOOLS.md guides still advertised a bare
`milestone complete <version>`, which now exits 1. Render the English
synopsis verbatim — `(--confirm | --dry-run)` plus the `[--force]` and
`[--archive-quick]` flags the translations had also fallen behind on.

* test(#3726): drop --confirm from the remaining preview-only invocations

Round 2 reverted the --confirm appends on --dry-run-only invocations in
tests/milestone.test.cjs, but four more sat in two files the sweep missed:
tests/milestone-archive.test.cjs (three) and
tests/milestone-window-single-owner.test.cjs (one).

Each is a preview run whose whole purpose is to document that a preview
mutates nothing, so `--dry-run ... --confirm` contradicted the semantics
the test exists to pin. Dropping the token restores each as incidental
proof that a preview needs no confirmation; the dedicated AC-3 test keeps
the explicit assertion.

No assertion added, relaxed, or removed — the change is four tokens.

* chore(#3726): migrate the emitted-drift ack from a fragment to a commit trailer

#3954 (ADR-3942) moved emitted-drift acknowledgments out of
tests/emitted-drift-acks/ and into git commit trailers, and the fragment
directory no longer exists on next. The reason this PR's fragment carried
moves verbatim into the Emitted-Drift-Ack-Growth trailer on this commit;
the fragment file is removed rather than resurrected.

Emitted-Drift-Ack-Growth: complete-milestone.md — #3726: +420 bytes (40186 -> 40606). The archive_milestone step's two `milestone complete` invocations now pass the required --confirm flag (the command refuses to mutate without it — the archive is irreversible), with a note explaining the flag and pointing at --dry-run for previews. Deliberate runtime-loaded workflow text for the new gate, not converter drift.

* fix(#3726): name --confirm in the version-required refusal

The documented arg-discovery path (gsd-tools.cjs top-level usage: invoke
the command without args and the error lists what is required) stopped at
`version required for milestone complete (e.g., v1.0)` — one required
argument short. Discovering --confirm took a second round trip through the
gate. The refusal now reads `… — and --confirm to mutate`, pinned by a test
that also asserts the version-less invocation leaves .planning/ untouched.

* test(#3726): pin the milestone complete docs against a silent regression

The changeset is `type: Fixed`, which the docs-required lint exempts, so
nothing in CI would notice a later edit that reinstated the bare-`--force`
override prose or dropped `--confirm` from the synopsis. Four tests in
tests/milestone.test.cjs now pin: the synopsis line in docs/CLI-TOOLS.md
and its four localized mirrors; the `--confirm` flag row; both
guard-override instructions in docs/CLI-TOOLS.md and docs/COMMANDS.md,
by guard name (a substring match on each instruction's `--force
--confirm` text); and — as an identity ratchet over the
milestone-complete sections — every `--force` sentence or clause that
lacks `--confirm`, so a new bare instruction in its own sentence or
clause fails whatever its wording. Named residual: a bare instruction
spliced into the same clause as a compliant one coalesces with it and
passes the ratchet; the by-name pins are what keep the four known
instructions from losing the pairing that way. The file is registered
in scripts/docs-guard-registry.cjs so the pin runs on the PR that
changes those docs, not only after merge.

---------

Co-authored-by: CI Rebase Check <ci@gsd-redux>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-08-29 17:00:45 -04:00
Tom Boucher
370cfc6680 enhance(#4036): persist CI shard/job timeout-vs-cap trending, warn at 90% (#4043)
* feat(#4036): persist CI shard/job timeout-vs-cap trending, warn at 90%

Adds two new mechanisms plus an audit-coverage extension:

- scripts/lib/ci-job-timing.cjs: shared elapsed-vs-cap arithmetic
- scripts/ci-check-job-near-cap.cjs: in-job advisory near-cap check,
  wired into test/test-full/mutate/smoke as each job's last step
- scripts/ci-timeout-report.cjs + .github/workflows/ci-timeout-report.yml:
  scheduled REST-API poll that appends new records to
  tests/ci-timeout-budget-history.jsonl and opens a small data-only PR
- tests/ci-test-job-timeout-budget.test.cjs: extended to cover mutate
  (mutation.yml) and smoke (install-smoke.yml), which previously had no
  headroom-factor gate coverage at all

Does not change any timeout-minutes value, shard composition, or shard-1
contents — those stay maintainer policy calls per the issue's own scope.

* fix(#4036): address two-orthogonal-review findings

- Parity tests guarding the two hand-duplicated literals this design
  cannot single-source through GH Actions YAML: CI_JOB_TIMEOUT_MINUTES
  vs each job's own timeout-minutes, and ci-timeout-report.cjs's
  JOB_RULES name-prefixes vs each job's actual name: template.
- Thread run.event through as runEvent on every persisted record, so
  PR-context and push-context install-smoke timings (genuinely
  different matrix shape) are distinguishable in the history rather
  than silently conflated under one job name.
- Replace the Windows near-cap start-time step's ambiguous PowerShell
  +/>> precedence with GitHub's documented string-interpolation form.
- Move github.run_id out of direct ${{ }} shell interpolation into an
  env: var in the new scheduled workflow, per this repo's own
  expression-injection-safe convention.

* test(#4036): regenerate golden install-tree fixtures for scripts/lib/ci-job-timing.cjs

npm run gen:install-tree — scripts/ ships wholesale into the installed
package (per ADR/known-defect precedent from #4012's own PR history: a
new scripts/lib/*.cjs file needs its golden entry regenerated or every
runtime's install-tree test fails). Confirmed via gsd-test: this was the
sole cause of the first real verification run's 25 failures (all in
tests/golden-install-tree.test.cjs, one per runtime). Top-level
scripts/*.cjs files (ci-check-job-near-cap.cjs, ci-timeout-report.cjs)
are not individually tracked in these fixtures — consistent with every
other existing top-level scripts/*.cjs file, so no entry was expected
or added for those two.

* fix(#4036): register new lib file with installer, fix H1 shell policy

- bin/install.js: add ci-job-timing.cjs to GSD_SCRIPTS_LIB_FILES (a
  hand-maintained registry, not generated — tests/install.test.cjs
  asserts every scripts/lib/ file is enumerated here)
- test.yml: replace the two OS-conditional "Record job start time"
  step pairs (test + test-full jobs) with a single unconditional
  `node -e` step. The prior pair's Windows variant declared an
  explicit shell: pwsh, which scripts/workflow-policy.cjs's H1 checker
  statically flags against every OS a job's matrix can realize,
  independent of the step's own if: gate. A single Node one-liner
  needs no shell override at all — it's syntactically valid and
  behaves identically under bash, zsh, and pwsh — which is both H1
  compliant and removes the last OS-specific shell syntax from this
  change entirely.

Both defects were found by a real gsd-test run, not local gates —
lint:ci and build:lib were clean throughout because neither the
scripts/lib/ install-manifest parity check nor the H1 shell-policy
baseline runs as part of lint:ci; both are gsd-test-only suites.

* docs(#4036): how-to for reading CI timeout budget signals

The phase-gate docs check correctly flagged the enablement sequence as
3 real steps (read the near-cap warning, find the accumulated trend
file, pick the right maintainer lever) — a reference table can't carry
a sequence. Adds docs/how-to/read-ci-timeout-signals.md, indexed from
docs/README.md.

* chore(#4036): backfill changeset PR number (4043)

---------

Co-authored-by: sim <sim@local>
2026-08-29 16:13:15 -04:00