Commit Graph

5528 Commits

Author SHA1 Message Date
Tom Boucher
400db94e02 fix(#3894): quick path honors workflow.research_before_questions; key resolves from global defaults (#4047)
* test(#3894): research_before_questions must resolve globally and order quick.md (failing first)

* fix(#3894): quick path honors workflow.research_before_questions; key resolves from global defaults

Two layers, one key. The quick workflow ran its discussion phase
before its research phase unconditionally — neither quick.md nor its
steps ever read workflow.research_before_questions, though the key is
documented, schema-registered, /gsd-settings-writable, and honored by
/gsd-discuss-phase and /gsd-new-project. A gray-area answer given
without research is then written to <quick_id>-CONTEXT.md as a locked
decision downstream agents are told not to revisit — an evidence-free
choice made unfalsifiable (the reporter's #3714 misresolution).

- quick.md Step 4 now carries the same research-before-questions check
  the two honoring paths make: when enabled, research-phase executes
  before discussion-phase; false/unset keeps the written order. Both
  sections stay section-manifest gated.
- src/config-loader.cts forwarded workflow.post_planning_gaps from
  ~/.gsd/defaults.json but silently dropped this key — same file, same
  nesting, one resolved and one didn't. Now forwarded with the same
  flat + nested-alias fallback shape, added to the resolution-keys
  lockstep canary and the #3532 shadowed-warning set (nested alias
  reporting generalized over both keys).

Emitted-Drift-Ack-Growth: quick.md — #3894: +Step 4 ordering rule (the research-before-questions check the discuss-phase and new-project paths already make); a real behavioral gate, not incidental bloat.

* fix(#3894): review fold-ins — gate the CONTEXT.md reference, colon slash-forms

- quick/steps/research-phase.md directed the researcher subagent to read
  <quick_id>-CONTEXT.md under DISCUSS_MODE with no existence hedge — but
  under the new ordering (research BEFORE discussion) the file cannot
  exist yet when the researcher is dispatched. The reference now says
  read-only-if-present with the #3894 reason; the alignment purpose
  still applies on the default ordering.
- quick.md's new rule used the hyphen slash forms (/gsd-discuss-phase,
  /gsd-new-project); source artifacts under gsd-core/workflows must
  author the colon form the install-time converters key on — the same
  file already uses /gsd:new-project and /gsd:quick elsewhere.

* docs(#3894): planning-config row names the flat CONFIG_DEFAULTS alias

config-field-docs requires every CONFIG_DEFAULTS key to appear in the
doc; the row documented the canonical namespaced form only. Adds the
same alias sentence post_planning_gaps's row carries, plus the #3894
quick-path note.

* chore(#3894): changeset fragment (pr number backfilled after PR creation)

* chore(#3894): backfill changeset PR number (4047)

---------

Co-authored-by: sim <sim@local>
2026-08-29 13:51:42 -04:00
Tom Boucher
192eb1dfbd fix(#3886): git commit timeout reported as commit_timeout; stale lock surfaced; 30s band (#4046)
* test(#3886): a timed-out git commit reports commit_timeout, not commit_failed (failing first)

* fix(#3886): git commit timeout reported as commit_timeout; 30s band; stale-lock surfaced

cmdCommit's git commit invocation did not distinguish a spawnSync
timeout from a real non-zero exit (#2608 fixed this for the staging
loop only): a slow pre-commit hook crossing the 10s cap was
SIGTERM'd mid-hook and reported as reason commit_failed with whatever
partial stderr git had flushed (in the reporter's case an incidental
CRLF warning), while the kill left a stale .git/index.lock blocking
the next attempt.

All three commit sites now check isSpawnTimeout before the
nothing-to-commit/ordinary-failure branches: cmdCommit reports
reason commit_timeout + timed_out:true and names the stale lock's
path (surfaced, not auto-deleted — deleting a lock a live git holds
is destructive; the caller recovers deliberately); the subrepo
counterparts do the same within their per-repo result / rollback
error. The commit calls also move to the 30s band the push call
already uses — husky+lint-staged alone idles ~4s on Windows before
any task runs.

* fix(#3886): review fold-ins — git-path lock resolution, shared band constant, executor contract row, precedence pin

- The stale-lock path is resolved via git rev-parse --git-path
  index.lock, never a literal .git/index.lock join (#3588 row 8's
  class: a linked worktree's .git is a FILE, so the literal path cannot
  exist there while the real lock — under <gitdir>/worktrees/<name>/ —
  blocks the next commit; this repo leans on linked worktrees).
- COMMIT_TIMEOUT_MS hoisted; all three sites and their messages build
  from it (the subrepo variant also regains the stdout fallback the
  primary site had).
- agents/gsd-executor.md's commit-result contract gains the
  commit_timeout row with the OPPOSITE retry advice from
  staging_timeout (remove the stale lock, then retry once) — an
  executor matching the doc previously had no handling for the new
  reason.
- Precedence pin: a timeout whose partial output contains 'nothing to
  commit' must still read as a timeout (branch-reorder mutant).

Emitted-Drift-Ack-Growth: gsd-executor.md — #3886: +commit_timeout row to the commit-result contract with the retry guidance OPPOSITE staging_timeout's (remove the stale lock, then retry once); the executor previously had no handling for the new reason.

* chore(#3886): changeset fragment (pr number backfilled after PR creation)

* chore(#3886): backfill changeset PR number (4046)

---------

Co-authored-by: sim <sim@local>
2026-08-29 13:27:14 -04:00
Tom Boucher
0bf778c352 fix(#3849): phase allocation counts numbers held by sibling git worktrees (#4042)
* test(#3849): phase allocation must skip numbers held by sibling worktrees (failing first)

* fix(#3849): phase allocation counts numbers held by sibling git worktrees

Both allocators (cmdPhaseAdd, cmdPhaseAddBatch) chose max+1 over numbers
gathered from ONE checkout — headers, bullets (add only), on-disk dirs.
Every sibling git worktree carries its own .planning/ on its own branch,
so a phase minted there was invisible and the same number was allocated
twice (the reported incident: two Phase 441s, one with six written
plans, surfaced a day late by human memory).

New shared horizon collectSiblingWorktreePhaseNums: one
git worktree list --porcelain, then per sibling — phase-dir names (the
cheap scan that would have caught the incident) and the WHOLE sibling
ROADMAP.md headers (a row can predate its dir; milestone-scoping would
be wrong — a number used under any milestone on another branch is
taken). Widen, never refuse: unreadable sibling / no .planning / not a
git repo / git unavailable each contribute nothing and allocation is
unchanged. Reuses isSentinelPhaseId and the allocators' own patterns.

Secondary (#1229 never reached batch): cmdPhaseAddBatch now also scans
roadmap bullets — a bullet-only 'Phase N' row was invisible to batch
allocation, exactly the condition #1229 was filed for.

Also fixes the two new tests' result-key access (output.phases, not
output.results).

* fix(#3849): review fold-ins — subprocess band, bounded test git, linked-worktree fixture

- execFileSync options now match the repo's git band (10s window,
  windowsHide, 4MiB maxBuffer) — a spurious 4s timeout silently reverted
  to the pre-fix collision.
- test git helper bounded (15s) per local/no-unbounded-spawn.
- new fixture: allocation FROM a linked worktree counts the main
  checkout — the incident's actual topology direction.
- fixture-setup rmSync carries the sanctioned lint-disable (setup, not
  teardown; cleanup() still owns directory removal).

* fix(#3849): exempt the sibling-worktree scan from the enumeration-drift guard

collectSiblingWorktreePhaseNums reads a SIBLING checkout's phases dir —
a different question from the cwd-scoped listMilestonePhaseDirs the
guard routes everything to (which cannot see another worktree's
.planning at all). Function-scoped, per ADR-3180 Decision 4(a): any
other re-derivation in phase.cts is still caught. The GREEN bench
caught the omission.

* chore(#3849): changeset fragment (pr number backfilled after PR creation)

* chore(#3849): backfill changeset PR number (4042)

---------

Co-authored-by: sim <sim@local>
2026-08-29 11:35:19 -04:00
Tom Boucher
519ac23ebb fix(#3839): hook tables say PreToolUse (validate-commit) and SessionStart (session-state) (#4041)
* test(#3839): docs hook tables must match surface registrations (failing first)

* docs(#3839): hook tables say PreToolUse for validate-commit, SessionStart for session-state

gsd-validate-commit.sh is registered PreToolUse (src/runtime-hooks-surface.cts;
its exit-2 block IS the contract — a post-tool hook cannot prevent a commit)
and gsd-session-state.sh is registered SessionStart (session orientation, not
post-tool tracking). Both rows said PostToolUse in ARCHITECTURE.md and the
three INVENTORY locales; the issue asked for a neighbouring-row scan, which
is how the session-state row was found. All other rows in the four tables
verify against the surface.

* fix(#3839): review fold-ins — 10 more wrong rows in ko-KR/pt-BR/zh-CN, parser authority + drift pins

Adversarial review found the same two wrong rows shipped in five more
files the issue's table missed (ko-KR ARCHITECTURE+INVENTORY, pt-BR
ARCHITECTURE+INVENTORY, zh-CN ARCHITECTURE) — all fixed; DOC_TABLES now
covers all ten shipped tables. The parity parser unioned only the Kimi
mirror list, silently exempting agent-isolation-guard (registered via
the dynamic preToolEvent push): probes are now parsed too, with bare
hook names resolved against hooks/ ground truth and dynamic event
variables resolved to their canonical (non-Gemini) events; an exact-set
pin replaces the loose size guard. allow-test-rule marker carries the
issue ref; unverified-ceiling 280→281 (audited: the new marker is
legitimate — the suite reads product docs whose text is the contract).

* fix(#3839): register the hook-table parity suite in the docs-guard lane

The new suite reads ten docs/ paths, so lint-docs-guard-registration
requires it in the docs-guard registry — the first GREEN bench run
caught the omission (the RED run's docs-guard failures were the same
signal, previously misread as marker fallout).

* chore(#3839): changeset fragment (pr number backfilled after PR creation)

* chore(#3839): backfill changeset PR number (4041)

---------

Co-authored-by: sim <sim@local>
2026-08-29 11:11:24 -04:00
Tom Boucher
3c08315a5e fix(#3827): new-mode routing gate — classify approval no longer authorizes scaffold writes (#4037)
* test(#3827): new-mode routing approval gate before roadmapper (failing first)

* fix(#3827): new-mode routing gate — classify approval no longer authorizes scaffold writes

The route_new_mode step delegated to gsd-roadmapper (PROJECT.md,
REQUIREMENTS.md, ROADMAP.md, STATE.md creation + commit) with no gate:
the discovery gate approved classification only, and the zero-conflict
branch said 'proceed to routing silently'. Merge mode previews its diff
and gates via approve-revise-abort; new mode now shows the exact
destinations and requires Create planning setup | Keep synthesized
intel only | Abort, per the skill contract's routing-gate requirement.
The keep-intel-only choice is the analysis-only path the issue asks
for: no roadmapper, no destination writes, intel preserved; finalize
labels it 'new (intel only)' and points at /gsd:new-project instead of
plan-phase. Ambiguous gate answers re-ask once, then treat as Abort —
never infer Create. Zero-conflict wording is mode-aware: silence about
conflicts is not authorization to write.

Emitted-Drift-Ack-Growth: ingest-docs.md — #3827: +routing gate display block, AskUserQuestion contract, three disposition branches (incl. the intel-only no-write path), ambiguous-answer rule, intel-only finalize line, mode-aware zero-conflict pointer; a real behavioral gate, not incidental bloat.

* chore(#3827): changeset fragment (pr number backfilled after PR creation)

* chore(#3827): backfill changeset PR number (4037)

---------

Co-authored-by: sim <sim@local>
2026-08-29 09:41:00 -04:00
Tom Boucher
331747ea99 fix(#3817): count the truncation remainder — display truncates, counting must not (#4034)
* test(#3817): audit-open counts must include the truncation remainder

* fix(#3817): count the truncation remainder — display truncates, counting must not

* chore(#3817): changeset fragment (pr number backfilled after PR creation)

* chore(#3817): backfill changeset PR number (4034)

---------

Co-authored-by: sim <sim@local>
2026-08-29 08:53:21 -04:00
Tom Boucher
ac0eed1267 Merge pull request #4015 from open-gsd/fix/3889-instrument-chunk-timeout 2026-08-29 08:02:14 -04:00
Tom Boucher
213a2fff63 chore(#3813): delete the caller-less listMilestoneArchiveDirs seam; #1883 contract now pins the live path (#4029)
* test(#3813): pin the #1883 unreadable-milestones contract on the live planning-snapshot path

* fix(#3813): delete the caller-less listMilestoneArchiveDirs seam; #1883 contract now pins the live path

* chore(#3813): changeset fragment (pr number backfilled after PR creation)

* chore(#3813): backfill changeset PR number (4029)

* chore(#3813): docs-exempt marker — internal dead-code removal

---------

Co-authored-by: sim <sim@local>
2026-08-29 07:44:22 -04:00
Tom Boucher
b811ea16fc fix(#3807): advance-plan refuses an ambiguous multi-Phase Current Position (#4028)
* test(#3807): advance-plan must refuse an ambiguous multi-entry Current Position

* fix(#3807): refuse an ambiguous multi-Phase Current Position before advancing

* chore(#3807): changeset fragment (pr number backfilled after PR creation)

* chore(#3807): backfill changeset PR number (4028)

---------

Co-authored-by: sim <sim@local>
2026-08-29 03:50:03 -04:00
Tom Boucher
3a4c3cb83e fix(#3805): audit-uat honours the audit_acknowledged marker via the shared predicate (#4025)
* test(#3805): audit-uat must honour the audit_acknowledged marker

* fix(#3805): route audit-uat's UAT and VERIFICATION scans through the shared acknowledged predicate

* chore(#3805): changeset fragment (pr number backfilled after PR creation)

* chore(#3805): backfill changeset PR number (4025)

---------

Co-authored-by: sim <sim@local>
2026-08-29 02:51:54 -04:00
Tom Boucher
f4fefb0bef fix(#3804): audit-uat enumerates all three phase-archive layouts (#4022)
* test(#3804): audit-uat must see all three phase-archive layouts

* fix(#3804): enumerate all three phase-archive layouts (flat, workstream-archived, workstream-active)

* chore(#3804): changeset fragment (pr number backfilled after PR creation)

* chore(#3804): backfill changeset PR number (4022)

---------

Co-authored-by: sim <sim@local>
2026-08-29 01:14:58 -04:00
sim
c1a2c33886 perf(#4012): two subprocess spawns, not five
My instrumentation suite tipped ubuntu shard 1/3 over its 15-minute job cap.
Measured, not guessed: baseline on next (c8f08b61f) passed that shard in
14m21s — 39 seconds of headroom — and the chunk-timeout instrumentation suite
I added costs 31,621ms, about 80% of what was left. The job ran 15m09s and
GitHub reported the cap as a cancel, which failed the required-tests rollup.

Cause is spawn count, not test content. The suite booted scripts/run-tests.cjs
five separate times — three success-path, two timeout-path — and each boot
globs the suite and spawns node --test children before any assertion runs. The
direct-require tests beside it cost milliseconds.

Now two spawns: one success run backing the elapsed-timing, temp-dir-non-leak
and no-EINVAL assertions, and one timeout run backing the in-flight-file
naming and the diagnostic wording. Every assertion is kept verbatim; only the
per-assertion subprocess boot is gone. Modelling per-boot overhead from the
measured total puts the saving around 18s, but that is an estimate derived
from one data point, not a measurement — the real number comes from CI.

The chunk timeout stays at 2000ms. It is already the lowest value used
anywhere in this file, and lowering it further would race a loaded CI box that
has to boot node --test, register the hang, and observe the kill inside the
window.

Worth recording separately: that lane has 39 seconds of margin on next, so it
is one cliff away from this happening to whoever adds the next test. The
per-chunk timing this PR adds is what made the attribution possible at all —
chunk 1/5 alone is 263s of a 900s budget, which was previously invisible.

Verification runs on the remote runner.

Refs #4012
2026-08-29 01:01:36 -04:00
Tom Boucher
80de48c319 enhance(#3914): every phase records a truthful guard ledger (#4018)
* fix(#3914): retire n/no-process-exit where its successor governs

Epic #3889 criterion 5 — no phase closes with a guard added and its
predecessor left standing — is violated in the tree by the epic that wrote it.

local/require-registered-exit was registered on gsd-core/bin/**/*.cjs and
scripts/**/*.cjs, while n/no-process-exit stayed 'error' over a nine-glob block
covering those same two. Only the hooks 'off' exemption ever came down; the
predecessor's registration never did. Both rules have been enforcing the same
property on the same surfaces since P6.

Narrowed, not deleted. Seven of those nine globs have NO successor —
eslint-rules/, bin/lib/, pi/, examples/, vscode/, .kilo/, .opencode/ — so
deleting the rule outright would silently drop enforcement on all seven. That
is the inversion this epic has already hit three times: removing a coarse guard
because a narrower one exists somewhere it does not reach. Flat config is
last-match-wins and both successor blocks come after the nine-glob block, so
'n/no-process-exit': 'off' in exactly those two retires the predecessor
precisely where the successor governs and nowhere else.

The successor is strictly more precise: it permits process.exit only inside
terminateNow in cli-exit.cts, the single sanctioned terminator (ADR-3889 §3),
where n/no-process-exit permits none and would flag terminateNow's own
generated copy.

Asserted at the consumer's altitude via ESLint.calculateConfigForFile on real
paths, with the positive control that matters: n/no-process-exit is still
'error' on six of the seven successor-less globs, so a future edit that turns
this into a blanket disable goes red. bin/lib/ has no file in this checkout and
is reported as untested rather than given an invented path. Severity is
normalized across the string/numeric/array forms the API can return, and the
normalized value asserted — not truthiness.

Verified by running calculateConfigForFile myself on both superseded globs and
four controls before trusting the test.

Found and fixed inline: the change made an eslint-disable directive at
gsd-tools.cjs:257 partially unused, which --max-warnings 0 rejects; narrowed to
the one rule still in force.

Verification runs on the remote runner.

Refs #3914

* docs(#3914): the epic added three guards, it did not remove one

The audit reconciled the epic ledger against what actually landed. The net is
+3, not -1: four lint:generated-sync --check arms (gen-scripts-cli-exit,
gen-hooks-cli-exit, gen-exit-code-registry, gen-exit-code-docs) plus one rule,
against two retirements.

An epic whose thesis was consolidation ended with a larger guard surface than
it started with. The additions are each defensible; the claim that the total
fell was never true.

Two of the three prior errors in this amendment are mine. It said "Net -1 by
count" above terms reading -1 -1 +1 +1 +1, which sums to +1 — an arithmetic
error in the paragraph directly below the sentence arguing that an ADR about
honest accounting must not pad its own ledger. And the term list omitted two of
the four --check arms, which is what turns that +1 into the real +3.

Recorded rather than quietly rewritten. This ledger has now been wrong three
times — the original -2, the -1 that replaced it, and #3914's own table, which
states -1 above terms summing to 0 — and a written claim nobody checked against
the thing it describes is the exact failure this epic exists to close.

Refs #3914

* fix(#3914): make the successor actually supersede before retiring the predecessor

An isolated security review found that the previous commit turned off a guard
that was still doing work. Reproduced by executing both rules against a
fixture, not inferred:

  const exit = 'exit';
  process[exit](1);

n/no-process-exit flags it; local/require-registered-exit did not, because it
early-returned on callee.computed. So retiring the predecessor on
gsd-core/bin/**/*.cjs and scripts/**/*.cjs un-guarded that shape on precisely
the two globs this epic's exit contract cares most about.

This is the third time in this epic I have removed a coarse guard on the claim
that a narrower one covered it, without checking construct-level parity — after
the allowlist key-to-prefix-to-exact-membership sequence and the band
ranges-to-categories one. The rule is the same every time: a narrower guard
supersedes a coarser one only where it demonstrably reaches at least as far,
and "demonstrably" means executing both against the constructs, not reading
either.

The successor now resolves computed property access for the statically
determinable cases — a string Literal, and an Identifier bound once to a string
Literal, resolved through scope — and leaves genuinely dynamic properties
alone so the rule does not over-fire. Measured after the fix: plain
process.exit flagged, process['exit']() flagged, process[exit]() flagged,
process[globalThis.k]() not flagged. That makes it a strict superset of the
predecessor on these globs, since process['exit']() was caught by NEITHER rule
before.

The second finding is worse than the first, because it was reasoning rather
than oversight. My justification comment claimed n/no-process-exit "would flag
terminateNow's own generated copy here". It would not — that file is in the
global ignore list, so neither rule ever lints it. There was no conflict to
resolve; I wrote a rationale I had not checked, in a change whose entire
subject is written claims nobody verified. Both comment blocks now state the
real basis.

The tests that should have caught this asserted only rule SEVERITY per glob and
never construct REACH, which is exactly how a coverage hole passed. A parity
matrix now pins all five shapes, including a RED/GREEN regression pin against
an inlined reproduction of the pre-fix rule — inlined rather than loaded from
HEAD, because HEAD resolves to the fixed commit under the remote runner and
would silently stop testing anything.

Verification runs on the remote runner.

Refs #3914

* fix(#3914): the two exit rules are complementary — keep both

Reverts this branch's retirement of n/no-process-exit. The premise was wrong
twice, and the second review proved the change itself was wrong.

I claimed local/require-registered-exit was a strict superset on
gsd-core/bin/**/*.cjs and scripts/**/*.cjs. Measured, successor vs predecessor:

  function f(exit) { process[exit](1); }         0  vs  1
  let exit='exit'; exit='exit'; process[exit]()   0  vs  1
  const { exit } = ...; process[exit](1)         0  vs  1

plus for-of bindings, let-then-assign, var redeclaration, catch params, and an
undeclared global named exit. The predecessor matches any identifier NAMED
exit however it is bound; the successor resolves only a string literal or a
single-write const. It never was a superset — I asserted the relationship after
fixing one construct and did not re-check the rest.

The justification was independently false: all three generated cli-exit copies
are in the global ignore list, so n/no-process-exit was never flagging
terminateNow. There was no conflict to resolve. I wrote a rationale I had not
verified, in the phase whose subject is written claims nobody checked.

So criterion 5 does not apply to this pair. They are not predecessor and
successor — they are complementary, each catching constructs the other misses.
The epic's criterion assumed a replacement relationship that does not exist
here, and retiring either rule loses real coverage. The ADR ledger now says so
with the measured shapes.

What survives is the genuine improvement: the computed-property strengthening.
local/require-registered-exit now catches process['exit'](1) and optional-chain
terminators like process?.[k]?.(1), which NEITHER rule caught before, while
correctly ignoring a genuinely dynamic property so it does not over-fire.

The parity tests are rewritten to assert what is true rather than what I wanted
to be true: a bidirectional matrix where each rule is shown catching shapes the
other misses. The previous matrix tested only the four shapes where the
successor wins, which is precisely why the regression shipped — a test set
selected to confirm the thesis.

Also corrected: a stale ADR sentence claiming a third wrong ledger version that
does not exist (the table it described now reads +3 over terms summing to +3),
and a changeset whose stated motivation was the false generated-copy conflict.

Verification runs on the remote runner.

Refs #3914

* fix(#3914): the exemption term was a no-op — the net is +4

Fourth correction to this ledger, and a fourth error of the same kind.

Every version counted removing the n/no-process-exit 'off' entry from the hooks
block as -1. Measured: calculateConfigForFile returns undefined for that rule on
hooks/**. It was never registered there, and no broader block sets it globally,
so the 'off' entry overrode nothing and removing it changed no enforcement at
all. A no-op removal, not a guard removal — the same category error as counting
baseline acknowledgement entries: a thing that is not a guard, in guard units.
It is misattributed too; that block came down in d98b55562 (#3910), already on
next before this branch existed.

So the epic added FOUR guards, not three.

This surfaced from a test of mine that overclaimed. I asserted n/no-process-exit
was error on "all nine CommonJS/hook globs" — but hooks is not one of the nine,
and the rule resolves to undefined there. Fixing the test to match reality is
what exposed the ledger term, which is the argument for tests that assert
identity rather than a comfortable shape.

The hooks state is now pinned explicitly rather than glossed: n/no-process-exit
unregistered, local/require-registered-exit error. It is mildly surprising and
therefore worth a test.

Also updates a pre-existing test that documented the old name-based-only
boundary as intentional. The computed-property strengthening deliberately moves
that boundary — process['exit'](0) was caught by NEITHER rule before — so the
test now asserts the new contract and cites the ADR, rather than being left to
fail or the rule weakened to satisfy it. A contract change should read as
deliberate in the test that pins it.

Verification runs on the remote runner.

Refs #3914

* chore(#3914): backfill changeset pr number to 4018

---------

Co-authored-by: sim <sim@local>
2026-08-29 00:53:33 -04:00
Tom Boucher
51ca9f39ba fix(#3801): register inline_plan_threshold in the defaults manifest and correct the docs (#4019)
* fix(#3801): register inline_plan_threshold in the defaults manifest (default 2) and correct settings-advanced

* chore(#3801): changeset fragment (pr number backfilled after PR creation)

* chore(#3801): backfill changeset PR number (4019)

* test(#3801): parse the defaults table with the shared markdown-table parser

---------

Co-authored-by: sim <sim@local>
2026-08-28 23:48:42 -04:00
Tom Boucher
ac7587287b fix(#3812): document how Current Position actually resolves a duplicate field (#4017)
* docs(#3812): say that Current Position is single-valued, and pin the behavior that makes it true

#3812 shipped CLOSED with half its acceptance unmet. #3873 delivered cardinality for FRONTMATTER
keys - current_phase/current_plan render as optional at docs/reference/state-md.md:89,91, covered by
tests/gen-state-md-docs.test.cjs:374. The issue's actual ask was the ## Current Position BODY
section, and that never landed. Surfaced by an /adr-phase-coverage audit of epic #3473; the issue
was reopened rather than noted.

The section now states three things: every field is single-valued, the section is overwritten rather
than appended to, and a duplicate resolves to the FIRST occurrence with no warning - so a line
appended in good faith is silently ignored rather than winning. Progress history belongs in
## Performance Metrics, two headings down, and the text now points there.

The third claim is a behavioral promise about the reader, so it was VERIFIED BY EXECUTION before
being written rather than inferred from the issue title:

  stateExtractField(<"Phase: 1 of 5 (First)" ... "Phase: 9 of 9 (Appended later)">, "Phase")
    -> "1 of 5 (First)"

The mechanism is state-document.cjs:405 - the plain-line pattern ^<field>:[ \t]*(.+) carries flags
im with NO g, so String.match returns the first hit. Writing "first wins" without running it would
have repeated the exact error I had to retract twice in this epic already.

A test pins the reader, not the prose. Three rows in tests/state.test.cjs: T1 (load-bearing) asserts
the duplicated case resolves first; T2 asserts the ordinary single-field case still works, so a fix
that only functions when duplicated cannot pass; T3 puts a Plan: line BETWEEN the two Phase: lines
and asserts it resolves independently - negative space, because a reader returning the first line of
the SECTION rather than the first matching FIELD would satisfy T1 alone. Proven to discriminate: a
last-match variant returns "9 of 9 (Appended later)" and T1 reds.

No assertion checks that the document contains a sentence. That is what local/no-source-grep exists
to stop, and it would pin wording that is allowed to improve. The point of the test is that if that
regex ever gains g and a last-match walk, the test fails - instead of the documentation quietly
becoming a lie with nothing to notice.

Prose only, no new heading. docs-state-md-locale-parity compares heading-level sequences by LCS
rather than text, so added paragraphs cannot fail it while an added HEADING would fail all four
locales. The constraint is structural, not stylistic - confirmed by running that comparison after
the edit.

The four locale copies are translated rather than left stale. They are not gate-enforced for prose,
so "nothing fails" was available and is not the same as correct: leaving four documents asserting
something the English one now contradicts is a correctness problem. Code spans and the anchor link
stay untranslated - they name real tokens.

The whole approach rests on one fact, checked first: ## Current Position at :196-208 sits OUTSIDE
every generated marker region (:81-104, :138-151), so a hand edit survives --write. Re-confirmed
after all five edits - gen-state-md-docs --check reports all 6 targets up to date. Had that been
false the fix would have belonged in the generator, and a hand edit would have been silently
reverted.

One real gate failure fixed inline rather than reported: the new test's comments referenced
docs/reference/state-md.md, which was not in that file's registered exempt-docs paths, and
lint-docs-guard-registration failed lint:ci correctly. Registered.

Known limit, named rather than folded in: gsd-tools validate/health still do NOT warn on a
duplicated Phase:. #3812 records that as a "consider", not a requirement, and confirms none of the
nine rules in src/health-diagnostic-rules/{state-consistency,phase-structure}.cts counts
occurrences. Documenting the silent first-match is the delivered scope; making it loud is new scope
and stays unclaimed.

Closes #3812

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

* docs(#3812): the rule I documented was false — replace it with the measured one

An isolated review returned two blockers. Both mine, and the first is the worse
kind: I wrote a falsifiable rule into a reference page and got it wrong.

1. "A duplicate resolves to the FIRST occurrence" is FALSE.

   stateExtractField (src/state-document.cts:401-419) tries BOLD `**F:**` across
   the whole input, THEN plain `^F:`, THEN a pipe-table row. Form precedence
   beats document order. Measured against the built reader, all intra-section:

     Phase: A (plain)  /  **Phase:** B (bold, later)   -> B    LATER WINS
       Phase: A (indented) / Phase: B (plain, later)   -> B    LATER WINS
     Phase: A (plain)  /  | Phase | T (table) |        -> A    first wins

   My original verification tested plain-versus-plain, saw first-wins, and
   generalized to all forms. Measuring one case and claiming the general rule is
   the same error I have had to retract twice already in this epic.

   It is also worse than silence. The sentence told authors an appended line is
   safely ignored; a bold line appended "for emphasis" silently overrides the
   original. Someone trusting the doc would have corrupted their own state file.
   And #3812 never asked for a resolution rule - it asked for single-valued,
   overwrite-not-append, and where history goes. The rule was my unrequested
   addition.

   Replaced with the measured truth: resolution is by FORM (bold anywhere, then
   plain at line-start, then table row), and only WITHIN the winning form does
   the first occurrence win. Both consequences stated plainly - a higher-ranked
   form wins regardless of position, and an indented `Phase:` is invisible to the
   plain form. All five claims in the new paragraph verified by execution before
   being written, including the two I had wrong.

2. The tests tested the wrong case and passed for the wrong reason.

   T1/T3 put the second `Phase:` under `## Somewhere else` - the INTER-section
   case, which #2956 already fixed by scoping. #3812 says verbatim that #2956
   "fixed the inter-section case and never addressed intra-section duplication",
   so the case the new prose describes was untested, and the fixtures passed
   because of section scoping rather than field resolution. They also called bare
   stateExtractField rather than the production chain, T2 could not discriminate
   first from last at all, and no fixture mixed forms - which is precisely why the
   false claim survived to review.

   Rewritten as four rows, all intra-section, all through the real
   stateCurrentPositionSlice -> stateExtractField path: plain-then-plain (first
   wins within a form), plain-then-bold (the bold LATER value wins - the row whose
   absence let the false claim ship), indented-then-plain (indented invisible),
   and sibling-field independence. Each proven to fail against a reader that
   disagrees.

3. Two dead anchors. pt-BR and zh-CN linked `#performance-metrics` while their own
   headings are `### Métricas de Desempenho` and `### 性能指标`. Both fixed to the
   anchor their own heading generates. ja-JP/ko-KR kept the English heading, so
   theirs already resolved.

4. A ja/ko sentence inverted its own meaning. Both rendered "which is the section
   designed to grow" with a bare demonstrative whose nearest referent read as
   Current Position - saying the opposite of the point. Rewritten so the clause
   attaches unambiguously to `## Performance Metrics`.

5. Cross-locale drift, flagged by the implementing agent rather than by me: after
   fixing EN, the four locales still stated the OLD false rule. Four documents
   asserting something measured to be wrong is worse than four saying nothing.
   All four now carry a faithful translation of the corrected paragraph, with
   code spans, each file's own anchor, and the ja/ko referent fix preserved.

Verified: all five claims executed against the built reader; every rewritten test
row proven to discriminate; gen-state-md-docs --check reports all 6 targets up to
date, so the edits stay outside the generated marker regions; locale heading
parity unaffected (prose only, no headings added); build:lib, lint and lint:ci all
exit 0.

Refs #3812

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

* docs(#3812): second false rule on the same page — scope the ranking to the section

A second isolated review found a second false falsifiable claim, and the failure
mode is the same one twice in a row:

  attempt 1: verified plain-vs-plain, wrote a claim about ALL FORMS
  attempt 2: verified bare stateExtractField, wrote a claim about THE DOCUMENT

Both times the claim covered a wider surface than what was actually executed. The
fix each time was not a better sentence, it was executing the surface the
sentence describes.

BLOCKER — "bold `**Phase:**` anywhere in the DOCUMENT wins" is false.

  ## Current Position / Phase: 1 of 5   +   ## Archive / **Phase:** 88

    bare stateExtractField(whole doc) -> "88 (other section)"
    PRODUCTION (slice then extract)   -> "1 of 5 (in section)"

#2956's section slice means production never hands another section to the
matcher; a bold line in `## Archive`, or in the YAML frontmatter, is simply not
seen. The ranking is real but scoped: it applies WITHIN `## Current Position`.
I verified against the bare function and wrote a claim about the system.

Every existing test placed its bold line inside the section, which is exactly why
nothing contradicted the claim. T5 now puts a bold `**Phase:**` in `## Archive`
and asserts production returns the in-section plain value, with the unscoped
reader asserted to DISAGREE so the row proves the scoping rather than assuming
it.

BLOCKER — the changeset still shipped the ORIGINAL retracted claim.

I corrected the page and left the release note saying "resolves to the first
occurrence ... a second entry added in good faith is silently ignored". The note
contradicted the page it announces, and the release note is what most people
actually read. Rewritten to the corrected rule.

MEDIUM — the concession was inverted. It read "wins even if it comes FIRST in the
file", which is the vacuous direction; the surprising case, and the one the very
next clause illustrates with an APPENDED bold line, is "even if it comes LAST".
All four locales reproduced the inversion faithfully, so it was an EN-source
defect rather than translation drift.

Two sharp edges now named, both measured: a bold `**Phase:**` followed only by
trailing spaces resolves to an EMPTY STRING and does not fall through to a valid
plain line below (T6 pins it); and `| **Phase:** | 3 of 4 |` short-circuits to the
bold form and returns the literal `"| 3 of 4 |"`. A page that teaches form
ranking has to say where the ranking bites.

Also fixed: all five files labelled the link `## Performance Metrics` while the
heading is `### Performance Metrics`. Anchors resolved correctly everywhere; only
the label's level was wrong.

Every clause in the final paragraph re-verified through the PRODUCTION chain
(stateCurrentPositionSlice -> stateExtractField), clause by clause, before being
written: bold in another section does not win; bold in frontmatter does not win;
bold appended last does win; first wins within one form; trailing-space bold
yields empty. All four locales carry the same corrected rule.

gen-state-md-docs --check reports all 6 targets up to date; heading counts
unchanged at 20/20 across all five files, so locale heading-parity is untouched;
build:lib, lint and lint:ci all exit 0.

Refs #3812

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

* chore(#3812): backfill changeset pr number

Refs #3812

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

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 20:50:15 -04:00
Tom Boucher
1051c6d8d4 fix(#3799): scope legacy cleanup to the install's resolved config dir and add --no-legacy-cleanup (#4013)
* fix(#3795): read the interrupted agent id before clearing the stale marker (#4006)

* test(#3795): the interrupted-agent read must precede the stale-id clear

* fix(#3795): read the interrupted agent id before clearing the stale marker

execute-plan's init_agent_tracking step ran `rm -f
.planning/current-agent-id.txt` BEFORE the existence check that read it,
so the interrupted-agent branch and the Task resume prompt it exists to
offer were unreachable (#3795) — a kill -9 mid-executor left the file,
and the next run deleted it before looking. The read now precedes the
clear; fresh-run semantics (no stale id leaking into the new spawn) are
preserved. A structural guard pins the order.

Emitted-Drift-Ack-Growth: execute-plan.md — #3795: +bytes from reordering the interrupted-agent read before the rm plus the explaining comment

* chore(#3795): changeset fragment (pr number backfilled after PR creation)

* chore(#3795): backfill changeset PR number (4006)

---------

Co-authored-by: sim <sim@local>

* fix(#3799): scope legacy cleanup to the install's resolved config dir and add --no-legacy-cleanup

* chore(#3799): changeset fragment (pr number backfilled after PR creation)

* chore(#3799): backfill changeset PR number (4013)

* test(#3799): mark the legacy-name fixtures with gsd-allow-legacy-name

---------

Co-authored-by: sim <sim@local>
2026-08-28 20:16:50 -04:00
sim
d1d6c82a0f chore(#4012): backfill changeset pr number to 4015 2026-08-28 20:00:09 -04:00
sim
bf8905fcc9 fix(#4012): a hang never emits the events I was listening for
The init marker settled it. The diagnostic reported "THE REPORTER LOADED BUT
RECORDED NO TEST EVENTS — the events file contains only the reporter's own
reporter:init marker", which refutes the reporter-never-loaded hypothesis and
leaves exactly one explanation.

The runner spawns a child process per test file and surfaces a subtest's
test:start / test:pass / test:fail to the parent's reporter only once the child
REPORTS that test — which happens when it completes. The fixture hangs forever,
so it never completes, so it never reports. I had recorded exactly those three
event types: the precise set that a hang guarantees you will never see. The
feature could not have worked for the case it was built for.

test:enqueue and test:dequeue are emitted by the runner as it queues and begins
each file, independent of anything inside finishing. test:dequeue is what
actually means "in flight", and it is now the primary signal, with test:start
kept as a secondary one. A file is in flight when it has been dequeued and has
no terminal event.

The four branches now describe states that are all real: the events file absent
(reporter never loaded); the init marker alone (the runner dequeued nothing at
all — genuinely surprising now rather than the expected outcome); everything
dequeued and terminated (the files finished and the process hung afterwards, a
handle leak); and one or more dequeued-but-unterminated files, named, which is
the case this whole feature exists to report.

Verified against the exact shape the real hang produces, by executing
analyzeChunkEvents on a synthetic events file: init + enqueue + dequeue with no
terminal event reports hangs.test.cjs as in flight, and appending a test:pass
clears it. Four more unit tests cover the ordering and multi-file cases with no
subprocess, so this logic is now checkable without a runner round-trip — which
matters, because every defect in this feature so far was visible only remotely.

T1 is untouched and should now pass for the right reason.

Verification runs on the remote runner.

Refs #4012
2026-08-28 19:52:57 -04:00
sim
444137e63a fix(#4012): make the artifact say whether the reporter ever loaded
Down to 3 remote failures, all one chain. The explicit no-events reporting is
working — the diagnostic now states the events file does not exist, instead of
silently printing the generic message. But it then ASSERTED a cause: "the child
was killed before the reporter wrote even one event (process/spawn startup
stall, not a test hang)". That was a guess dressed as a finding, and the fixture
contradicts it: it starts a real test, so test:start should fire in milliseconds
against a 2000ms budget.

Two hypotheses remained and I could not separate them locally, because the local
test runner is hook-blocked here: either the custom reporter never LOADS in the
child, or it loads and no event reaches it before the SIGKILL.

Rather than guess a third time, the artifact now answers it. The reporter
appends a reporter:init line as its first action, before consuming anything, so
the file's contents discriminate: absent means the reporter never loaded;
init-only means it loaded and saw no test events; init plus events means it
works. The diagnostic has a branch for each and, where the cause is genuinely
unresolved, names both possibilities instead of picking one.

Also passes the reporter as a file:// URL via pathToFileURL. Node documents the
--test-reporter value as an import()-style specifier, and a bare absolute path
is not a portable one — notably on Windows. That is a correctness fix whichever
hypothesis holds, and it is a live candidate for the first. FIXED_OVERHEAD is
derived by reducing over the actual argv strings, so the longer URL is accounted
automatically.

Verified by execution, not assumption: composing the reporter against an EMPTY
event stream writes exactly one line, the init marker. That is the whole point
of the marker, so it is pinned by a test rather than left to inspection.

T1 stays red and untouched.

Verification runs on the remote runner.

Refs #4012
2026-08-28 19:41:32 -04:00
sim
5504724d7a fix(#4012): the reporter body must return nully, not an iterable
Fourth failure on this feature, and this one was caused by the previous fix's
lint workaround.

  TypeError [ERR_INVALID_RETURN_VALUE]: Expected nully to be returned from the
  "body" function but got an instance of Array.

When stream.compose is given an async FUNCTION as the body, that function must
return nully. The reporter ended with 'return []' under a comment asserting
Node "still requires the exported function to return an iterable" — exactly
backwards, and the direct cause of 41 failures across every run-tests.cjs
invocation.

That return existed only to dodge ESLint's require-yield after the previous
commit converted async function* to async function. A lint workaround became a
runtime crash, and the comment written to justify it stated the opposite of the
contract. Both are now corrected to what the runtime actually does.

Verified by EXECUTION rather than by reading: composing the real reporter
against a fake event stream completes with no error and leaves both handled
events durably on disk, with the ignored event type skipped. The failing form
was reproduced the same way first, so the diagnosis is not inferred from the
stack trace alone.

The new unit test requires the reporter directly and asserts the returned value
is nully — the assertion that would have caught this before it reached the
runner — plus the exact NDJSON written. No subprocess, so this half of the
feature is verifiable without a full runner pass, which matters because every
defect in this feature so far has only been observable remotely.

T1 remains untouched and red.

Verification runs on the remote runner.

Refs #4012
2026-08-28 19:29:51 -04:00
sim
7f2af28639 fix(#4012): the reporter sink must be a regular file, not devNull
Third failure on this feature, and this one broke everything rather than just
the diagnostic: 43 failures across every run-tests.cjs invocation.

  Error: EINVAL: invalid argument, fsync
  Emitted 'error' event on WriteStream instance

Node opens a WriteStream for a --test-reporter-destination and FSYNCS it on
close. fsync on /dev/null is EINVAL — it is a character device, not a regular
file. So os.devNull is not a usable reporter destination at all, and every
chunk crashed on exit.

The sink only ever needed to be a regular file that stays empty, since the
reporter writes its real output through appendFileSync to the path in
GSD_RUN_TESTS_EVENTS_FILE. It is now one fixed file inside the existing events
dir, pre-created rather than relying on the stream's create-on-open, and left
empty by design. One sink for the whole run, not per chunk, so its path length
stays constant — reporterOverhead feeds FIXED_OVERHEAD, which is computed once
before chunking, and a variable-length path would silently mis-account the
Windows argv ceiling.

The comment that named devNull now says why the destination must be a regular
file, so this does not get re-optimized back into the same crash.

Reaching for devNull was the mistake: it looks like the obviously correct way
to discard output, and it is, for a pipe or an fd — but not for something Node
is going to fsync. Each of the three failures on this feature was a different
edge of the same assumption, that a reporter destination behaves like ordinary
output.

The regression test asserts the closest externally observable consequence — a
normal run must not surface the EINVAL/fsync text. The argv construction lives
inside main() with no exported seam, and the sink is swept before a test could
stat it; adding a seam purely to assert that is left out rather than reshaping
production code for the test. Stated plainly rather than implied.

The failing T1 is untouched and still red.

Verification runs on the remote runner.

Refs #4012
2026-08-28 19:20:11 -04:00
sim
0abd137ec7 fix(#4012): the events reporter has to survive SIGKILL
The remote run proved the instrumentation did not work. Timing landed —
"chunk 1/1 was killed after 2006ms" — but the in-flight-file naming produced
nothing and fell through to the pre-existing generic message. The feature I
wrote to diagnose a kill was itself destroyed by the kill.

Root cause, confirmed rather than assumed. The reporter yielded strings, which
node pipes into the --test-reporter-destination WriteStream. That stream
BUFFERS. execFileSync's timeout sends SIGKILL, which is uncatchable and gives
nothing a chance to flush, so the events sat in a buffer that died with the
child. The parent's own timer reported correctly because it lives in the
parent — which is exactly why half the feature looked fine.

The reporter now writes each event with fs.appendFileSync, unbuffered and
durable at the moment it happens, to a path passed through
GSD_RUN_TESTS_EVENTS_FILE. Env vars do not count toward the Windows
32,767-char argv ceiling, so moving the path out of argv also REDUCES
FIXED_OVERHEAD; the accounting moved with it rather than being left stale. The
destination is now a fixed devNull sink that stays empty by design.

Silence was the reason this was invisible for a whole run. Failing to read the
events file now says so explicitly, and distinguishes a file that could not be
read at all from one that exists but is empty — the generic fallback firing
quietly is what let a broken feature look like a working one. A write is
unbuffered but not atomic, so a kill can still interleave a partial line; the
reader tolerates exactly one unparsable trailing line and reports the complete
ones before it.

The failing T1 was left red and untouched rather than weakened to pass. Three
new unit tests cover the reader directly, with no subprocess, so the parsing
half is verifiable without a full runner pass: missing file, existing-but-empty
file, and a truncated final line.

Also adds ndjson-reporter.cjs to GSD_SCRIPTS_LIB_FILES in bin/install.js —
scripts/lib/ ships, and omitting it meant the file would install everywhere and
orphan on uninstall. That single omission caused 4 of the 7 remote failures.

Verification runs on the remote runner.

Refs #4012
2026-08-28 19:10:54 -04:00
sim
11c24ce973 chore(#4012): regenerate the 19 install-tree goldens
scripts/ ships, so a new file under scripts/lib/ moves every install-tree
golden. The remote runner caught this as 26 failures across all 19 runtimes.

My fault in the dispatch: I limited the implementation's verification to
build:lib and lint:ci and left regen:derived out. Both of those passed, which
is precisely why a green local gate is not a substitute for the runner — the
goldens are only exercised there.

One line per golden: scripts/lib/ndjson-reporter.cjs joining the shipped tree.

Refs #4012
2026-08-28 18:58:05 -04:00
sim
8497833a15 fix(#4012): a killed chunk now names the file that was hanging
The per-chunk timeout fired correctly but reported almost nothing, so every
diagnosis cost a CI round-trip. run-tests.cjs logged chunk START only — no
timestamp, no duration, no end line — then on a kill printed all ~55 basenames
and asked the operator to work out whether output kept flowing (slow) or
stopped early (hang). It could not name the in-flight file because the child is
spawned with stdio inherit, deliberately, per #3597/#1051.

Three additions.

Per-chunk elapsed timing on every path, not just failures, so drift toward the
cap is visible before it becomes a kill. Every timing number in the
investigation behind this had to be reconstructed by hand from GitHub log
timestamps.

A second, machine-readable reporter running ALONGSIDE the human one, writing
NDJSON to its own file. On a kill that file is read back and the files with a
test:start and no matching completion are named, with the staleness of the last
event, so "stopped 480s ago at X" reads differently from "still emitting at
kill". stdio stays inherit and nothing is piped or tee'd — the maxBuffer and
live-output risks that shaped the original design are untouched.

Ranking of the killed chunk's files by known weight, flagging any absent from
tests/test-timings.json, since an unweighted file is an unknown quantity.

Two details that are correct rather than lucky. Passing --test-reporter at all
replaces node's implicit default, so the human reporter is now named explicitly
and reproduces node's own selection (spec on a TTY, tap otherwise) — visible
output is unchanged. And the destination path's chunk index is zero-padded to a
fixed width because FIXED_OVERHEAD is computed ONCE before chunking; a
variable-length path would have silently mis-accounted the Windows 32,767-char
argv ceiling and reintroduced #3597. The reporter flags are added to
FIXED_OVERHEAD exactly as --test-force-exit is.

The multi-reporter pairing and the stream.compose reporter contract were
confirmed against Node's v24 documentation, not recalled — the first draft
carried them as an unverified assumption and said so.

Also corrects a stale comment claiming the 600s cap sits "below the 20m job
cap". The lane is sharded 3x at timeout-minutes: 45; the windows shards were at
19m when chunk 1/5 was killed on b351c83e0 and c3e667df3. The per-chunk cap is
now the binding constraint, and the old silent-cancel model leads to the wrong
conclusion.

Verification runs on the remote runner.

Closes #4012
2026-08-28 18:50:55 -04:00
Tom Boucher
83273f9642 fix(#3798): the profile closure follows command references into workflow spawn surfaces (#4009)
* test(#3798): tiered profiles must install the agents their workflows spawn

* fix(#3798): the profile closure follows command references into workflow spawn surfaces

* chore(#3798): changeset fragment (pr number backfilled after PR creation)

* chore(#3798): backfill changeset PR number (4009)

---------

Co-authored-by: sim <sim@local>
2026-08-28 18:02:44 -04:00
Tom Boucher
ac3668e4b7 fix(#3797): make the roadmapper's role, output, and checklist match its write-first execution flow (#4008)
* test(#3797): the roadmapper must follow one write-first contract

* fix(#3797): make the roadmapper's role, output, and checklist match its write-first execution flow

The roadmapper contradicted itself: role blurb, output format, and
completion checklist described an approve-first flow while its execution
flow said "Write Files Immediately" with reactive-only revision (#3797).
The approval gate belongs to the ORCHESTRATOR — both callers read the
written ROADMAP.md, present it, and gate on approval (with an auto-mode
bypass a subagent cannot host) — so write-first is the contract.

All approve-first text now describes the write-then-return reality, the
old "Draft Presentation Format" (whose ## ROADMAP DRAFT header matched no
orchestrator branch) is folded into the ## ROADMAP CREATED structured
return as a preview block, and the duplicate checklist lines are merged.
A structural guard pins the single contract.

Emitted-Drift-Ack-Growth: gsd-roadmapper.md — #3797: +bytes — approve-first wording replaced with write-first descriptions; the DRAFT presentation template folded into the ROADMAP CREATED return as a preview block

* chore(#3797): changeset fragment (pr number backfilled after PR creation)

* chore(#3797): backfill changeset PR number (4008)

---------

Co-authored-by: sim <sim@local>
2026-08-28 15:55:55 -04:00
Tom Boucher
004c7532b6 fix(#3796): write the audit report to the single-version filename every reader expects (#4007)
* test(#3796): the audit report writer and readers must agree on the filename

* fix(#3796): write the audit report to the single-version filename every reader expects

* chore(#3796): changeset fragment (pr number backfilled after PR creation)

* chore(#3796): backfill changeset PR number (4007)

---------

Co-authored-by: sim <sim@local>
2026-08-28 14:44:01 -04:00
Tom Boucher
ab69b9ce56 enhance(#3987): guard slug re-derivation and the swallowed-precondition shape — §8.5 was guardable after all (#3999)
* feat(#3987): guard slug re-derivation, and record why the swallow shape cannot be guarded

Epic #3473's Decision 1 requires the wrong call site be UNREPRESENTABLE. #3984
measured that two of the nine §8 rules had no guard at all and recorded both as
"Shipped - test-covered". This closes one of them, proves the other cannot be
closed the same way, and corrects two false claims I merged yesterday.

1. §8.3 - scripts/lint-slug-derivation-drift.cjs.

   generateSlugInternal (src/core-utils.cts) is the canonical owner; #3883 removed
   11 inline copies. Nothing prevented a twelfth: no slug guard existed in
   scripts/ or eslint-rules/.

   The detector is STATEMENT-scoped and matches the shape the real copies took -
   one statement carrying BOTH .replace(<negated class>, '-') and
   .replace(/^-+|-+$/, ''). Statement scoping is what buys the precision: the
   loose LINE-level form yields 18 hits with 7 unrelated, a material
   false-positive rate. Measured on the tree: 5 flags, 2 TRUE, 3 SANCTIONED,
   0 FALSE.

   The three sanctioned sites are allowlisted with a reason each, following
   lint-phase-enumeration-drift's form rather than a bare denylist. The owner
   itself is listed explicitly even though it escapes by construction - an
   implicit escape is a latent bug, and the next person to touch line 192 would
   not know the guard depended on it.

2. Both TRUE positives were live defects, not style.

   scripts/qa-smell-ratchet.cjs reproduced the canonical formula including the
   60-cap but trimmed BEFORE truncating - the #2849 bug - and never
   transliterated. The divergence is total, not cosmetic:

     canonical  "privet-mir-privet-mir-privet-mir-privet-mir-privet-mir-prive"
     inline     "tail"

   Cyrillic collapsed to nothing and only the ASCII remainder survived, so the
   ratchet was keying on wrong identifiers for any non-ASCII input.

   tests/planning-inspect.test.cjs carried a helper whose comment claimed parity
   with getPhaseDirFromPhaseId. That function now transliterates; the helper did
   not, so the test asserted against a stale formula while looking correct. Both
   now route through the seam.

3. §8.5 - measured, and deliberately NOT shipped.

   A candidate detector (swallowing catch + errno-retry-set test in the same
   function) gives 26 flags across 11 functions: 0 TRUE, 26 FALSE. Every one is
   best-effort unlink/rm/close cleanup, lost-rename-race backoff, or a deliberate
   null fallback. The file-scoped variant is worse at 71.

   Worse than the noise: the only known true instance was removed by #3885, so
   there is NO POSITIVE CONTROL - the guard cannot be shown capable of failing,
   which this repo requires of every drift guard. Shipping it would add a guard
   nobody can trust and nobody can test.

   The ADR now records the measurement and the reason, keeps §8.5 at
   "Shipped - test-covered", and points at the #1884 regression test as what
   actually enforces it. An honest "not detectable at acceptable precision" beats
   a guard that only ever passes.

4. Two claims I merged into the ADR yesterday were wrong.

   §8.9 said 17 of 19 subsumed children have a test citing their issue number,
   and that #3364 and #3812 have none. Both halves are false, and the claim came
   from a NUMBER-GREP - inside an amendment whose own subject is that a text match
   is not a fact.

     #3364 IS cited: tests/runtime-marker-resolution.test.cjs:107,
       T3 installMarkerResolvesWhenEnvAndConfigAbsent_3897 (#3364), asserting at
       :115-119.
     #3812 IS covered: tests/gen-state-md-docs.test.cjs:374, asserting at :382.

   Corrected to 19 of 19.

   #3812 does carry a real finding, though a different one: it is PARTIALLY
   DELIVERED on a CLOSED issue. The shipped fix declares cardinality for
   frontmatter keys, but #3812's stated acceptance was about the
   ## Current Position BODY section, and docs/reference/state-md.md:196-208 still
   has no normative single-valued/overwrite sentence and no pointer to
   ## Performance Metrics for history. Recorded in the ADR and left for #3812 to
   re-open - fixing it here would bury a scope question inside an unrelated PR.

Note on B6: this ADDS a guard, and B6 said the net count must fall. #3951 already
amended that clause - a guard ledger is a claim about COVERAGE, not count - which
is what makes adding this one honest rather than contradictory.

Verified: the guard flags 0 on the fixed tree, and PROVES IT CAN FAIL - a fresh
inline copy planted in src/ makes it exit 1 naming the exact statement. All three
sanctioned sites were confirmed exempt BY the allowlist, not by accident of the
pattern, by re-attributing each to a non-exempt path and watching it flag.
build:lib, lint and lint:ci all exit 0.

Refs #3987

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

* chore(#3987): add the changeset fragment

Doc-only, so it carries forward from the verified sha rather than costing a
second matrix run.

Refs #3987

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

* fix(#3987): §8.5 IS guardable — I was wrong, and the guard found a live defect

Two orthogonal reviews. The correctness review overturned my central judgment,
and it was right.

1. I concluded §8.5 was "not detectable at acceptable precision" and recorded
   that in the ADR. False.

   My evidence was 26 flags / 0 TRUE / 26 FALSE. The reviewer pointed out what I
   had not: all 26 false positives are CLEANUP verbs - rmSync 54, unlinkSync 43,
   closeSync 17, chmodSync 12 - and the obvious narrower predicate was never
   tried. A swallowed cleanup is legitimate best-effort. A swallowed CREATION is
   a precondition silently lost, which is exactly the #1884 shape.

   Measured properly, in three stages:
     swallowing catch                                     911
     + try-block calls a CREATION verb                     24
     + enclosing function references a *_ERRNOS set         0

   0 flags, 0 false positives. The `*_ERRNOS` naming key is empirically total -
   all 10 retry/tolerate sets in src/ follow it.

   My second claim was worse. I wrote that no positive control exists because
   #3885 removed the only true instance, so the guard "cannot be shown capable of
   failing". That is self-refuting: this very PR's slug guard proves-it-can-fail
   on a synthetic tree, and the pre-#3885 blob is available as exactly such a
   fixture. It is now the control, and it works in both directions - the rule
   flags 0c43d853e^:src/planning-workspace.cts at line 210, the line the fix
   commit's own message cites, and reports zero on the post-fix code.

   I stopped at the first negative result on the option that meant less work.

   Shipped as eslint-rules/no-swallowed-precondition.cjs, wired into the existing
   src/**/*.cts ESLint block rather than a scripts/lint-*-drift.cjs: no script in
   scripts/ requires typescript/espree/acorn, and scripts/ ships to consumers, so
   a .cts-parsing standalone guard would add a devDep at consumer runtime. The
   ESLint block already parses .cts for free.

2. The guard immediately found a live defect of the same class.

   src/capability-lock.cts swallowed a mkdirSync on the lock directory, then
   acquireLock classified the follow-on failure as `code !== 'EEXIST' → return
   null`. A real EACCES/EROFS makes openSync(lockPath,'wx') fail ENOENT, which is
   not EEXIST - so a fatal filesystem error was laundered into "lock
   unavailable". Same defect as #1884, different laundering target.

   Fixed the way #3885 fixed #1884: the creation failure propagates. Regression
   test proven fail-first by hand - with the fix stashed, EACCES was laundered to
   null; restored, it throws.

   The strict rule does NOT catch this shape (its errno classification is an
   inline literal, not a named set). The rule is deliberately left strict: the
   broadened form had 2 false positives - capability-lock.cts:408, the deliberate
   EEXIST steal protocol, and commonjs-marker.cts:131, which returns a distinct
   documented outcome. The gap is noted in code rather than papered over with a
   noisy predicate.

3. The security review found the slug guard's exemption FAILED OPEN.

   currentFunction was never reset, and only a column-0 `function` declaration
   updated it, so exemption bled from an allowlisted declaration to the next one.
   generateSlugInternal exempted 50 lines for an 11-line function. A
   re-derivation planted anywhere in that window was silently exempt - the same
   fail-open shape that produced a blocker in #3897, and an allowlist is a
   SUBTRACTION so a mismatch fails open by construction.

   Extent is now tracked by real brace depth, and a test plants a violation after
   each allowlisted function's real closing brace and asserts it IS flagged.

4. Also from the security review: the guard was a CI-DoS and narrower than I
   claimed.

   Its unbounded [^\]]* was re-scanned from every `.replace(/[^` start: 54.3s on
   a 1.28MB line. It imported MAX_REGEX_LITERAL_LEN and never called
   readRegexLiteralAt - the bounded tokenizer that exists for exactly this. Now
   routed through it with a 2MB file cap: ~200ms.

   15 of 25 genuine re-derivations evaded. Widened to catch replaceAll, {1,},
   \s*-wrapped classes, escaped ], literal new RegExp(...), five trim spellings,
   .split().join(), and multi-line .replace( args - still 0 false positives.
   Two forms still evade and are documented as deliberate gaps with negative
   tests: the two-statement/temp-var form and new RegExp built from a variable.
   Both need data flow, and guessing at it is how a guard becomes noisy.

   Also fixed: // inside a string truncated the line, a ; inside the collapse
   regex split the statement (a one-character bypass), and SCAN_EXT omitted
   .mjs/.tsx/.jsx.

5. A regression I introduced, caught by the same review.

   qa-smell-ratchet.cjs top-level-required a build output that is not
   git-tracked, so the script hard-failed MODULE_NOT_FOUND before build:lib -
   including for --help, which previously had no build dependency. The require is
   now lazy at the point of use.

6. Four of my own tests were vacuous or weak.

   T9's input yielded an identical string under the buggy formula, so it passed
   on the implementation it was meant to catch. T12 compared maxLen null vs 60 on
   an 18-char name, where they agree trivially. T9-T12 all asserted
   generateSlugInternal directly, so they would pass unchanged if both call-site
   fixes were reverted. And prove-it-can-fail was scoped to scanRepo, never the
   CLI - dropping main()'s exit-code line would have kept every row green.

   All rewritten with discriminating inputs, per-call-site rows that red when the
   fix is reverted, 59/60/61 boundaries, an entirely-non-alphanumeric row, and a
   CLI row asserting the real subprocess exit code and both sanitizeForReport
   sites.

Verified: both guards flag 0 on the tree and both prove they can fail. The
swallow rule's control is confirmed in both directions - pre-#1884 shape flagged,
post-#3885 shape clean. build:lib, lint and lint:ci all exit 0.

Refs #3987

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

* docs(#3987): record that §8.5 IS guardable, and correct a correction that made a ledger worse

Three ADR corrections, two of them to text this branch wrote hours ago.

§8.5 advances to Enforced. Its previous entry said the rule was not detectable
at acceptable precision. That was wrong twice: the 26 false positives were
uniformly CLEANUP verbs, which is a reason to narrow the predicate rather than
abandon it, and the claim that no positive control exists was self-refuting - the
pre-#3885 blob is available as a fixture and this repo's own guards prove-it-can-
fail on synthetic trees. Narrowed to creation verbs plus a *_ERRNOS reference:
911 -> 24 -> 0 flags, 0 false positives, control confirmed in both directions.
The entry keeps the wrong reasoning visible, because a high false-positive count
being evidence the predicate is wrong - not evidence the rule is unguardable - is
the transferable part, and the first negative result is most seductive when it is
also the answer that means less work.

§8.9's correction is itself corrected. The original 17-of-19 claim was CORRECT
for the predicate it stated; this branch silently swapped cited -> covered and
declared 19 of 19. #3812 appears in zero test files. Changing what a word means
to make a ledger read better is a worse failure than the miscount it claimed to
repair. Both predicates are now reported separately - 18 of 19 cited, 19 of 19
covered - because §8.9 asks for a test NAMING each child, so 18 is the number
that answers it. #3812 is also re-opened for real, rather than the first draft's
promise that it could be.

§8.3 stays Shipped - test-covered rather than advancing. The slug guard catches
the copy-paste class and a dozen variants, but two forms still evade by decision
(temp-var split, new RegExp from a variable) because both need data flow. Naming
them keeps the status honest: the wrong call site is much harder to write, not
unrepresentable.

Closes #3987

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

* chore(#3987): backfill changeset pr number

Refs #3987

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

* fix(#3987): replace my own wall-clock assertion, and close the guard that let me write it

CI went red on ubuntu shard 2/3. The failing test was mine, and the failure was
the test, not the code.

  a 1.28MB line ... scans in well under a second (was 54.3s pre-fix)  7368ms

It asserted ELAPSED TIME. ~200ms locally, 7.4s on a shared CI runner. The bound
introduced for the MAJOR-2 DoS fix works - 7.4s against a 54.3s pre-fix baseline
is the fix doing its job - but an absolute wall-clock threshold on shared
hardware is a race, not an assertion. CLAUDE.md says so directly: "Clock Seams:
Do not assert on wall-clock time." I wrote the anti-pattern the project bans, in
a PR about guards.

Raising the threshold would only move the flake. The row now asserts a
DETERMINISTIC bound instead: an instrumentation seam on drift-scan.cjs counts
readRegexLiteralAt calls and characters examined, and the test asserts
charsExamined stays under an absolute ceiling. Measured on the same 1.28MB
fixture: 120,000 calls, 48,000,000 chars - two orders under the ceiling. The
pathological fixture is kept; only the thing being asserted changed.

Proven to still discriminate: with MAX_REGEX_LITERAL_LEN raised to simulate the
unbounded pre-fix behavior, the same fixture does not complete in 120 seconds,
versus ~0.3s bounded. It is a real regression test, not a tautology.

Then the second half, which is the same defect class as the rest of this PR.

  eslint-rules/no-elapsed-assertion.cjs matched only the EXACT identifiers
  ^(elapsed|duration|took|ms)$.

I used `elapsedMs`. It evaded the rule entirely. tookMs, durationMs,
elapsedTime and msElapsed evade the same way. A guard that cannot see the
violation it exists to catch is exactly what this PR is about - it just happened
to be an existing rule rather than one of the two I came here for, and it was
found because I committed the violation it should have blocked.

Widened to /^(?:elapsed|duration|took|ms)(?:[A-Z]\w*)?$/ plus a narrow
start/endMs delta pair. Deliberately NOT a blanket *Ms suffix: a first draft did
that and produced 2 false positives on `timeoutMs` in
plan-phase-stall-detection, which is a configured timeout and not a measurement.
Verified negative on params, items, forms, terms, dirnames, timeoutMs,
cacheTtlMs and staleAfterMs.

Measured over the five files carrying camelCase timing identifiers: 0 true
positives beyond my own, so nothing else needed rewriting. The rule's own test
file gains a row asserting `elapsedMs` flags, proven to fail against the
pre-widening rule - the same prove-it-can-fail standard both new guards in this
PR are held to.

Refs #3987

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

* fix(#3987): a comment I added leaked a Claude reference into every runtime install

The runner went red with 4 failures in tests/install.test.cjs:

  Leaking: .hermes/scripts/lib/drift-scan.cjs
  Leaking: .qwen/scripts/lib/drift-scan.cjs

The instrumentation seam added for the deterministic bound carried a comment
naming CLAUDE.md as the source of the no-wall-clock-assertions rule. scripts/
SHIPS to consumers, so that comment was installed verbatim into hermes and qwen
trees, and the install suite scans for exactly this - a Claude-specific reference
reaching a non-Claude runtime.

The rule is real and worth citing; the filename is not portable. The comment now
says "this repo's test rules" and states the rule inline, which is what a reader
of an installed tree actually needs anyway.

Worth noting what caught it: not lint, and not the two guards this PR adds - the
install suite's full-tree scan, which exists precisely because a shipped file is
read by runtimes that have never heard of CLAUDE.md. Same lesson as the rest of
this PR from the other direction: the check that matters is the one that can see
the surface where the defect actually lands.

Refs #3987

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

* fix(#3987): a test fixture swallowed 46 git exit codes and produced a silent false negative

CI red on ubuntu shard 3/3:

  tests/health-validation.test.cjs:2029
  expected exactly one W024, got [{"code":"W006", ...}]

Not caused by this branch, and the evidence is decisive rather than a hunch: the
SIBLING test at :2039 builds the IDENTICAL fixture with the identical
commitsAhead and asserts the same thing, and it PASSED in the same process, same
file, same run. Same input, both outcomes - which rules out logic, ordering,
sharding and environment, and leaves a per-invocation nondeterministic failure
inside one fixture build.

The mechanism is an unchecked exit code, 46 times over. The W024 fixture performs
~46 runGit spawns and never checks a single one. runGit returns failures as DATA
and never throws, so one silently-failed `git commit` yields 19 commits instead
of 20, or a silently-empty `git rev-parse HEAD` yields a blank state_head. Either
drops readStateHeadFreshness below the advisory threshold, W024 never fires, and
only W006 remains.

Reproduced exactly: 20 commits -> ["W006","W024"]; 19 -> ["W006"]; blank
state_head -> ["W006"] - byte-identical to the CI assertion dump.

The arithmetic is what hid it. At threshold-1 and threshold+1 a lost commit still
produces the asserted answer; only the exactly-at-threshold cases sit one commit
from a false negative. Two of the seven tests are in that position, and CI hit
one. That is why it had never been seen before, and why it surfaced now: this
branch adds three test files, which reshuffles the cost-weighted shard partition
and moved this file into a chunk where the latent flake fired.

My files were checked as suspects first and cleared: all fixtures mkdtemp-unique,
no process.chdir, no .planning/ writes, no git spawns, and node --test gives
per-file process isolation regardless.

Fixed at the cause, not the symptom. A mustGit wrapper throws on a non-zero exit
with the command, exit code and stderr, and all nine call sites route through it.
The fixture now asserts its OWN preconditions before the assertion under test
runs - the seed head is non-empty, and `git rev-list --count <seed>..HEAD` equals
the requested commitsAhead - so a fixture that did not build what it claims fails
loudly as a FIXTURE ERROR naming got-versus-asked, instead of quietly handing a
weaker input to the assertion.

Proven: dropping one commit now raises
  FIXTURE ERROR: requested commitsAhead=19 but git rev-list --count reports 18
where it previously produced a silent ["W006"] pass-for-the-wrong-reason. 64/64
tests in that block pass unperturbed.

Deliberately NOT done: no threshold change, no retry, no loosened assertion, no
skip. The assertion was correct; the input was silently wrong.

Worth naming, because it is the same shape from the other side: this PR ships
eslint-rules/no-swallowed-precondition.cjs, whose entire subject is a swallowed
precondition failure being laundered into a plausible downstream outcome. This
fixture is that defect in test code - the swallowed git failure was laundered
into a legitimate-looking "W024 did not fire". The rule does not cover test
fixtures, so the connection is noted at the fix site rather than enforced.

Refs #3987

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

* fix(#3987): two tests wrote to committed files; the shard packing decided when that mattered

CI red on windows-latest shard 1/3 only:

  "gen-exit-code-registry: CLI" > "a --write run redirected to a tmpdir leaves
  every committed artifact untouched"
  AssertionError: hooks artifact must be untouched

The Linux runner passed the same sha at 40425/40425. It is Linux-only, so a
Windows-scheduling defect is structurally invisible to it.

Root cause, established by measurement rather than inference.

tests/cli-exit.test.cjs appended a corruption marker to the REAL COMMITTED
hooks/lib/exit-code-registry.js, held it corrupted across a full subprocess, and
restored it in a finally. tests/exit-code-registry.test.cjs reads that same real
file before and after its own subprocess and asserts byte equality. If it samples
while the other test holds the file corrupted, it fails. The landmine is
pre-existing, from 2ea5efc15 (#3911).

What this branch changed is WHEN the two run together. scripts/run-tests.cjs
shards by cost-weighted LPT over the sorted unit list, so adding three test files
repacks the bins:

  merge-base c3e667df3 (838 files): cli-exit -> shard 1, exit-code-registry -> shard 3
  HEAD       03b342601 (841 files): BOTH in shard 1, same argv chunk, one
                                    node --test process, concurrent

Co-location is necessary but not sufficient - Linux shard 1/3 also had both and
passed. Windows loses because TEST_CONCURRENCY defaults to 2 there against 4
elsewhere, spawn cost is ~10x, and the sibling corruptor holds one of only two
slots through a ~90s tsc compile. That turns a sub-second overlap into seconds.

Not a path-separator or case-sensitivity issue, and not CRLF - .gitattributes
pins * text=auto eol=lf. Redirection was not at fault either: ensureScriptsOut
derives all five --out flags correctly and gen-exit-code-registry.cjs honours
them with no __dirname escape.

Fixed at the cause: no test writes to a committed file any more. Both corruptors
now copy to a mkdtempSync tmpdir, corrupt the COPY, and point the generator at
it. Repinning or reordering the shards would have turned CI green while leaving
the landmine armed for the next reshuffle.

That required closing an inconsistency between two sibling generators.
gen-exit-code-registry.cjs already accepts
--out/--scripts-out/--hooks-out/--dts-out/--sh-out and honours them under
--check; gen-hooks-cli-exit.cjs hardcoded OUTPUT_PATH and had no flag surface at
all, so its corruptor could not be redirected anywhere. It now takes --out in the
same style, honoured by both --write and --check, and is a no-op when absent -
verified: a bare --check on the default path still exits 0.

ensureScriptsOut moved to tests/helpers/exit-code-artifact-flags.cjs and both
test files import it. Hand-rolling a second copy of the flag derivation would
have been a re-derivation of exactly the kind this PR ships a guard against.

Verified: both tests still detect corruption (proven by defeating the check and
watching them red, with a positive control showing an uncorrupted copy exits 0);
SHA-256 of hooks/lib/exit-code-registry.js and hooks/lib/cli-exit.js identical
before and after running both rewritten bodies, and git reports nothing under
hooks/ modified - that is the property that was violated. A repo-wide search for
the corrupt-then-restore-in-finally shape against hooks/ found no other
instances.

One detail worth recording: the tmpdir test keeps --declaration pointed at the
real committed declaration rather than copying it, because the generated banner
embeds path.relative(REPO_ROOT, declarationPath) - copying it would produce a
false drift unrelated to the injected corruption. The declaration is read-only on
that path and never written.

Refs #3987

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

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 14:40:02 -04:00
Tom Boucher
4d151e46b6 fix(#3795): read the interrupted agent id before clearing the stale marker (#4006)
* test(#3795): the interrupted-agent read must precede the stale-id clear

* fix(#3795): read the interrupted agent id before clearing the stale marker

execute-plan's init_agent_tracking step ran `rm -f
.planning/current-agent-id.txt` BEFORE the existence check that read it,
so the interrupted-agent branch and the Task resume prompt it exists to
offer were unreachable (#3795) — a kill -9 mid-executor left the file,
and the next run deleted it before looking. The read now precedes the
clear; fresh-run semantics (no stale id leaking into the new spawn) are
preserved. A structural guard pins the order.

Emitted-Drift-Ack-Growth: execute-plan.md — #3795: +bytes from reordering the interrupted-agent read before the rm plus the explaining comment

* chore(#3795): changeset fragment (pr number backfilled after PR creation)

* chore(#3795): backfill changeset PR number (4006)

---------

Co-authored-by: sim <sim@local>
2026-08-28 13:50:37 -04:00
Tom Boucher
dd4f179672 feat(#3970): per-task external-tracker content-resolution seam (#4000)
* feat(#3970): per-task external-tracker content-resolution seam

Implements ADR-3646 (Phase 1, #3970): a `<task tracker-id="...">` attribute
plus a new optional `taskContentResolver` capability-manifest field let a
capability resolve a task's action/verify/acceptance-criteria/read_first/done
content from an external issue tracker instead of PLAN.md's inline body.

- src/plan-document.cts: parses the `tracker-id` attribute into `PlanTask.trackerId`
- src/task-content-resolution.cts: new leaf module — split/find/build/resolve,
  with a hard-halt (throw) contract on ambiguous/failed/timeout/malformed
  resolution, never a silent fallback to possibly-stale inline text
- src/task-command-router.cts: new `task resolve-content --plan --task-id --raw`
  CLI verb wiring the module into a real process exit code
- gsd-core/bin/lib/capability-validator.cjs: validates the new
  `taskContentResolver` manifest field (feature-role only, cross-capability
  trackerPrefix uniqueness)
- gsd-core/workflows/execute-plan.md, gsd-core/references/loop-hook-dispatch.md,
  docs/reference/capability-manifest.md: wire the seam into the per-task loop
  and document it as a new `execute:task` point outside the existing
  contribution/step/gate vocabulary (unconditional in autonomous mode)

Closes #3970

* fix(#3970): gate checkpoint tasks out of content resolution, close trackerPrefix grammar parity gap, cover path-traversal guard

Standards/Spec code-review pass on the task-content-resolution seam (ADR-3646
Phase 1) found three defects:

1. execute-plan.md's task-content-resolution bullet fired on any
   tracker-id-bearing task with no check that it wasn't type="checkpoint:*",
   contradicting ADR-3646 Decision 1 (a checkpoint task must never enter
   resolve-content). plan-document.cts already parses trackerId: null
   unconditionally for checkpoint tasks; only the workflow prose needed the
   fix, so the bullet now explicitly excludes checkpoint tasks.

2. task-content-resolution.cts's parseResolverDeclaration accepted any
   non-empty trackerPrefix with no grammar check, while capability-
   validator.cjs's KEBAB_RE enforces kebab-case at install time — a
   Generative Fix Divergence gap. Added the same grammar (as a literal
   regex, documented as intentionally not shared across the .cts/.cjs build
   boundary) plus a parity test asserting the two surfaces agree across a
   valid/invalid trackerPrefix table.

3. task-command-router.cts's routeResolveContent path-traversal guard on
   --plan had zero test coverage. Added a test exercising a
   ../../../etc/passwit-shaped path and asserting the USAGE rejection names
   the offending path.

* fix(#3970): sanitize resolver diagnostics and cap resolver timeoutMs

Two findings caught by an isolated security-review pass on the task
content resolution seam:

- ResolverFailedError/ResolverMalformedOutputError embedded raw,
  unsanitized subprocess stderr/stdout (attacker/model-influenced via
  the tracker-id argv token) into .message. A hostile or buggy resolver
  could smuggle a newline plus a forged "Error: " line, or terminal
  escape sequences, into a diagnostic io.cjs's error() writes verbatim
  to stderr. Fixed at the constructor (task-content-resolution.cts) via
  io.cjs's existing formatDiagnosticToken(), so every caller of
  resolveTaskContent gets a safe .message by construction.

- capability-validator.cjs's validateTaskContentResolverFields had no
  upper bound on taskContentResolver.invoke.timeoutMs, letting a
  manifest declare an effectively unbounded value and defeat the
  "bounded subprocess" design intent. Added a 120000ms ceiling specific
  to this field, without touching the shared isPositiveIntegerMs()
  helper (still used unbounded by the reviewer lane's timeoutFloorMs
  and probe timeoutMs).

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

* fix(#3970): fix gsd-test failures — stale prose allowlist line and stderr-vs-message assertion

gsd-test (remote dockerized matrix) came back red with 5 failures on this
PR; all five are real defects, fixed here.

- tests/no-bare-gsd-tools-command-position.test.cjs: PROSE_ALLOWLIST's
  execute-plan.md entry pointed at line 415, which ffc190df4's
  checkpoint-exclusion caveat (added near line 221) shifted down by one
  line. The actual "validated downstream by gsd-tools uat
  classify-coverage" descriptive mention now sits at line 416. Updated
  the allowlist entry's line number to match.

- tests/task-command-router-resolve-content.test.cjs: the path-traversal
  test asserted the outside-project-scope diagnostic against the thrown
  ExitError's own .message. io.cts's error() (ADR-3889) writes its
  human-readable message to fd 2 via writeAllSync and then throws a bare
  `new ExitError(1)` with no message argument — by design, so the
  exception carries no duplicate text and the thrown ExitError's message
  defaults to "process exit 1" (cli-exit.cts's ExitError constructor).
  Root cause was the test, not the source: task-command-router.cjs's
  outside-project-scope rejection already calls error() correctly and the
  diagnostic text is genuinely emitted, just on fd 2, not on the
  exception. Fixed the test to capture fd-2 writes (mirroring
  tests/estimate-calibrate.test.cjs's runCalibrateExpectError and this
  same file's own captureStdout for fd 1) and assert against the captured
  stderr text instead of err.message. This was masked locally because a
  manual `node -e` sanity check that only inspects the caught exception's
  .message cannot see what the real node:test run actually failed on.

Emitted-Drift-Ack-Growth: execute-plan.md — adds the ADR-3646 task-content-resolution bullet and checkpoint-exclusion caveat to the per-task execute loop; a real behavioral prose addition, not incidental bloat.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(#3970): backfill changeset PR number (pr:0 -> pr:4000)

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 13:17:04 -04:00
Tom Boucher
12f9d1d9a0 enhance(#3913): docs, and the guards come down (#3994)
ADR-3889 terminal phase. Generated docs/reference/exit-codes.md from the exit-code declaration with a --check drift arm; deleted the inert soft-error-exit-zero oracle; promoted untyped-success from SMELL to VIOLATION so it can fail a build; pruned all 5 smell-baseline entries.

Fixed inline: two mis-scoped oracles (routing-validity, value-hygiene), a second source behind the band table, unescaped declaration strings reaching Markdown, and a pre-existing Windows 8.3 short-name path-comparison defect.

Guard ledger corrected from a claimed net -4 to a measured net -1.

Closes #3913
2026-08-28 13:10:20 -04:00
Tom Boucher
c3e667df34 fix(#3786): authorize mutable scope from live observation only in the quick planner (#4005)
* test(#3786): the quick planner constraints must carry a mutable-scope authority rule

* fix(#3786): authorize mutable scope from live observation only in the quick planner

The quick planner could commit HISTORICAL scope as authorized edit or
verification scope before any live observation of the mutable state: a
minimized probe used cached PR-diff paths (65 of them, "pending
replacement") or broadened verification to the PR integration surface in
2 of 3 trials (#3786). One explicit authority requirement reduced that
to 0 of 3.

The new planner constraint authorizes mutable-state scope ONLY from a
live observation made at planning time (for conflict resolution, the
fresh merge index via git diff --name-only --diff-filter=U) or keeps
files/verify CONDITIONAL on it; historical STATE.md entries, recovery
notes, and cached PR/base diffs may guide investigation only. A
structural guard pins the rule in the shipped constraints block.

Emitted-Drift-Ack-Growth: quick.md — #3786: +620 bytes — one MUTABLE-SCOPE AUTHORITY constraint bullet added to the planner <constraints> block

* chore(#3786): changeset fragment (pr number backfilled after PR creation)

* chore(#3786): backfill changeset PR number (4005)

---------

Co-authored-by: sim <sim@local>
2026-08-28 12:31:02 -04:00
Tom Boucher
c8f08b61fb fix(#3782): segment verification debt by the archived_milestone stamp in progress step 1.6 (#4001)
* test(#3782): step 1.6 must segment verification debt by the archived stamp

* fix(#3782): segment verification debt by the archived_milestone stamp in progress step 1.6

progress.md step 1.6 read the cross-population summary.total_items as
current-milestone debt while claiming the whole query respects milestone
boundaries. The active tree is milestone-filtered; archived trees are
deliberately unfiltered (each result stamped archived_milestone), so a
healthy current milestone presented six shipped milestones' still-open
items as CURRENT debt on every /gsd-progress run (#3782).

Step 1.6 now computes CURRENT_DEBT/ARCHIVED_DEBT via jq selects on the
stamp, tracks archived_debt separately (visible with its own labeled
header — segmented, never hidden), corrects the scoping claim, and
unwraps the CLI's @file: large-payload redirect before jq so counters
cannot silently read 0. A structural guard test pins the segmentation.
parse_gap_files stays deliberately cross-population.

Emitted-Drift-Ack-Growth: progress.md — #3782: +1406 bytes — step 1.6 gains the CURRENT_DEBT/ARCHIVED_DEBT segmentation jq, the @file: unwrap, and the archived-visibility paragraph; parse-gap paragraph reworded to match

* chore(#3782): changeset fragment (pr number backfilled after PR creation)

* chore(#3782): backfill changeset PR number (4001)

---------

Co-authored-by: sim <sim@local>
2026-08-28 11:40:34 -04:00
Tom Boucher
2012e8cc7f fix(#3781): span-carrying heading walk unblocks acknowledge on heading-shaped deferred items (#3998)
* test(#3781): heading-shaped deferred entries must be acknowledgeable

* fix(#3781): span-carrying heading walk unblocks acknowledge on heading-shaped deferred items

* test(#3781): table fixture counts the row; BLOCKER 1 updated to the supported contract

* chore(#3781): changeset fragment (pr number backfilled after PR creation)

* chore(#3781): backfill changeset PR number (3998)

---------

Co-authored-by: sim <sim@local>
2026-08-28 10:21:57 -04:00
Tom Boucher
b351c83e03 docs(adr): ADR-3646 — per-task external-tracker content-resolution seam (#3991)
Resolves the four conditions on the approved-feature verdict for #3646:
new execute:task granularity tier below wave, hard-halt enforced via a
code-side resolver seam (Lens B) rather than prose dispatch (since #3647's
dispatch-reliability defect is still open), registration/validation
requirements for loop-hook-dispatch.md + capability-validator.cjs, and
autonomous-mode behavior via a new non-gate kind.

Closes #3969

Co-authored-by: sim <sim@local>
2026-08-28 09:32:37 -04:00
Tom Boucher
cf3eb84b3f fix(tests): stop racing spawnSync's documented timeout boundary in row 24 (#3988)
emitted-ack-trailer.test.cjs's "git log is bounded by a timeout" row asserted
readAckTrailers throws on timeoutMs: 1 against a real git call. Empirically
confirmed (5 direct probes against the exact gsd-tester image gsd-test uses)
that a trivial git merge-base can complete inside that 1ms window ~1-in-5
runs, landing on process-seam.cjs's own documented "child finished right at
the deadline" boundary case (status populated alongside error.code ===
ETIMEDOUT) -- which the seam correctly classifies as EXITED, not TIMED_OUT.
readAckTrailers, ackTrailerGit, and process-seam.cjs are all already correct;
only the test's mechanism for forcing a timeout was racy.

Replaces the real-timing race with a deterministic PATH-shim `git` that
idles via the same non-blocking setTimeout sleeper pattern already
established in tests/process-seam.test.cjs, given a 100x margin (5000ms
sleep vs 50ms bound) so the race is eliminated by construction.

Discovered while verifying an unrelated docs-only PR (#3969 / ADR-3646 for
#3646) -- this test's failure blocked gsd-test's push gate for every branch
cut from next, independent of diff content.

Closes #3985

Co-authored-by: sim <sim@local>
2026-08-28 09:18:57 -04:00
Tom Boucher
9f1996b8f9 fix(#3775): ack matches exactly the status-line case shapes the reader reads back (#3989)
* test(#3775): bare Title-case status lines must ack through the reader-visible path

* fix(#3775): match exactly the status-line case shapes the reader reads back

* chore(#3775): changeset fragment (pr number backfilled after PR creation)

* chore(#3775): backfill changeset PR number (3989)

---------

Co-authored-by: sim <sim@local>
2026-08-28 08:59:55 -04:00
Tom Boucher
3a6c0412a9 enhance(#3624): local/no-exact-case-env-access — ratchet ADR-1703 onto production env reads (epic #3411 Phase 4) (#3976)
* enhance(#3624): local/no-exact-case-env-access — ratchet ADR-1703 onto production env reads (epic #3411 Phase 4)

Extends ADR-1703's portability rule catalog with a second production-runtime
rule: it flags an exact-case read of a Windows case-varying environment
variable (PATH, PATHEXT, ComSpec, USERPROFILE, TEMP, TMP, APPDATA) off any
receiver that is not process.env itself, matched via an env-shaped-receiver
check to avoid colliding with ordinary `.path`-named properties elsewhere in
the tree.

Exports the seam's private `_envGet` as `envGet` so the rule's remediation
message names a real helper, and fixes the one pre-existing violation the
tightened rule found (`src/runtime-hooks-surface.cts`'s `env.APPDATA` read).

Closes #3624

* fix(#3624): extractStaticName recognizes non-computed Literal destructuring keys; add missing accessor-call test case

Review findings from the code-review + isolated-adversarial passes:
- extractStaticName only matched non-computed Identifier keys, so a
  destructuring like `const { 'PATH': v } = opts.env;` (the issue's own I8
  acceptance case) silently evaded the rule. Widened to accept a Literal key
  regardless of computed, which is safe for MemberExpression too (its
  non-computed property is always an Identifier by grammar).
- Added the missing RuleTester valid case for "a case-insensitive accessor
  call" (envGet(env, 'PATH')) from the issue's Done-when checklist.

* docs: backfill changeset PR number for #3624 (PR #3976)

---------

Co-authored-by: sim <sim@local>
2026-08-28 08:49:03 -04:00
Tom Boucher
bb4f3073c0 docs(#3984): give every §8 rule its delivered status and its measured executor (#3986)
Surfaced by an /adr-phase-coverage audit of epic #3473 after its last sub-issue
merged. §8 is the epic's declared source of truth - "where this section and the
code disagree, the code is the defect" - and line 136 defines a per-rule status
vocabulary.

Not one of the nine rules was ever advanced past its pre-implementation status.
The word Enforced appeared exactly ONCE in the document: in the sentence that
defines it. Three rules read "Required - phase unassigned" for work that Phases
5, 7 and 8 demonstrably delivered, so a reader auditing coverage today would
conclude three of the nine contract rules had no owner. That is the orphan shape
this epic exists to eliminate, sitting in the epic's own contract, and it is the
same defect class as B6's guard ledger: a status column is a claim, and it was
false.

Flipping all nine to Enforced would have repeated the mistake inverted. Decision
2 says a declared policy with no executor is a loud failure, so Enforced asserts
an executor EXISTS - and measurement found the nine are not equally enforced. The
vocabulary is now three values:

  Enforced              a standing guard wired into lint:ci or CI fails the
                        build; a new wrong call site cannot land
  Enforced (structural) the wrong call site is unrepresentable
  Shipped - test-covered  delivered and regression-tested, but NO standing
                        guard; a regression in covered code is caught, a new
                        wrong call site elsewhere is not

Measured per rule, with the executor cited:

  8.1 Enforced             lint-vendored-deps + no-external-require-in-bin
  8.2 Enforced (partial)   phase-enumeration-drift covers the sentinel arm; the
                           #3357 resolver arm is test-covered only
  8.3 Shipped              NO guard for slug or marker re-derivation
  8.4 Shipped              structural for argv; no guard for the general rule
  8.5 Shipped              the delivering change added zero guard scripts
  8.6 Enforced (structural) the transaction type; state-write-path-drift
  8.7 Shipped              structural via reconcileReportedFields; no guard
  8.8 Enforced             gen-state-md-docs --check in lint:generated-sync
  8.9 Enforced (prospective) lint-fix-has-regression-tests fires on NEW fix
                           commits; it does not assert the 19 children are
                           covered. 17 of 19 have a test citing their number;
                           #3364 and #3812 have none - a text match, not proof
                           the behavior is uncovered, but not evidence it is
                           covered either

The third status value is not a euphemism for done. It names precisely where this
epic's own thesis - make the wrong call site unrepresentable - is NOT yet
achieved. §8.3 and §8.5 are the thinnest: nothing today stops a twelfth inline
slug copy or a second silent swallow.

Also corrects a claim this ADR made three days ago and #3977 has since overtaken.
The ledger amendment concluded #3426/#3239 "need new detectors". The measurement
behind that was right - the glob widening structurally could not reach them - but
the prescription was not the best available: #3977 rerouted the test's parsers
onto the ADR-2143 seam, so there is no ad-hoc parse left for a detector to find.
Removing the violation beat teaching the rule to see it. Both issues are CLOSED;
the roster row and the amendment now say so, and say why the earlier conclusion
should not be built on.

Closes #3984

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:20:58 -04:00
Tom Boucher
4f32209f78 enhance(#3267): reduce handleEvaluate complexity below refactor-trigger's own threshold (#3978)
* fix(#3267): reduce handleEvaluate complexity below refactor-trigger's own threshold

handleEvaluate scored 26 (then 21 after later #3261 commits) against the
complexity-triggered-refactor feature's own default threshold of 15. Extracts
the read-and-analyze loop (analyzeTouchedFiles) and the artifact/baseline/
ledger write path (finalizeEvaluation) into named helpers, per the issue's
suggested direction. Behavior-preserving: every existing test in
tests/refactor-trigger-cli.test.cjs is unchanged, and every degrade-path
reason code (REFACTOR_INVALID_PHASE, REFACTOR_GIT_UNAVAILABLE,
REFACTOR_NO_TOUCHED_FILES, REFACTOR_FILE_UNREADABLE,
REFACTOR_ANALYZER_UNSUPPORTED, REFACTOR_ANALYZER_UNPARSEABLE,
REFACTOR_BASELINE_WRITE_FAILED, REFACTOR_STRICT_NOT_ENFORCING) keeps its
current value and emission path.

The four complexity-trigger.cts lexer functions (scanFunctions,
stripLiterals, skipTypeExpr, skipGenericParamList) are deliberately left
untouched, per ADR-1953 D5 — they are a hand-rolled lexer state machine,
densely branchy by construction, and refactoring them to lower the metric
would be exactly the "split a coherent function to satisfy a metric"
behavior D5 exists to prevent.

Closes #3267

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

* docs: backfill changeset PR number for #3978

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-08-28 08:12:27 -04:00
Tom Boucher
d24e22b156 enhance(#3912): gsd-tools declares outcomes, pinned at v1 (#3983)
* enhance(#3912): gsd-tools declares outcomes, pinned at v1

ADR-3889 §4. Phase 6 already moved error()'s terminator onto the seam, so what
remained was the declaration — and the pin that makes it invisible today.

The census corrected two documented figures before any code changed.
ERROR_REASON has exactly 25 members (the ADR and epic were right; an earlier
note of mine claiming 23 was wrong and is corrected). And output({error}) is
**64 sites across 9 files, not the 60 ADR-2980 ratified** — the module shape
holds but the total drifted +4: frontmatter 7 not 6, phase 4 not 2, roadmap 3
not 2. That matters because this phase's criterion demands the pin be asserted
over the enumerated population rather than sampled; asserting over a stale 60
would leave four sites unpinned while claiming full coverage, which is the
shape of failure this epic exists to remove.

The issue does not state the fact that shapes the design: output() never
touches the exit code. Confirmed by reading it — it writes fd 1 and returns.
So a declared outcome for those 64 sites had nowhere to be READ. The mapping
was never the work; wiring somewhere for the declaration to land was.

The seam already existed twice over. cli-exit.cts holds two globalThis-Symbol
cells, each because the module is emitted to three locations and a module-level
`let` would let instances disagree, and runMain already maps a code returned by
main(). A third cell inherits that solution. output() records DEGRADED for any
{error} payload — key-order agnostic, which is exactly why the "42 sites"
figure undercounts — and runMain projects the cell only when main() returns
nothing, so an explicit return still wins.

error() maps its reason through a table over the closed 25-member enum, leaving
all 278 call sites untouched; 226 of them pass no reason at all. The version
gate lives in error(), NOT in projectOutcome: registered names are
version-invariant there, so mapping a reason straight through would make USAGE
project to 64 under v1 and break the pin on its first line. projectOutcome is
left exactly as Phase 2 shipped it, DEGRADED's 0/80 asymmetry included.

Proven rather than asserted. v1 is byte-identical across three real CLI paths —
config-get plain, config-get --json-errors, and an output({error}) path —
matching exit code and exact bytes against the pre-change build. Under
GSD_EXIT_CONTRACT=v2 the same commands now exit 66 (CONFIG_KEY_NOT_FOUND ->
NO_INPUT) and 80 (DEGRADED), both looked up through the registry. An
anti-vacuity test pins that v1 and v2 genuinely differ for at least one reason,
because without it a mapping where everything projects to 1 under both versions
would satisfy every other assertion and the declaration would be theatre.

A1 iterates all 25 enum members and A3 asserts over the measured 64-site
population, so a 26th reason or a 65th site fails until it is given a mapping —
the drift guard this phase needs, given ADR-2980's own count had drifted +4
unnoticed.

Verification runs on the remote runner.

Refs #3912

* fix(#3912): the outcome cell must never lower an exit code

The remote run caught a fail-open that this phase introduced, in the phase
whose entire purpose is removing fail-opens.

`state validate --strict` on a missing STATE.md exited **0** where it must exit
1. Mechanism: `runMain` projected the pending outcome whenever `main()` returned
void, and under v1 DEGRADED projects to 0 — so a `process.exitCode` already set
non-zero by the command was clobbered down to success. Confirmed live against a
fixture, before and after.

This refutes a review conclusion recorded earlier in this phase, that the cell
was "fail-closed and can never mask a failure as success". It could, and did.
Recording that plainly so the assumption is not repeated: the cell's danger was
never only that it might add a failure — it was that projecting it
unconditionally overwrites whatever decision came before.

Projection is now guarded: it may set a code only when none is set, and an
already-non-zero exit code always wins. The full precedence — explicit `main()`
return, then an existing non-zero exitCode, then the declared outcome — is
written at the projection site. A regression test drives a void return with a
pre-set non-zero code and a pending DEGRADED, and fails against the pre-fix
build.

The second failure was my test encoding the wrong contract, not a code defect.
It asserted `output({found:false, error: undefined})` records DEGRADED because
the KEY is present. `JSON.stringify` drops undefined, so the payload the user
receives is `{"found":false}` — carrying no error at all, and calling that
degraded would hand back exit 80 under v2 for output that reads as clean. The
discriminator is a serializable error VALUE, not key presence. The test now
pins `{error: undefined}` as explicitly NOT degraded, and the design doc's
wording is tightened to match.

Verification runs on the remote runner.

Refs #3912

* docs(#3912): the versioned exit contract, and a flag defect the docs found

Diataxis pass for Phase 8, plus a real fix that only surfaced because writing
the how-to meant running its own examples.

The docs. ADR-2980's "Revisit if" clause asked for exactly the versioned
projection this phase provides, so it gets an amendment naming #3912 /
ADR-3889 section 4 as that boundary: v1 stays 0 byte-for-byte, v2 projects
DEGRADED to 80. The amendment also records the count drift rather than
restating a stale figure — the ADR ratified 60 output({error}) sites in 9
modules; the AST-measured population is 64 across the same 9 (frontmatter 7
not 6, phase 4 not 2, roadmap 3 not 2). The pin is asserted over the
enumerated 64. json-errors.md gains the outcome-declaration reference,
including the precedence order a review pass got wrong and the suite refuted:
an explicit main() return, then an already-set non-zero process.exitCode, then
the declared outcome. Projection may only ever set a code, never lower one.

A how-to is owed here and is written, not skipped. Under v1 nothing changes,
so the audience is an operator opting into v2 and needing to know what the
codes mean for a CI gate — a migration, which is how-to shaped. It covers
turning v2 on, the code table, why 80 is "ran and reported a condition" rather
than a crash, and how to split a gate that treats any non-zero as fatal. No
tutorial: there is no new entry point to learn, and under the default contract
a reader would be walked through observing nothing.

The defect. Running the how-to's own Step 1 example returned

    $ gsd-tools --exit-contract=v2 state validate --strict
    Error: Unknown command: --exit-contract=v2          (exit 64)

while the same flag trailing the subcommand worked and exited 80. The flag
half-worked, by argv position. resolveContractVersion scans argv
non-destructively, so the token survived into the dispatcher, which treats
argv[2] as the command name. --json-errors had already solved precisely this
at gsd-tools.cjs:4455, under a comment naming the hazard verbatim: "The argv
splice must happen here too, otherwise the dispatcher below sees
--json-errors as an unknown command." The later flag never got the same
treatment.

Fixed rather than documented around: the version is resolved first — which
memoizes the cell and makes an invalid value throw early — and then every
--exit-contract= token is spliced out of the dispatcher's argv copy.
--exit-contract is now listed in TOP_LEVEL_USAGE, where it never was. The
regression test pins leading position, trailing position, agreement between
the two, and a loud failure on v3 rather than a silent fall back to v1.

Neither review engine would have caught this: the defect is invisible in the
diff, because the diff does not touch argv handling. It surfaced only from
running the documentation's own example. Writing a how-to is an execution pass.

Verification runs on the remote runner.

Refs #3912

* fix(#3912): the flag splice has to run before the run-with-timeout return

An isolated review of the previous commit found that the fix did not deliver
what it claimed, and that two of its own tests were weak. All three findings
reproduced by execution before any change was made.

The fix was placed below a return. main() intercepts `run-with-timeout` at
gsd-tools.cjs:4436 and returns from there — above both the --json-errors block
and the --exit-contract splice added in the previous commit. So the flag still
died in leading position for that one command:

    $ gsd-tools --exit-contract=v2 run-with-timeout 5 -- node -e "..."
    Error: Unknown command: run-with-timeout        (exit 64, child never ran)

The previous commit message and the test's describe-block both claimed
position-independence unconditionally. That was an overclaim, not a gap left
open, and it is the part worth naming: the fix was verified by hand on the
commands I happened to think of, and `run-with-timeout` returns before the
code I was verifying.

Both global-flag blocks now run above the interception, with a comment naming
it so a later edit cannot slide them back down. Moving --json-errors up fixes
the identical pre-existing bug for that flag, verified failing beforehand
(exit 1, sdk_unknown_command). Fixing the sibling is deliberate: same defect,
same block, and a known-broken twin next to a fixed one is not a resting state.

Two tests were not pulling their weight. The invalid-value test was vacuous —
it passed against the pre-fix build, because `--exit-contract=v3` already
exited 1 there and already printed the resolve error lazily through
error() -> getContractVersion. Both its assertions held before the fix, so it
pinned nothing. The real discriminator is that the pre-fix build emits BOTH
"Unknown command: --exit-contract=v3" and the resolve error, while the fixed
build emits only the latter; the test now asserts that absence.

The leading-position and leading==trailing tests asserted proxies — "not 64",
"no Unknown command", "the two agree" — none of which pin a value, and all of
which would survive both positions being identically broken. With a .planning
directory and no STATE.md, state-snapshot exits exactly 80 under v2 and 0
under v1 in both positions. Those numbers are pinned now. The multi-token case
the descending splice loop exists for is covered too, and run-with-timeout has
regression tests for both flags.

The lesson is narrower than "test more". Hand-verifying the production
behavior does not verify that the test would have caught its absence. The
pre-fix binary has to be run against the test's own assertions.

Investigated and deliberately not changed: splicing before --cwd parsing
degrades one diagnostic from "Missing value for --cwd" to "Invalid --cwd:
<path>", but that is pre-existing — verified on the pre-fix build via
--json-errors, which already did it. This change joins the pattern rather than
creating it, and both forms exit 64 on malformed input either way.

Verification runs on the remote runner.

Refs #3912

* chore(#3912): backfill changeset pr numbers to 3983

* test(#3912): pin the reason-table invariant as set equality, not a count

A graph-backed review flagged the unchecked lookup in
expectedErrorCode3912. Investigated by execution: the drift guard DOES
hold — for an unmapped reason under v2 the production error() yields 1
while the table yields undefined, so the assertion fails. Not a
correctness defect, and deliberately NOT made tolerant, since a tolerant
lookup would destroy the guard.

Two real problems remained. The guard asserted the wrong invariant: it
counted the TABLE's keys at 25 rather than checking they match the
ENUM's values, so a renamed member keeps the count at 25 and slips past,
and a 26th member leaves the table at 25 and slips past too. Both were
then caught only indirectly, by an undefined mismatch producing 'must
exit undefined'. It is now a sorted set equality, so the failure names
the specific missing or extra reason.

And the comment above it described a '?? FAIL' fallback that does not
exist anywhere in the function. It now states what the code actually
does, verified by running it rather than by reading it.

Refs #3912

---------

Co-authored-by: sim <sim@local>
2026-08-28 08:09:05 -04:00
Tom Boucher
7adcb730e5 test(#3239): reroute package-legitimacy-gate table parsers onto the ADR-2143 seam (#3977)
* test(#3239): reroute package-legitimacy-gate table parsers onto the ADR-2143 seam

tests/package-legitimacy-gate.test.cjs hand-rolled two markdown-table
parsers that duplicated src/markdown-table.cts (ADR-2143). The local
.split('|') mis-split escaped-pipe cells, and the local
.filter(cells => cells.length === headers.length) silently dropped
ragged rows instead of failing loudly — a legitimacy gate silently
skipping a malformed row it could not fully read.

Both call sites now delegate to the seam's parseMarkdownTable (via
gsd-core/bin/lib/markdown-table.cjs), addressing cells by column name.
The multi-table grouping loop (no ADR-2143 equivalent) stays local but
now delegates each group's actual parse to the seam and throws loudly
on a malformed group instead of filtering it out silently.

Adds fixtures locking the two named divergences (ragged row -> typed
error, escaped-pipe cell -> correct single-cell split) plus the
grouping helper's happy-path and fail-loud behavior.

The tests/**/*.cjs fence question (issue proposed-work item 3) was
already decided and shipped by #3951 Rung B: local/no-adhoc-markdown-parsing
is registered as 'error' on tests/**/*.cjs in eslint.config.mjs and the
rule's own filename gate matches it. No further fence decision needed.

* chore(#3239): changeset fragment (pr number backfilled after PR creation)

* chore(#3239): backfill changeset PR number (3977)

---------

Co-authored-by: sim <sim@local>
2026-08-28 08:07:24 -04:00
Tom Boucher
355c943b08 enhance(#3626): make CONTEXT.md seam claims checkable via a SEAM.*.enforced-by gate (#3975)
* feat(#3626): make CONTEXT.md seam claims checkable via SEAM.*.enforced-by gate

Adds SEAM.<id>.owns / SEAM.<id>.enforced-by=lint-rule:<name>|test:<path>
predicates to CONTEXT.md, generalizing the existing WORKTREE.SEAM.* shape,
plus scripts/lint-seam-enforcement.cjs (wired into lint:ci) which fails
when a declared single-owner seam names no existing, registered
enforcement mechanism. Backs all six current module-level single-seam/
single-canonical-owner claims found in CONTEXT.md, including the Shell
Command Projection Module's Windows-binary-resolution claim via #3619's
local/no-private-binary-resolution rule.

Scope is resolves-only per maintainer decision: the gate proves an
enforcement pointer exists and is registered, not that its surface
covers every file the seam claims. See docs/adr/3626-context-md-seam-claim-gate.md.

Closes #3626

* fix(#3626): back the Package Identity Module's seam claim too

Isolated adversarial review caught a miss in the "no grandfather list"
sweep: the Package Identity Module also declares itself "Single seam
owning GSD's published-package coordinates" and already names its real
enforcement (scripts/lint-package-identity-drift.cjs). Backs it with
SEAM.package-identity.owns/enforced-by=test:tests/package-identity.test.cjs,
bringing the total to 7 backed seams. Also makes explicit, in the design
doc and ADR, that function-level "single owner" sentences inside
already-covered modules (STATE.md Document Module, etc.) are deliberately
out of scope — a seam claim is about a module's boundary, not every
function inside it.

* docs(#3626): backfill changeset PR number

---------

Co-authored-by: sim <sim@local>
2026-08-28 08:06:41 -04:00
Tom Boucher
d98b55562c enhance(#3910): the raw terminator is banned by construction (#3980)
* enhance(#3910): move the last src/ terminators onto the seam

Phase 6 bans the raw terminator by construction, which it cannot do while
violations stand. A census found 12 sites the rule would flag; nine of the ten
unsanctioned ones were owned by no phase of the epic at all — a coverage hole
in the decomposition, since P0-P2 are infra, P3 the gate modules, P4 the
scanners, P5 the fragments, P7 the hooks, P8 io.cts, and P6 itself only adds
the rule. `src/**/*.cts` now holds exactly 2 raw exits, both inside
`terminateNow`, the single sanctioned site.

`io.cts`'s `error()` is the interesting one. It was first called substantive on
"dozens of callers, contract risk" — asserted, not measured, and the
measurement refuted it: 289 call sites, zero inside a try whose catch would
swallow a throw. The real obstacle was structural instead: `terminateNow`
cannot emit exit 1, because ADR-3889 §1 makes 0 and 1 unallocatable and
`nameForExitCode(1)` throws. So the only route is `ExitError` under `runMain`,
which sets exitCode and writes stderr only when the error carries a user
message — keeping the existing stderr write and throwing a message-less
ExitError is observably identical.

That census was still too narrow, and running the CLI proved it. It asked
whether the CALL sits in a try/catch; the two regressions that surfaced were
interceptors elsewhere on the stack:

- `command-routing-hub.cts`'s `dispatch()` swallowed the ExitError into a
  HandlerFailure, so the caller emitted a duplicated, wrong stderr line on
  every Hub-routed path. It now rethrows ExitError explicitly — the same shape
  `gsd-tools.cjs` already used at two dispatch sites, so this follows an
  established idiom rather than inventing one.
- the profile-pipeline router's deliberately un-awaited `.catch(e => error(...))`
  turned an ExitError rejection into an uncaught exception; it now mirrors
  runMain's handling.

`edge-probe` and `ui-consideration-probe` gained `runMain` wrappers because
probe-core's new throwing default would otherwise have escaped them.

A follow-up sweep of every dispatcher — 19 command routers, the Hub, the
gsd-tools dispatch seams — found no further swallowing catch. The admitted
bound: ~1260 non-rethrowing catches repo-wide were scanned structurally but not
individually classified. Both real regressions were found by execution, not by
reading, so the suite is the detector that matters here.

`gsd-tools.cjs:253` stays a raw exit deliberately: it is the ensureRuntimeBuild
bootstrap, which runs before cli-exit is required, so the seam does not yet
exist. It needs a second allowlist entry, which means #3910's "single allowlist
entry" criterion is unachievable as written.

Verification runs on the remote runner.

Refs #3910

* enhance(#3910): ban the raw terminator by construction

Adds local/require-registered-exit and registers it on all four globs:
src/**/*.cts, scripts/**/*.cjs, hooks/**/*.js, gsd-core/bin/**/*.cjs.

Registering on the .cts glob is load-bearing, not redundant — the emitted .cjs
mirrors are globally eslint-ignored, so a rule registered only on the emitted
globs is blind to the sources. That is the #3496 lesson, and it is how the
previous guard became invisible: n/no-process-exit was 'error' in one block yet
fired zero times on all three surfaces that mattered.

The dead n/no-process-exit: 'off' block for hooks is deleted in the same PR.
Phase 7 migrated every hook, so the exemption now protects nothing.

Two allowlist entries, not the one #3910 anticipated. terminateNow's body is
detected STRUCTURALLY — a process.exit lexically inside a function of that name
— rather than by a path and line number that rots. The second is
gsd-tools.cjs's ensureRuntimeBuild bootstrap, an inline disable with its reason
at the call site: it runs before ./lib/cli-exit.cjs is required, so the seam
does not exist yet and no migration is possible. #3910's 'single allowlist
entry' criterion is therefore unachievable as written, and is amended with the
measurement rather than quietly missed.

The rule is proven able to FAIL, per glob: four positive controls, one for each
registered glob. A guard that cannot be shown to fire is not a guard. Four
matching negative controls pin process.exitCode as never-flagged — conflating
it with process.exit is what inflated this epic's original census 2x. An
allowlist case and a near-miss (same shape, different function name) fix the
structural detection in place.

Verification runs on the remote runner.

Refs #3910

* fix(#3910): stop the detached catch from throwing, and scope the allowlist

Review findings, one of them a regression the previous fix introduced.

_handlePipelineRejection called error() from inside a DETACHED .catch().
error() now throws, so that throw became an unhandled promise rejection — and
on Node >=15 with --unhandled-rejections=throw, Node dumps a raw stack trace
with absolute paths on top of the clean Error: line. That was impossible before
this branch, because process.exit(1) terminated synchronously before any
rejection machinery could observe it. The handler now writes byte-identical
stderr itself, in both plain and --json-errors form, and sets exitCode in
place. This was the THIRD interceptor found, and like the first two it surfaced
by running the CLI rather than by reading code.

The rule's terminateNow allowlist had no path constraint, so any function
anywhere named terminateNow across all four globs inherited it. It now requires
the structural nesting check AND a cli-exit.cts basename — still no line
numbers to rot.

The four per-glob positive controls only varied a filename inside RuleTester,
which never resolves eslint.config.mjs. Since the rule is filename-agnostic,
all four exercised identical logic and none proved the rule was WIRED — this
epic's own failure mode. A registration test now asserts the rule resolves for
a real path in each glob, and it is proven able to fail: removing one glob's
registration flips the resolved value from [2] to undefined.

Three evasions the rule cannot catch (computed member, aliasing, .call/.apply)
are documented in its header and pinned by tests, labelled as known limits
rather than endorsed, so a future change that starts catching them is a
deliberate diff.

Refs #3910

* docs(#3910): document the raw-terminator ban

Reference and Explanation via a new docs/features fragment (FEATURES.md is
generated from it, not hand-edited). How-To:
docs/how-to/resolve-a-raw-terminator-finding.md, indexed from docs/README.md —
a contributor whose code trips the rule picks among three replacements by
surface (runMain/ExitError for a CLI path, terminateNow for a hook,
process.exitCode where the process should drain), and needs to know why
process.exitCode is correct and never flagged, since conflating the two is what
inflated this epic's original census 2x.

The page also names the three patterns the rule cannot catch and says plainly
that using one to dodge it is a review finding, not a fix — documenting them
without that sentence would read as a sanctioned workaround.

docs/INVENTORY.md deliberately untouched: eslint-rules/ is not a tracked family
in the manifest (verified — a regen produced a zero diff), so a hand-written row
would desync the table from the family it claims to belong to.

Refs #3910

* fix(#3910): a catch that sniffs the message swallows an ExitError

The remote run returned 41 failures, and one of them was a live production
regression rather than a test artifact.

`cmdMilestoneComplete`'s unstarted-phase guard re-threw only when
`e.message.startsWith('Cannot mark milestone complete:')`. `error()` used to
`process.exit(1)`, uncatchable, so the guard always fired. It now throws an
ExitError carrying no message, the string test fails, and the ExitError was
silently swallowed — the guard stopped blocking milestone completion entirely.
Proven against the real CLI: pre-fix, a milestone with an unstarted phase
archived at exit 0; post-fix it is blocked at exit 1 with the intended message.

That is a guard that silently stopped guarding, which is this epic's thesis
appearing inside the phase meant to enforce it. Worth stating plainly: an
earlier census DID examine this site, saw a `throw e`, and classified it as
rethrowing. It was wrong — the rethrow is conditional, and a conditional
rethrow on an inspected message is indistinguishable from an unconditional one
unless you read the predicate.

So the class was swept rather than patched where it was tripped over. An AST
census of every CatchClause across src/, gsd-core/bin/ and scripts/ found 38
conditional rethrows. Two more had the same defect and are fixed the same way:
`config.cts`'s `'No config.json'` sniff and `gsd-tools.cjs`'s
`e.name === 'WindowsError'`. The remaining 25 are provably unreachable — every
one wraps a bare fs, YAML, manifest-require or git-exec primitive that cannot
throw ExitError — and two were scanner false positives, both explained. Each
fix is an unconditional `instanceof ExitError` rethrow placed BEFORE any
inspection, matching the idiom command-routing-hub and gsd-tools already used.

Residual bound, stated rather than implied: zero known-reachable unfixed sites,
contingent only on error() never later being called inside one of those 25
primitive try blocks.

The remaining failures were harness artifacts, and the harnesses were corrected
to the new contract rather than the assertions weakened. Tests that mocked
`process.exit` to observe termination now catch ExitError and assert its code;
tests parsing stderr as a single JSON object still assert exactly that, with
their ad-hoc `node -e` scripts wrapped in runMain so it is true. milestone and
phase-resolution-parity needed no test change — they were correctly written
against the real bug and are what caught it.

Verification runs on the remote runner.

Refs #3910

* chore(#3910): backfill the changeset PR number

Also reframes the fragment to lead with the user-visible change — the
milestone guard blocking again — rather than the narrowest of the three fixes.

Refs #3910

---------

Co-authored-by: sim <sim@local>
2026-08-28 03:15:39 -04:00
Tom Boucher
9f219d05ba fix(#3964): route init's waiting-signal, codebase, and skill-manifest paths through the project-aware resolver (#3971)
* test(#3964): waiting_signal, codebase_dir, and skill-manifest must be project-scoped

* fix(#3964): route waiting_signal, codebase_dir, and skill-manifest through the project-aware resolver

* chore(#3964): changeset fragment (pr number backfilled after PR creation)

* chore(#3964): backfill changeset PR number (3971)

* test(#3964): assert on the POSIX-normalized codebase_dir across platforms

---------

Co-authored-by: sim <sim@local>
2026-08-28 02:25:52 -04:00
Tom Boucher
5ad708aa4e fix(#3972): one worktreesOptedOut ladder in planning-workspace, shared by resolver and guard (#3979)
* test(#3972): the guard fallback must share the opt-out ladder

* fix(#3972): one worktreesOptedOut ladder in planning-workspace, shared by resolver and guard

* test(#3972): without a workstream the root config is the effective config

* fix(#3972): guard the whole ladder body — malformed env values degrade, never throw

* chore(#3972): changeset fragment (pr number backfilled after PR creation)

* chore(#3972): backfill changeset PR number (3979)

---------

Co-authored-by: sim <sim@local>
2026-08-28 01:53:23 -04:00
Tom Boucher
45f18b444d fix(#3963): inherit the root worktree opt-out under GSD_WORKSTREAM (#3967)
* test(#3963): root use_worktrees opt-out must be inherited under a workstream

* fix(#3963): inherit the root worktree opt-out under GSD_WORKSTREAM

* chore(#3963): changeset fragment (pr number backfilled after PR creation)

* chore(#3963): backfill changeset PR number (3967)

---------

Co-authored-by: sim <sim@local>
2026-08-27 23:38:19 -04:00
Tom Boucher
e9868a92ba fix(#1875): route installer settings/defaults writes through atomic, lock-guarded primitives (#3966)
* fix(#1875): route writeSettings through atomicWriteFileSync

writeSettings is the sole writer of settings.json/settings.local.json for
six runtimes and wrote them with a naked fs.writeFileSync. Hosts discard the
entire settings file on any parse failure, so a crash mid-write cost the user
every hook, permission, env var, and statusline they had — not just GSD's.

Route it through the atomicWriteFileSync (temp+rename) already used elsewhere
in the installer and already bound in this file.

withWriteFailure in the migration integration harness matched only the final
destination path, so an atomic write bypassed the injection entirely and turned
a rollback assertion into a vacuous pass. It now also matches the .tmp- sibling.

Refs open-gsd/gsd-core#1874 (F5)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(#1875): add changeset fragment

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(#1876): honor the readSettings null contract in the #338 local-merge leg

readSettings returns null only for an unparseable file — its documented
"preserve existing, don't touch" signal. The #338 migration coerced that null
to {} and wrote the result back, so a settings.local.json with one stray comma
lost all its non-GSD content on the next install.

The guard stands the whole migration down rather than just the local write:
skipping the merge while still stripping the shared file would destroy the GSD
entries outright instead of relocating them.

Aborting here reaches a pre-existing latent crash that the clobber had been
masking. Both are fixed, with their own regression test:

- the unparseable-settings guard returned bare `undefined` while all five
  sibling early exits return the full result shape, so installAllRuntimes'
  statusline lookup (results.find(r => r.runtime)) threw;
- handleStatusline dereferences result.settings, which is null on every early
  exit, so the call site now falls through to the banner branch.

Both crashes reproduce on unmodified next with a malformed settings.local.json
and no migration involved.

Refs open-gsd/gsd-core#1874 (F6)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(#1876): add changeset fragment

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(#1877): lock and atomically write the machine-global ~/.gsd/defaults.json

Every non-Claude install read-modify-writes ~/.gsd/defaults.json with no lock
and two separate naked whole-file writes. The file is read by every runtime and
project on the machine, so concurrent installs lost each other's key, and a
crash in either write window truncated it — silently, because the read path
swallows parse errors and treats a corrupt file as absent.

Take the existing acquireInstallMigrationLock around the read-modify-write and
apply both mutations in one atomicWriteFileSync. An install that changes nothing
no longer rewrites the file at all.

Existing semantics are unchanged: the explicit resolve_model_ids:true opt-in
(#1569) and an existing "omit" are preserved, non-canonical values still default
to "omit" (#1156), a pre-existing runtime string is preserved (#2395), the
malformed-non-object recovery (#1657) stands, and both console lines still print
when both keys change.

The #2834 structural test sliced a fixed 1200-character window from the function
source; the added lock comment pushed an asserted token past it. The window now
tracks the function body, so a comment or guard cannot red it spuriously.

Refs open-gsd/gsd-core#1874 (F18)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(#1877): add changeset fragment

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(#1874): preserve target mode and create temp files exclusively in atomicWriteFileSync

* fix(#1874): write the migration-lock payload through the exclusive descriptor

* chore(#1874): bold-led changeset fragments + fragment for the hardening pass

* chore(#1874): rename the shadowed lock-release catch binding

* test(#1874): fix source-grep/try-finally violations, add missing fault-injection cases

- drop the /TypeError/.test(stderr) source-grep assertion (exitCode already
  proves the installer didn't crash)
- convert inline try/finally fs-mock restoration to t.after() across the F5/F18
  test suites, per this repo's no-try/finally-in-test-body rule
- add a rename-failure fault-injection case for atomicWriteFileSync
- add a read-only-.gsd-directory fault-injection case for the F18 lock+write path
- extract MAX_TEMP_FILE_ATTEMPTS constant, dedupe the partial-write-then-throw
  mock into a shared tests/helpers.cjs helper

Found during this session's own Standards-axis code-review pass on resurrected
PR #3385.

* chore(#1874): reset changeset fragments to pr:0 placeholder

The resurrected fragments carried the closed PR's number (3385). This is a
new PR, so reset to the pr:0 placeholder and backfill the real number once
gh pr create returns it, per CONTRIBUTING.md's PR Number Handling.

* chore(#1874): backfill changeset PR number (#3966)

---------

Co-authored-by: Richard Spiers <1355479+richardspiers@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: sim <sim@local>
2026-08-27 23:27:43 -04:00
Tom Boucher
15af0f5536 enhance(#3951): B6+B7 — widen two unreachable lint rules and make the guard ledger true (#3965)
* fix(#3951): two lint rules that could not reach the code they govern

B6 names two widenings. Measuring them first turned up a defect the criterion did
not know about, and refuted the reason it gave for one of them.

1. no-adhoc-markdown-parsing self-gates on its own filename.

   Lines 107-110 short-circuit create() to {} unless the path matches
   /(?:^|\/)src\/[^/]+\.cts$/. B6 says to widen the files: glob in
   eslint.config.mjs - but doing only that ships an INERT rule, because the gate
   still returns {} for every new path. Both halves have to change, and the gate
   is the load-bearing one.

   That same regex hides a live hole: [^/]+ is FLAT-ONLY, so it requires the file
   to sit directly in src/. The registered glob is src/**/*.cts, which includes
   subdirectories. 28 .cts files - health-diagnostic-rules/ (10),
   installer-migrations/ (11), observability/ (3), host-integration-adapters/ (2),
   vendor/ (2) - are inside the registered glob and silently skipped.

   Measured with the gate neutralized: 0 violations there today. The hole is
   hiding nothing right now, and is fixed anyway, because "no violations today" is
   not a property that keeps holding.

   The fix is not invented: require-subprocess-timeout.cjs:196 already carries the
   correct form of this guard, /(?:^|\/)src\/.*\.cts$/ with .*, one directory over.
   Checked the other 21 rules for the same bug - no-adhoc-regex-escape and
   no-private-binary-resolution short-circuit only to exempt their own seam file,
   which is the right shape, and no-crlf-fragile-split has no filename gate at
   all. This bug is unique to the one rule.

2. no-adhoc-regex-escape could not see the shape that actually occurs.

   Line 396 gated the whole UNSAFE-NEW-REGEXP arm on arg.type === 'Identifier'.
   Every check below it - the _SOURCE provenance check, the
   isSoleReturnOfOwnParameter shape - lives inside that branch, so
   new RegExp(obj['key']) and new RegExp(cfg.pattern) were never examined at all.
   Runtime data arrives as a property access far more often than as a bare
   identifier, which is exactly why this rule never fired on the #3477 ReDoS.

   Widened to MemberExpression, measured by AST walk across all five registered
   blocks rather than by grep. 27 sites, zero TSAsExpression:

     18  safe new RegExp(X.source, flags)  -> exempted, keyed strictly on the
         PROPERTY being `source`, never on the object. Keying on the object would
         wave through X.anything and buy nothing. B6 estimated ~10; that was an
         undercount.
      3  _SOURCE-suffixed constants reached through a required module namespace
         (phaseId.BRACKET_PHASE_TOKEN_SOURCE) -> the same provenance-exempt class
         the rule already recognizes for bare identifiers, extended to reach them.
         Without this the widening produces 3 false flags.
      6  real findings -> marked, each a test extracting a pattern from a shipped
         file at test time, where the runtime contract IS the product.

   Deliberately the NARROW MemberExpression form. The rule's own
   isSoleReturnOfOwnParameter doc comment records that an earlier broad
   "any non-literal identifier" heuristic produced ~25 false positives and was
   rejected; a re-run of the census after this change flags exactly the 6 above
   and nothing else.

Verified by execution, not by reading: the gate now accepts src/<subdir>/x.cts,
still accepts flat src/x.cts, and still exempts paths outside src/ - each pinned
by a test proven to fail against the old regex. build:lib, lint and lint:ci all
exit 0.

Refs #3951

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

* fix(#3951): give no-adhoc-markdown-parsing its reach, and fix the 80 parses it finds

The rule self-gates on filename AND is registered on one glob, so widening either
half alone is inert. Both move here: the gate now accepts tests/**/*.cjs and
scripts/**/*.cjs alongside src/**/*.cts, and eslint.config.mjs registers it on the
same two.

A test pins that the gate and the registration AGREE, in both directions. The
original defect was a gate narrower than its registration; the failure mode of
this fix is a gate wider than its registration. Both are silent, so the test
asserts the pair rather than either half.

80 violations across 43 files, all in tests/, zero in scripts/. 70 are routed
through the existing seams - scanFencedBlocks, collectSection, stripFencedCode,
tokenizeHeadings from markdown-sectionizer; splitTableRow, parseMarkdownTable,
findTableWithColumns from markdown-table. Headerless STATE.md tables use
splitTableRow per line, because parseMarkdownTable needs a real delimiter row.

10 are suppressed, 12.5%, well under the third that would have meant the rule is
mis-scoped for tests/ rather than the tests carrying debt. Each names its reason:
three regression guards (#3873 / bug-#21) are deliberately independent of the
generator's own fence handling, and routing them through the seam would have them
test the generator against itself; one is a negative-text probe that extracts
nothing; six are a shell-pipe-to-jq detector whose regex coincidentally matches the
table fingerprint and is not markdown parsing at all.

All ten sit in tests whose subject is .md content, which is normally a reason to
prefer the seam. The marker used is allow-adhoc-markdown, distinct from
no-source-grep's allow-test-rule, and lint:ci's lint-allow-test-rule-refs reports
the same 280/280 unverified count as before - checked rather than assumed, because
those two markers are easy to conflate.

The widening earned its keep immediately: it found a test that passed for the
wrong reason.

  tests/config-field-docs.test.cjs asserted notEqual(<cell>, '600') against the
  TYPE column instead of the DEFAULT column. notEqual('number', '600') is true
  forever, so the guard against workflow.subagent_timeout regressing to the old
  seconds default could never fire. docs/CONFIGURATION.md:434 is
  `| workflow.subagent_timeout | number | 300000 | ... |`, so the default is cell
  index 2; the assertion is now row-scoped through splitTableRow and reads 300000.

That is the argument for the widening in one case: the violation was invisible to
lint, the suite was green, and the assertion was vacuous. A rule that cannot reach
a file cannot tell you the file is lying.

Not fixed here, and recorded rather than assumed: #3426/#3239 are NOT reachable by
this widening. tests/package-legitimacy-gate.test.cjs yields zero violations even
with the gate bypassed - its hand-rolled scans are real, but built from line
filters and split('|') rather than the regex-literal fingerprints this rule
detects. They need new detectors. The epic assumed a wider glob would catch them.

build:lib, lint and lint:ci all exit 0; the post-fix census across tests/** and
scripts/** is 0 violations.

Refs #3951

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

* fix(#3951): B7 — and #3356's defects were still live in the code

B7 asks that each closed child be driven fail-first with a behavioral identity
test at the CONSUMER's output. Four of eleven children had no test citing their
issue number. Auditing them by BEHAVIOR rather than by number-grep changed the
answer for three of the four.

#3364 and #2540 — traceability only. Both were implemented by #3941 and their
consumer-output tests exist and were shown failing-first; neither cited its
originating issue, so an audit that greps for the number reports them uncovered.
Tagged the specific asserting test in each file, following the citation form those
files already use.

#3372 — covered, but only at helper level, and the triage narrowed it. Of the four
commands the issue names, only estimate-cli's collectCalibrationSamples actually
enumerates phase dirs from disk; smart-entry, audit and roadmap-upgrade derive from
ROADMAP/body text and never reach the sentinel path, so they are benign by
construction and were left alone rather than "fixed" into churn. The existing #3882
rows asserted the helper's return value. Added a consumer-output test driving
`query estimate-calibrate` and asserting sample_count and the persisted document.
RED proof: reverted collectCalibrationSamples to a raw readdirSync and ran the real
CLI - sample_count 3, sentinel leaked; restored - sample_count 2.

#3356 — NOT covered, and BOTH halves of the defect were still live in source. The
issue is closed; the bug was not fixed. Fixed here rather than writing tests that
document a bug as correct.

  Defect 1, the contradicted row. quick.md:627 claimed
  `quick-tasks-append` performs "the equivalent write" to the Step 7c row. It did
  not: the `#` cell was a positional ordinal and `Directory` read `—`, because the
  route had no way to receive a quick id or task directory. Added OPTIONAL
  `--quick-id` / `--slug` / `--directory`. A caller with neither - fast.md, the
  original #2133 caller - omits them and gets the byte-identical prior row, so
  nothing existing changes. A caller that HAS a real id and directory now gets the
  canonical row quick.md:632 renders. The false-equivalence sentence itself is
  corrected rather than left to mislead the next reader.

  Defect 2, the forced re-derive. The route called readModifyWriteStateMd with no
  options, so a body-only append to the Quick Tasks table triggered a full
  re-derive of the disk-derived progress.* frontmatter. Every other body-only
  writer passes { resync: false } - src/state.cts's own docstring prescribes it -
  and this route was the lone outlier. RED proof: reverted the option, seeded a
  project with 2 real phase dirs and a curated total_phases of 25, ran
  quick-tasks-append; total_phases collapsed to 2. Restored; it stayed 25.

That second one is the shape this epic exists to close: a silent write that
replaces curated state with a re-derivation nobody asked for, exit 0 throughout.

build:lib, lint and lint:ci all exit 0.

Refs #3951

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

* docs(#3951): amend B6's ledger to what was measured, and document the new flags

The ADR gains a ledger amendment in its own correction style - the sixth wrong
premise it records, found the same way as the other five, by measuring before
building.

B6 says the net guard count must fall. It rose: 62 -> 69, +7, measured from the
epic's filing commit to origin/next. The attribution is the point, though. Five of
the seven came from PRs unrelated to this epic, one was added by a phase of it, and
the epic did retire something sub-file - #3884 removed a detector with an explicit
"net: -1 detector, 0 added" ledger. Every named casualty is load-bearing, two
already carry retractions in this same document, and a sweep of all 22 rules plus
every scripts/lint-* found no provably dead guard. There is no honest way to make
the count fall; forcing it would trade coverage for a number, which is the Goodhart
outcome Decision 6 exists to prevent.

The amendment also records that B6's own prescribed fix for one widening was inert.
no-adhoc-markdown-parsing self-gates on its filename, so widening only the files:
glob - which is what the criterion says to do - ships a rule that still returns {}
for every new path. And #3426/#3239 are not reachable by that widening at all;
their scans use line filters and split('|'), not the regex fingerprints the rule
detects. The roster row tracked them against the wrong mechanism.

Three roster rows updated from aspiration to fact: the two widenings are DONE with
their measured counts, and lint-phase-enumeration-drift is marked RETAINED rather
than "expected casualty - verify before retiring", because Phase 5 verified it and
kept it.

The rule Decision 6 should carry forward is stated plainly: a guard ledger is a
claim about COVERAGE, not about COUNT. "Net count must fall" is measurable and
wrong. "Every guard is reachable, and each retirement names what makes its defect
unrepresentable" is the property that was actually wanted.

CLI-TOOLS.md documents the optional --quick-id/--slug/--directory flags and says
plainly that omitting them keeps the pre-#3356 row byte-identical, plus that the
append no longer re-derives progress frontmatter.

New features fragment (id 3951); FEATURES.md regenerated rather than hand-edited.
Changeset is Changed, pr:0 pending backfill.

Refs #3951

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

* test(#3951): correct four rows that pinned the lint rule's old narrow reach

The remote suite came back RED with 5 failures, all in tests/eslint-rules.test.cjs.
They are stale tests, not a regression: four rows assert that
no-adhoc-markdown-parsing is inert outside src/*.cts, which is exactly the
contract this deliverable changes.

Confirmed by reading rather than inferred from the names - the row at :1981 used
filename: 'tests/some.test.cjs' and filename: 'scripts/helper.cjs', the two roots
the rule now covers on purpose.

Worth recording WHY local gates missed this. npm run lint and lint:ci were green,
and the touched test files passed standalone. Lint only reports violations in real
files; these rows assert the rule's REACH using synthetic RuleTester filenames, so
nothing but the full suite could see them. Local green on a rule change says
nothing about the rule's own tests.

Each row is rewritten with BOTH halves rather than flipped from valid to invalid:

  - the same fingerprint under tests/ or scripts/ is now flagged, with the right
    messageId
  - the negative space is preserved - the same fingerprint under a path outside
    all three roots (gsd-core/bin/lib/foo.cjs) is still NOT flagged

The second half is the one that matters. Without it the rule has no boundary and
nothing would catch an over-wide gate later, which is the mirror image of the bug
this deliverable just fixed.

Each row is renamed to state the current contract; the old names said
"non-src/*.cts ... is not flagged" and would have been actively misleading once
the bodies changed.

Proven to test the widening rather than restate it: every flagged half was run
against HEAD~2's pre-widening rule and does NOT fire there, then against the
current rule and does. 12/12 on that probe; the full file is 178/178.

Swept for the same staleness elsewhere and found none.
require-subprocess-timeout's own "inert outside src/*.cts" row is untouched -
that rule's gate was not widened here - and no-adhoc-regex-escape's test file
already carries correctly-targeted rows.

Refs #3951

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

* test(#3951): acknowledge the quick.md growth the attribution guard reported

The full suite came back RED with one failure, and it is mine:

  1 file(s) grew without an acknowledgment:
    quick.md grew 364 bytes

gsd-core/workflows/quick.md is runtime-loaded emitted content, so correcting
its false 'performs the equivalent write' claim trips emitted-attribution by
construction. This is the acknowledgment, not a workaround - there is nothing
to regenerate.

The fragment names ONE path, which is the only one the guard reported. The four
spent acknowledgments it also listed (audit-uat, plan-phase, progress, review)
belong to other fragments whose ripple the base already absorbs; they are inert,
not failures, and are deliberately NOT copied here - naming paths I did not
change would make this record false in the other direction.

Byte figure corrected before committing: the guard reported 37220 -> 37584
(+364), but origin/next has since moved and quick.md is 37232 there now, so the
measured delta is +352. The reason text says so and names the base as a moving
figure rather than pinning a number that is already stale.

Refs #3951

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

* test(#3951): move the quick.md growth ack to a trailer, delete the obsolete fragment

The acknowledgment mechanism changed under this branch. Merging next brought in
the redesign - it also deleted .github/workflows/ack-fragment-sweep.yml, which
was in the merge status and which I did not register at the time - and the guard
now says so directly:

  Add a trailer to a commit in this PR (never a new file).
    Emitted-Drift-Ack-Growth: quick.md - <why this growth is deliberate>

So tests/emitted-drift-acks/3951-quick-append-equivalence.json is obsolete on
arrival. A fragment file is no longer read by anything, and leaving it would be a
dead record that looks like an active one. It is deleted here rather than kept
"just in case".

The byte figure moved again with the merge: 37232 -> 37596, +364. The earlier
fragment said +352, measured before the merge auto-merged quick.md itself. The
trailer carries no number, which is the better design - the figure was stale
twice in two attempts.

Refs #3951

Emitted-Drift-Ack-Growth: quick.md — #3356/#3951 replaces a false claim with an accurate one. Line 627 said the `quick-tasks-append` shortcut "performs the equivalent write" to the Step 7c row rendered above it; it did not, and that was the documented half of #3356 — with no quick id or task directory the route emitted a positional ordinal in `#` and an em-dash in `Directory`, a visibly different row. The corrected sentence has to carry three facts the original elided: what the shortcut actually writes when it has neither input, that this is honest behavior for its real caller (`fast.md`, which has neither), and how a caller with both now gets the byte-identical canonical row via the new optional `--quick-id`/`--slug`/`--directory` flags. Prose is the product here — an executing agent reads this line to decide whether the shortcut is safe for its case, and a shorter correction would either drop the flags (leaving the reader unable to act on the fix) or drop the limitation (recreating the false claim in gentler words).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#3951): backfill changeset pr number

Refs #3951

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

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 23:10:49 -04:00
Tom Boucher
2ea5efc151 enhance(#3911): hooks declare their crash policy (#3960)
* enhance(#3911): give hooks an exit seam that needs no build

ADR-3889 Phase 7 foundation. The 19 shipped enforcement hooks hold 91 of the
epic's 128 terminators and cannot reach `terminateNow` today.

The obvious route — requiring `gsd-core/bin/lib/cli-exit.cjs`, as
gsd-agent-isolation-guard.js already does for two other modules — is rejected.
That precedent carries its own warning (#3582): those files are tsc output,
gitignored and absent on a raw plugin-marketplace or git-clone install, so the
hook must first call ensureRuntimeBuild() to self-heal. Making the module a
hook needs IN ORDER TO TERMINATE depend on a build inverts the dependency, and
its failure mode is precisely the fail-open this phase exists to remove: a
guard that cannot terminate cannot deny. `lint-hooks-runtime-build-seam`
already encodes that concern, and Design B would have had to add an
ensureRuntimeBuild() call to all 19 hooks to satisfy it.

So `hooks/lib/` becomes a third emit location for cli-exit and a fifth for the
registry, preserving the invariant `src/cli-exit.cts`'s own header states: it
imports nothing but node:fs and its sibling registry, and the generator
dual-emits that sibling alongside each copy so a relative require resolves next
to whichever copy loaded it. Shipping needed no change — build-hooks.js already
declares HOOKS_SUBDIRS_TO_COPY = ['lib'].

Proven, not asserted: the two files are copied into an otherwise-empty tmpdir
and a child process requires them and terminates — PASS exits 0, HOOK_DENY
exits 2 with the payload on both stdout and stderr. That test fails the moment
the hooks copy gains a require reaching outside hooks/lib/.

Also fixed inline: the registry's fifth target let any `--write` test overwrite
the real committed hooks/lib/exit-code-registry.js, because the test helper
derived only three of the other output paths. It now redirects all five, and a
regression test asserts every committed artifact is byte-identical after a
redirected write.

Install-tree goldens pick up the two new shipped paths across 11 runtimes —
insertions only, no removals. lint:ci was green while they were stale, so this
was found by regenerating rather than by a gate.

Verification runs on the remote runner.

Refs #3911

* enhance(#3911): declare a crash policy, and migrate the write guard

Adds `hooks/lib/hook-exit.js` — the hook-facing vocabulary over `terminateNow`,
hand-written because the cli-exit copy beside it is generated:

  allow(payload)          exit 0
  deny(payload, stderr?)  exit 2
  crash(onCrash, payload) whichever the hook DECLARED

`crash()` takes the policy as a required argument with no default, which is the
whole mechanism: fail-open by accident stops being expressible. A hook must
name ALLOW or DENY at the call site, and an unrecognized value terminates
INTERNAL rather than guessing. Fail-open stays legal; fail-open by omission
does not.

`gsd-write-guard.js` is the first hook migrated, all 12 sites, and it exposed a
gap in the seam. `terminateNow`'s doc comment justified its fd-2 write by
citing this hook's `emitBlock` — but modeled it as sending the same bytes to
both streams, when `emitBlock` actually sends full JSON to stdout and only the
bare `reason` string to stderr, because Kimi's hook bus feeds stderr verbatim
back to the model. Migrating as written would have turned a readable sentence
into a JSON blob for Kimi-backed agents.

#3911 requires both "all 19 hooks terminate through terminateNow" and "no
hook's effective default changes". Those are jointly satisfiable only by
teaching the seam to carry a distinct stderr payload, so `terminateNow` gains
an optional third argument: omitted, behavior is byte-for-byte what it was; a
string is written raw, which is exactly the Kimi case. The doc comment's
inaccurate claim about emitBlock is corrected in place.

Proven rather than asserted: the pre-migration file is reconstructed from HEAD
and driven with the same catastrophic-shrink payload as the migrated one —
exit code, stdout and stderr all byte-identical.

Verification runs on the remote runner.

Refs #3911

* enhance(#3911): all 19 hooks terminate through the seam

Migrates the remaining 18 enforcement hooks onto allow/deny/crash. An AST walk
now reports zero `process.exit(` call sites across every `hooks/*.js` — down
from the 91 the census measured.

Each hook with an outer catch declares its policy once, at module top, with the
reason that policy is right for that specific guard: a read guard that cannot
scan must not block the read; a statusline that renders every prompt must
degrade rather than crash; an injection scanner must not retroactively block a
result already returned. Those sentences are the deliverable — they are what
turns fail-open-by-accident into fail-open-on-purpose. No hook's effective
default changed.

Wiring exposed two defects, both fixed here rather than noted.

A SECOND stdout/stderr-splitting site turned up in `gsd-workflow-guard.js`'s
`emitForceAddBlock`, matching the pattern already known from the write guard —
full JSON to stdout, bare reason to stderr for the Kimi bus. It uses the
`stderrPayload` argument added in the previous commit, which is now carrying
its second real caller rather than one special case.

More seriously, `terminateNow` emitted both streams inside ONE try, so a
payload that failed to serialize aborted before the stderr write ever ran. The
two windsurf guards write nothing to stdout on a block and only a reason string
to stderr, so `deny(undefined, reason)` exited 2 with EMPTY stderr — a deny
that silently loses its reason, which is the exact "fails with success" class
this epic exists to close. The streams are now emitted independently, each with
its own guard, and `undefined` means "nothing to write for this stream" rather
than an error. Regression tests inject a throwing write on one fd and assert
the other still receives its payload; they fail against the single-try version.

Byte-identity was proven per hook, not assumed: each pre-change file is
reconstructed from HEAD and driven side by side with the migrated one across
its normal path, its deny path, malformed stdin and empty stdin — exit code,
stdout and stderr compared.

Verification runs on the remote runner.

Refs #3911

* enhance(#3911): harden the three shell hooks, and pin every hook's policy

`gsd-phase-boundary.sh`, `gsd-session-state.sh` and `gsd-validate-commit.sh`
gain `set -euo pipefail`.

The expected hazard did not materialize, and that is worth recording: every
intentionally-non-zero command in all three is already the condition of an
`if`/`elif`, which `set -e` never fires on, and none of them reads a
possibly-unset variable or pipes through a grep that may legitimately match
nothing. No `|| true` guards were needed. Each hook was still checked
command-by-command before the flags went in rather than after.

Twenty-one before/after cases across the three hooks — disabled and enabled,
planning and non-planning, missing STATE.md, malformed JSON, the Kimi payload
shape, quoted and unquoted `-m`, valid and over-long Conventional Commits —
all match on exit code, stdout and stderr.

The hardening is shown to actually fire, not merely added: with a stubbed
`node` that fails at the JSON-emit step, phase-boundary and session-state go
from silently exiting 0 with empty stdout to failing visibly with the error
surfaced. No such case could be constructed for `gsd-validate-commit.sh`,
whose every statement already sits inside an if-condition — recorded as
unproven rather than claimed.

`tests/hooks-crash-policy.test.cjs` adds the per-hook coverage the issue asks
for, table-driven over all 19 hooks rather than 76 hand-written cases: normal
allow, deny where a deny path exists, crash-honors-the-declared-policy, and an
unclosed-stdin case — the one `process.exitCode` structurally cannot serve. The
deny assertions encode each hook's ACTUAL stream split rather than a uniform
shape, since four of the six deliberately differ. A drift guard enumerates
`hooks/*.js` and fails if a terminating hook is ever added without a row.

Writing those tests surfaced two hooks that emit a block decision in their JSON
body and exit 0. Both were checked rather than assumed, and neither is a
fails-with-success: `gsd-read-injection-scanner.js` is PostToolUse, where the
tool has already run and exit 2 has no meaning, and `gsd-cursor-subagent-start.js`
follows Cursor's JSON-body protocol. They are deliberately left alone — a
mechanical sweep to `deny()` would have broken exactly these two.

Verification runs on the remote runner.

Refs #3911

* fix(#3838): the commit validator says when it could not validate

#3911 claims to subsume #3838. Measurement said otherwise, so this closes it
for real rather than by assertion.

`set -euo pipefail`, added earlier on this branch, does NOT fix #3838: bash
exempts a command used as an `if` condition from `set -e`, and all three of the
hook's swallow-and-pass sites are exactly that shape. Verified against the
hardened hook with a node shim that fails only the classifier call — a
non-conforming commit still exited 0 with empty stdout AND empty stderr,
indistinguishable from "your commit conforms". That is the defect verbatim.

All three sites named in #3838 now capture the real exit status instead of
consuming it as a condition, and each distinguishes its genuine negative from
"could not run":

- the classifier: 0 = is a git commit, 1 = genuinely not one, anything else =
  could not classify. Its `node -e` now wraps the require and the call in
  try/catch and exits 3 on a throw, so a broken require chain can never be
  mistaken for `isGitSubcommand` legitimately returning false — which is the
  arm that matters, since `token-scanner.cjs` is a gitignored build artifact
  and a fresh checkout lands there.
- the opt-in config read and the JSON command extraction get the same
  treatment.

On "could not run" the hook emits a diagnostic to stderr naming which check
failed and why, then exits 0. The issue confirms this is safe — it is a
PreToolUse hook, so stderr does not disturb the JSON protocol — and ranks it
the smallest sufficient fix. The gate still fails open, but it can no longer do
so silently, which is the whole complaint: a validator that disables itself
quietly costs more than one that is absent, because it is trusted.

Both controls are unchanged and pinned by tests: a conforming commit still
passes silently, a non-conforming one still exits 2 with its existing block
payload. The defect test asserts stderr is non-empty and names the failure; it
fails against the pre-fix hook.

Verification runs on the remote runner.

Refs #3911, #3838

* docs(#3911): document the hook crash-policy contract

Reference and Explanation via a new docs/features fragment (FEATURES.md is
generated from it), INVENTORY rows for the three new hooks/lib files, and an
ARCHITECTURE note on the hooks section.

How-To: docs/how-to/declare-a-hook-crash-policy.md, indexed from docs/README.md
— a hook author now has to choose and declare a crash policy, which is more
than one step and crosses into which harness protocol their hook speaks. It
covers allow/deny/crash, writing an ON_CRASH reason that is actually useful,
when a deny needs a distinct stderr payload, the two hooks whose harness reads
a JSON-body decision and must NOT use deny(), and what to do when a check
cannot run at all — with #3838 as the worked example.

Refs #3911

* test(#3911): prove the seam actually ships, and stop hand-rolling temp cleanup

Two review findings.

The acceptance criterion 'hooks/dist/** stays in parity via the build seam
(lint:hooks-runtime-build-seam)' was misstated and unmet: that lint checks
something else — that a hook requiring a compiled gsd-core/bin/lib module also
calls ensureRuntimeBuild(). Nothing exercised that the three new hooks/lib
files reach hooks/dist/lib at all. That gap is not theoretical: #770 is a
recorded ship-blocking bug where a new hook never shipped because a copy list
missed it. The suite now builds dist through the repo's own ensureBuiltHooks(),
byte-compares each shipped copy against its source, and spawns a child that
requires the SHIPPED dist copy and denies — which is what catches a copy that
exists but cannot resolve its sibling registry.

gsd-validate-commit.sh hand-duplicated mktemp/run/rm three times; one idempotent
trap on EXIT replaces them, guarded so cleanup cannot alter the exit status.
Behavior-neutral across five cases, with temp-file counts taken before and
after each run.

Refs #3911

* fix(#3911): stage transitive hook lib requires, not just one level

The remote run returned 7 failures across 3 real causes.

The important one is a PRODUCTION bug this phase exposed rather than caused.
`writeCursorHooksJson` scanned each hook script for `./lib/X` requires exactly
one level deep and never re-scanned the lib files it staged for their own
sibling requires. Nothing had a transitive lib dependency before, so the gap
was invisible. Adding hook-exit.js -> cli-exit.js -> exit-code-registry.js
made real Cursor installs ship a bundle that dies at require time with
MODULE_NOT_FOUND. It now walks to a fixed point, and a real installed Cursor
hook runs to completion.

The staging harness in shared-hooks-dir-resolution hand-copied its fixture, so
the injection scanner crashed at require time and its exit-1 was being read as
a policy decision. Migrated to copyScriptWithDeps, which walks the require
graph — the repo's recorded rule for this class, since adding another
copyFileSync keeps it alive for the next person.

The missing-lib-source test in cursor-hook-workspace-roots hardcoded which lib
file it expected to be named in the abort message; the same throw now fires for
a different file first. Its assertion is unchanged in substance — staging still
must abort rather than ship a broken hook — only the name is no longer pinned.

The last one was my own test asserting an uppercase reason code. Measured
against origin/next: the pre-change hook emits the same lowercase
'config_unreadable', so the test was wrong, not the migration. Corrected to the
real value rather than making the code match the test.

Verification runs on the remote runner.

Refs #3911

* chore(#3911): regenerate the cursor install-tree golden

The staging fix means a Cursor install now correctly carries the two
transitive lib files it was silently missing. Additive only — no path was
removed. The golden diff is the evidence the packaging defect was real.

Refs #3911

* chore(#3911): backfill the changeset PR number

Refs #3911

* fix(#3911): a git probe that timed out is not a negative

A macOS CI lane failed three deny cases at 2084ms, 2112ms and 2177ms — just
past the 2000ms budget these hooks give their git probes. The three that passed
took 72ms, 595ms and 651ms. Under shard contention `git rev-parse` overruns,
the hook reads the non-zero result as "not a git repo", and allows with exit 0
and empty stdout AND empty stderr. Under load, the guards silently stop
guarding. That is ADR-3889's thesis exactly, sitting inside the security hooks
this phase is about.

The repo had already recognized the class in one place — gsd-cursor-subagent-start.js
fail-closed-denies on `git_timed_out` (#3045) — but nowhere else.

`hooks/lib/git-probe.js` classifies a probe's outcome, distinguishing a real
non-zero exit from ETIMEDOUT, a signal kill, and a spawn failure, rather than
folding all four into `status !== 0`. Three guards route their eight git probes
through it.

The resolution is the same shape #3838 took, and the same one that issue
endorsed as smallest-sufficient: fail open, but loudly. **No exit code changes
on any path** — a developer on a loaded machine is still not blocked, which
keeps #3911's declaration-pass contract intact for exit codes. What changes is
that the hook now says on stderr which probe could not answer, instead of
presenting silence as a clean verdict.

Scope was checked across every hooks/*.js, not just the three that failed:
gsd-agent-isolation-guard spawns no git; gsd-statusline's two probes gate only
a cosmetic display segment, not an allow/deny decision, and are left alone.

The C2 deny assertion was a real-race test — it demanded exit 2 while a slow
git legitimately yields 0. It now requires the hook to either deny, or allow
with a diagnostic naming the probe that could not run; a silent allow still
fails, so the assertion is not vacuous. A deterministic regression stubs git on
PATH to sleep past the budget rather than waiting for load to reproduce it.

Verification runs on the remote runner.

Refs #3911

* test(#3911): a PATH shim cannot intercept the hooks' git spawn on Windows

The deterministic timeout regression stubbed git on PATH and asserted the
guard reports rather than silently allows. It passes on Linux and macOS and
failed on Windows in 83ms and 176ms — the stub was never invoked at all.

Mechanism: the hooks call spawnSync('git', args) with no shell:true, so on
Windows CreateProcess resolves git.exe only and never a PATH .cmd shim. The
git.cmd branch could not have worked and is removed rather than left implying
a Windows path that does. Adding shell:true to the hooks to serve a test would
change product behavior and widen an injection surface, so the case is skipped
on win32 only, with the mechanism written into the skip reason so a future
reader does not 'fix' it that way.

Linux and macOS keep the coverage, and macOS is where the underlying fail-open
was actually caught.

Refs #3911

---------

Co-authored-by: sim <sim@local>
2026-08-27 22:21:10 -04:00