Commit Graph

5686 Commits

Author SHA1 Message Date
Tom Boucher
7bb366e836 fix(#4130): --context flag for check decision-coverage-plan + parseDecisions quadratic-backtracking hardening (#4374)
* test(#4130): failing-first regressions for --context flag + parseDecisions hardening

Block A (flag): check decision-coverage-plan --context <path> must route
identically to the positional form; flag wins over positional context;
valueless --context falls through to the #2770 fail-closed caller error;
verify keeps its positional surface (flag is plan-only). RED on base:
the flag token lands in the args[2] phase slot (false uncovered) or the
args[3] context slot (silent CONTEXT.md-missing skip).

Block B (hardening): regex-lattice asserts pin the atomic-ID wrapper
(?=(X))\1 and the em-dash first-separator narrowing [^*—–]*[—–] plus the
no-adjacent-overlap property; a differential property compares the module
against a frozen copy of the pre-hardening grammars (reference validated
against the base build: 60k generated lines, 0 mismatches); 40k cliff
shapes assert correct outcomes with no wall-time asserts (repo rule).

A12: partitionPredicateArgs keeps one parser behind parsePredicateFlags.

* fix(#4130): --context flag for check decision-coverage-plan + quadratic-backtracking hardening in parseDecisions

(A) check decision-coverage-plan --context <path> — sibling convention
(check predicate, #2008): --flag value pairs parsed by the new shared
partitionPredicateArgs (parsePredicateFlags reimplemented as its flags
half — one parser, cannot diverge), the flag winning over a same-purpose
positional, positionals kept (no sibling deprecates them; the plan-phase
workflow caller passes positionals), valueless --context falls through
to the #2770 fail-closed caller error. Repair of the routing accident
where --context landed in the args[2] phase slot (false uncovered) or
the literal token in the args[3] context slot (silent green skip).

(B) parseDecisions regex seam hardened, byte-identical on all legal
inputs: the three bullet grammars consume the ID atomically via the
(?=(X))\1 lookahead emulation (kills the tail/[^:*]* O(n^2) re-split,
~1.1s @ 40k), and the em-dash first separator narrows [^*]*[—–] to
[^*—–]*[—–] (kills the dash-position O(n^2) retry, ~1.7s @ 40k). Group
indices unchanged (handlers untouched). Pinned by regex-lattice tests,
a differential fast-check property vs the frozen pre-hardening grammars,
and 40k cliff/legal-shape outcome tests (no wall-time asserts per repo
rule — no deterministic engine step counter exists in Node).

* docs+test(#4130): document --context invocation; harden lattice test tooling

- docs/CONFIGURATION.md Decision Coverage Gates: new 'Invoking the plan
  gate directly' block documenting both the positional and --context
  forms, flag precedence, and the valueless-flag fail-closed semantics
  (same place the gate's behavior is documented; sibling check predicate
  documents its flags the same way).
- Two changeset fragments per the maintainer brief (Added: flag; Fixed:
  hardening), PR numbers to be backfilled.
- tests/decisions.test.cjs review fixes: readRegExpTemplate template
  escaping (bare ')' SyntaxError), range-aware lattice checker with
  backreference skip and template unescape, honest A1 contract, lint
  escape warning.

* fix(#4130): valueless --context fails closed per #2770; A8 isolates flag-vs-positional context

Suite-caught fixes from the first verify run:
- cmdDecisionCoveragePlan now refuses a flag-shaped token as the
  positional context path: a bare valueless --context stays a positional
  (sibling parser semantics, unchanged) but reading it as a PATH would
  turn a caller mistake into a silent 'CONTEXT.md missing' green skip —
  exactly what #2770's fail-closed law forbids. Now falls through to
  the missing-context-argument error, as documented.
- A8 test compares decoy-positional+flag against flag-with-phase (phase
  held constant) so the row isolates WHICH context was read; the old
  form compared against a no-phase invocation that could never match.

* chore(#4130): backfill PR number in changeset fragments (PR #4374)

---------

Co-authored-by: sim <sim@local>
2026-09-06 02:55:17 -04:00
Tom Boucher
6adf3098ac fix(#4145): resolve gsd-pristine/ baselines by recorded hash, relocate orphans (#4364)
* test(#4145): regression rows for hash-matching prefix-less pristine baselines

RED skeleton: src/pristine-baseline.cts exports findPristineByHash as a
null-returning stub so the new rows fail behaviorally, not at require time.
Failing-first rows: verifier resolution (no_baseline must drop to 0 when an
exact-hash orphan exists), findPristineByHash unit row, and the two
saveLocalPatches relocation rows. Negative-space rows pin today's behavior:
missing baselines still report ok_no_baseline, mismatching orphans are never
adopted or deleted, canonical precedence and the #3657 drift posture are
untouched.

* fix(#4145): resolve gsd-pristine/ baselines by recorded hash, relocate orphans

Both pristine readers joined the manifest-keyed path strictly, so a snapshot
stored without the gsd-core/ prefix (an earlier release's writer) was reported
as ok_no_baseline by the verifier and pushed into regeneration by
saveLocalPatches — where incoming-release candidates can never satisfy the
recorded outgoing hash, leaving the correct baseline permanently unconsumed.

- src/pristine-baseline.cts (new, ADR-457): shared findPristineByHash —
  deterministic sorted scan of gsd-pristine/, exact sha-256 equality with the
  recorded pristine_hashes entry (the same authority the #3657 drift guard
  trusts), symlink-skipping, canonical path excluded via skipRel.
- verify-reapply-patches.cjs verifyFile(): on canonical miss with a recorded
  hash, adopt byte-identical content found anywhere under gsd-pristine/ before
  reporting OK_NO_BASELINE. Drift posture (#3657), canonical precedence, and
  the frozen REASON/report shapes are untouched; the verifier stays read-only.
- install.js saveLocalPatches(): preserve-check rescue — relocate a
  hash-matching orphan to the canonical path (copy, hash-verify, then remove
  the orphan) so the state self-heals on the next update instead of repeating
  forever. Honest accounting: new non-overlapping rescued counter.
- Workflow doc: one-sentence note on hash-based snapshot resolution.
- Derived ripples: INVENTORY-MANIFEST.json regen, eslint ignore + .gitignore
  entries for the compiled artifact, seedFixture mkdir fix in the new rows.

Emitted-Drift-Ack-Growth: reapply-patches.md — one-sentence note on hash-based pristine snapshot resolution (#4145)

* fix(#4145): review follow-up — orphan scan never consumes a canonical path

Adversarial review finding: with two modified files sharing byte-identical
outgoing content, recoverOrphanedPristine could adopt the OTHER file's
canonical pristine as its rescue source — relocating it (copy + delete at
its home path) and ping-ponging the single baseline between the two files
across updates. findPristineByHash's skip parameter now accepts a Set, and
saveLocalPatches passes the normalized manifest keys so every canonical
path is excluded; only genuine non-canonical orphans are eligible for
removal (no strict-join reader ever consults those). Adds the
canonical-theft regression row, a Set-skip unit assertion, and tightens the
workflow doc sentence the same pass flagged as overstated.

* fix(#4145): INVENTORY roster row + symlink-fixture correction

Two leftovers from the ab17b7a1e5 bench run, both root-caused:
- docs/INVENTORY.md roster row for cli_modules/pristine-baseline.cjs
  (#3762 gate: every manifest entry carries a row).
- The findPristineByHash symlink unit fixture placed its symlink target
  INSIDE the scanned root, so the walk legitimately matched the real target
  file. The implementation skips the symlink itself; the fixture now keeps
  the target outside the scanned tree so the assertion tests what it claims.

* changeset(#4145): fixed fragment for pristine baseline hash resolution

---------

Co-authored-by: gsd-agent <agent@gsd.local>
2026-09-06 02:04:18 -04:00
Tom Boucher
d5a85da8ab fix(#4363): bump download-artifact and setup-node off node20 runtimes (#4365) 2026-09-06 00:03:05 -04:00
Tom Boucher
c3e2da153b fix(#4134): refuse punctuation-only milestone heading names (#4358)
* test(#4134): fail-first regression — refuse punctuation-fragment milestone names

A first-milestone ROADMAP.md H1 that puts the version after the name
(# Roadmap: Project — Name (v1.13)) leaves exactly ')' after the heading's
own version token, which the ADR-3180 §7.2 pinned name rule returns as a
COMPLETE-scope milestone name. Failing-first coverage:

- getMilestoneInfo: name-then-version H1 (STATE-anchored + ROADMAP-only
  fallback) must yield TRUNCATED {version, name: null}, never ')'
- the refusal is level-agnostic (H2/H3)
- punctuation-family remainders (')', '()', '**', '.,;:', ']}', emoji-only)
- listMilestoneHeadings enumerates the heading with name: null
- init manager CLI reports milestone_name: null and no lone ')' anywhere
- property (seed 20260905, 300 runs): a word-char remainder is always a
  name, a punctuation-only remainder never is
- negative space: canonical delimiter forms, parenthetical names (#3171),
  trailing markers, digit-only names, CRLF headings, version-last-no-parens
  control

* fix(#4134): refuse punctuation-only milestone heading names

extractMilestoneHeadingName returns everything after the heading's own
version token as the name (ADR-3180 §7.2 pinned rule), which assumes
version-then-name. A name-then-version heading — the H1 a first-ever
ROADMAP.md drifts into ('# Roadmap: Project — Name (v1.13)') — leaves
exactly ')' after the token, and that fragment was returned as a
COMPLETE-scope milestone name, propagating into init.* JSON output and
buildStateFrontmatter's STATE.md writes.

A remainder with no letter or digit anywhere (any script) is heading
structure, not a curated name: refuse it as name: null so callers report
the honest §7.2 rule-6 answer (version kept, TRUNCATED scope). Names
that merely contain punctuation are unaffected — '(' stays an ordinary
name character (#3171) — and digit-only names qualify.

Also closes the template gap that lets the shape occur: the roadmapper
agent's output_formats now templates the version-free canonical H1
('# Roadmap: [Project Name]', per templates/roadmap.md) instead of
leaving a first milestone's title line to invention. The new section
shifts the file's existing bare-gsd-tools prose mention from line 647
to 660, so its line-keyed PROSE_ALLOWLIST entry moves with it.

Emitted-Drift-Ack-Growth: gsd-roadmapper.md — deliberate +498 bytes: new '### 0. Top-Level Title (H1)' output_formats section templating the canonical version-free H1, closing the first-milestone template gap that lets an H1 drift into 'Name (vX.Y)' and corrupt milestone_name extraction (#4134)

* chore(#4134): add changeset

* chore(#4134): backfill PR number in changeset

---------

Co-authored-by: sim <sim@local>
2026-09-05 23:31:35 -04:00
Tom Boucher
e6d047decc fix(#4129): derive completed_phases from the ROADMAP authority; honor the progress-ratchet on every state write (#4359)
* test(#4129): failing-first regressions — completed_phases clobber on resyncing writes and phase-complete failure to increment

* fix(#4129): completed_phases derives from the ROADMAP authority and the write path honors the progress-ratchet

Three coordinated prongs (diagnosis in .gsd/bug/fix-4129-completed-phases-recompute/):

P1 — buildStateFrontmatter's disk scan floors the completed-phases numerator at
the milestone-scoped ROADMAP Complete-row count (deriveProgressFromRoadmap, the
one owner), gated inside the same safeToUseRoadmapCount / not-withheld branch
that owns the denominator. A completed phase whose verification routes stale
(#2348 clean-commit-time drift) or is missing no longer under-counts forever.

P2 — applyPreserveAlways's resync arm merges instead of wholesale-replacing on
a measured scan: totals derived both directions (#2440), completed counters
up-only (#2969 — the schema-declared progress-ratchet, now enforced on the
write path like the read path always has), percent recomputed from the merged
counters. The #3756 unmeasured guard and the #3242 explicit-progress contract
are unchanged.

P3 — phase complete's atomic 3-file commit passes the post-completion
ROADMAP-derived counters through the #2736 authoritativeFm seam (new object
direction for the progress key; completedOnlyRaise at the post-preservation
re-assert), because the transaction's disk scan reads the pre-completion
ROADMAP and failed to increment on the completing phase's own write.

* fix(#4129): adversarial-review hardening — intent is a floor at BOTH authoritativeFm sites

The pre-preservation merge could lower a correctly-higher disk-derived
counter (a verification-passed phase whose ROADMAP table row drifted behind
the disk signal). completedOnlyRaise now governs both application sites: the
intent and the derivation agree on direction (up), never on subtraction.

* fix(#4129): the ratchet merge keeps derived values verbatim when numerically equal

The re-parsed derived block carries string scalars ("2") while the curated
snapshot carries numbers (2); substituting the curated spelling over an
equal derived one was a no-op in substance but a shape churn the ADR-3473
§8.7 reporting loop surfaced as a phantom preserved-over-disagreeing-derived
warning on phase complete (ADR-3408 §8.5 Matrix B). Only a strictly-greater
curated counter replaces the derived value now; percent gets the same
verbatim rule.

* changeset(#4129): backfill PR 4359

---------

Co-authored-by: sim <sim@local>
2026-09-05 23:02:07 -04:00
Tom Boucher
bd75d42f52 Merge pull request #4362 from open-gsd/chore/backmerge-main-to-next-b67f6028c
chore: back-merge main → next (b67f6028c)
2026-09-05 22:11:10 -04:00
github-actions[bot]
0293afd108 chore: back-merge main into next (b67f6028c) 2026-09-06 02:10:44 +00:00
Tom Boucher
519bb60263 Merge pull request #4361 from open-gsd/chore/sync-next-version-1.13.0
chore: sync next package version to 1.13.0
2026-09-05 22:10:36 -04:00
github-actions[bot]
b3906c66f6 chore: sync next package version to 1.13.0 2026-09-06 02:10:29 +00:00
Tom Boucher
b67f6028ce Merge pull request #4360 from open-gsd/release/1.13.0
chore: merge release v1.13.0 to main
2026-09-05 22:10:26 -04:00
github-actions[bot]
b5b9814f03 chore: promote CHANGELOG for v1.13.0 2026-09-06 02:09:37 +00:00
github-actions[bot]
d0bf2c5165 chore: finalize v1.13.0 2026-09-06 02:09:28 +00:00
Tom Boucher
06eba5fdb0 fix(#4130): parse phase-prefixed decision IDs (D4-01) (#4357)
* test(#4130): failing-first regression for phase-prefixed decision IDs

Add the #4130 matrix: D4-01/D12-01 across all three bullet forms, tags,
discretion, wrapped lead-ins, gate-level plan/verify end-to-end rows, and
parity properties (well-formed digit-prefixed ids parse to their exact id;
a non-digit injected into the prefix fails loud). Update the #2347
non-D-prefix fixture from D5-NN (now a legal grammar) to DEC-NN, and
graduate the representative d5-prefix corpus fixture from could-not-parse
to parsed-but-uncovered.

All new rows are RED against origin/next; they go green with the parser
fix in the next commit.

* fix(#4130): parse phase-prefixed decision IDs (D4-01)

The three declaration grammars, the parse-miss guard, the #3939 join
regexes, and the token evidence all anchored on the literal 'D-' (or
'**D-'), so an ID carrying a digit-run phase prefix between the leading
letter and the hyphen matched nothing — while the #2347 shape detector
correctly called those bullets decision-shaped, collapsing the whole
CONTEXT.md to could-not-parse with 0 extracted instead of a coverage
verdict.

Derive the extractor ID grammar from one shared DECISION_ID_SOURCE
('D[0-9]*-' + the existing alnum tail, full id captured), widen the
guard/join anchors to ID_ATTEMPT_SOURCE (bare 'D-' or a digit-initial
prefix run, so a typo'd 'D4x-01' fails loud while letter-initial prose
like 'Deferred-until' stays none-present), and align the bare-token
evidence. Both gates and the gap-checker share the parser, so all three
surfaces read phase-prefixed decisions now; the gate messages name the
accepted forms including the phase-prefixed one.

* docs(#4130): document the phase-prefixed decision identifier form

The canonical CONTEXT.md reference said decisions carry 'a sequential
D-NN identifier' with no mention of the optional phase-number prefix the
parser now accepts (D4-01) or the alphanumeric tail it always accepted
(D-INFRA-01). Name both in the Decision identifier format section, EN
and ja-JP.

* chore(#4130): changeset

* chore(#4130): backfill PR number in changeset

---------

Co-authored-by: sim <sim@local>
2026-09-05 21:05:19 -04:00
Tom Boucher
48789fe9a9 fix(#4355): add --merge-async to test:coverage:unit:raw (#4356)
* test(#4355): assert test:coverage:unit:raw carries --merge-async

tests/c8-merge-async-flag.test.cjs asserted the opposite based on a
disproven assumption that `--reporter none` skips c8's merge phase --
it does not (Report.run() computes the merge unconditionally before
consulting the reporter list). This is the failing-first assertion for
the fix in the next commit.

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

* fix(#4355): add --merge-async to test:coverage:unit:raw

c8's Report.run() computes the full coverage merge unconditionally,
even with --reporter none -- it only skips the final text/json output,
not the merge dispatch (verified against node_modules/c8/lib/report.js).
Without --merge-async this used the synchronous _getMergedProcessCov(),
loading every raw per-process V8 coverage dump into memory at once and
OOM-crashing release.yml's finalize-test job (run 33997057100) with a
silent exit 1 and no diagnostic ~21s after the test suite itself
finished cleanly ("# fail 0"). Same class already fixed on the other
three coverage scripts via #4068/#4172.

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

* docs(changeset): add changeset for #4355 coverage:unit:raw fix

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

* docs(changeset): backfill PR number for #4355 fix

pr:0 -> pr:4356

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

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 20:28:51 -04:00
Tom Boucher
a65cb291e8 fix(#4105): park the #3889 hang fixture on a settling timer (#4349)
* test(#4105): guard the #3889 hang fixture — must genuinely hang by itself and self-terminate

RED at this sha: against the current never-settling-promise body the guard
fails on the matrix line (Node 24: the unheld promise never self-terminates,
ceiling expires) and off it (v22-class runtimes: the child exits rc=1 after
~60ms, never reaching the still-hanging checkpoint). Same shape as the #4104
self-exit regression: spawn the exact served body, observe liveness past the
chunk bound and natural exit — no elapsed-value assertions.

* fix(#4105): park the #3889 hang fixture on a settling timer

The never-settling promise held no libuv handle, so the hang T1/T4 rely on
was a property of the runtime's test-runner shutdown behavior, not of the
fixture: v24/v26 happen to hold the loop open; v22-class runtimes exit rc=1
after ~60ms (# cancelled 1), so the chunk never reaches the timeout path and
the two timeout assertions assert nothing. Park on a settling 10s timer (the
#4104 idiom): an explicit handle makes the hang the fixture's on every Node
line, 10s >> the 2000ms chunk bound (margin asserted structurally in the
#4105 guard), ~0% CPU while parked, and guaranteed self-termination if a
kill orphans it. Behavior on the Node 24 matrix line is unchanged — the
chunk is still killed by the harness timeout (~2006ms) with the identical
diagnostic.

* test(#4105): drive the fixture guard off the child's exit event + runner timeout

Review-driven restructure (Memtrace flaky_test_fixed_sleep on the 200ms poll
interval): the guard now waits on the child's natural 'exit' event — no
polling interval, no hand-rolled watchdog setTimeout. The immortal-body bound
is the node:test per-test { timeout: 2 * HANG_PARK_MS } backstop, the
health-validation #663 house pattern and the no-elapsed-assertion-compliant
form. t.after still reaps the child on every path. Same failing-first arms:
still-hanging checkpoint, natural-exit (no signal), exit code 0.

* changeset(#4105)

* changeset(#4105): backfill PR number

---------

Co-authored-by: sim <sim@local>
2026-09-05 19:39:35 -04:00
Tom Boucher
0be5bf865a enhance(#3783): audit-uat summary segments current-milestone vs archived debt (#4336)
* test(#3783): add failing coverage for audit-uat summary segmentation

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 19:03:14 -04:00
Tom Boucher
f9f72cb54c enhance(#3777): opt-in concurrent per-plan planners in chunked mode (#4346)
* test(#3777): add failing-first coverage for concurrent per-plan planner dispatch

Extracts and executes the real bash blocks this PR is about to add to
plan-phase.md and chunked-planning-mode.md (CHUNKED_PARALLEL resolution and
the BATCH_PLAN_IDS dedup guard), plus config-set/config-get coverage for the
new planning.chunked_parallel key. Expected RED against the current shipped
workflow text — the extraction anchors do not exist yet.

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

* feat(#3777): dispatch chunked mode's per-plan planners concurrently within a Wave

Adds opt-in planning.chunked_parallel (default false, byte-identical to the
existing serial loop). When true and the runtime's negotiated dispatch
capacity (dispatch-capacity, #3673) is greater than 1, chunked planning's
per-plan Tasks that share one outline Wave are issued together instead of
one at a time; a later Wave still waits for the current one to be verified
on disk and committed. A host with no declared maxConcurrency (most
non-Claude runtimes today) stays serial regardless of the setting.

Resolution and the Plan-ID dedup guard live in chunked-planning-mode.md
itself (gated on the section's own CHUNKED_MODE skip-check) rather than in
plan-phase.md, so a non-chunked run pays no extra gsd_run calls.

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

* test(#3777): repoint extraction at chunked-planning-mode.md after the move

CHUNKED_PARALLEL resolution moved out of plan-phase.md into
chunked-planning-mode.md itself (see the preceding commit); update the
test's extraction path and header comment to match.

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

* fix(#3777): relocate the canonical runtime-launcher preamble before its first use

The CHUNKED_PARALLEL resolution block's two gsd_run calls landed earlier in
the file than the sole existing preamble (in the commit step), which
tests/runtime-launcher-parity.test.cjs's (B) check requires to precede every
gsd_run call in the file. Move the preamble (not duplicate it) to the top of
the resolution block; the commit step's fenced block now just calls
gsd_run directly.

Caught by the GREEN checkpoint gsd-test run before push.

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

* fix(#3777): strip the canonical preamble from the extracted resolution block

The CHUNKED_PARALLEL resolution fence now carries the relocated
runtime-launcher preamble as its first line (previous commit). Extracting
the whole fence and running it after the test's own gsd_run stub let the
embedded preamble's own resolver logic `unset -f gsd_run` and exit 1 before
reaching the resolution logic, since no real gsd-tools.cjs exists in the
temp script dir — every test calling runChunkedParallelResolution() failed.

Strip the preamble (sourced from gsd-core/workflows/_runtime-launcher.snippet.sh,
the same file scripts/sync-runtime-launcher.cjs treats as canonical) before
splicing in the stub, so this suite tests only the resolution logic it is
actually about.

Caught by the post-rebase gsd-test run before push.

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

* docs(#3777): add the How-To page the phase gate requires

Enablement is 2 commands (config-set, then --chunked), which this repo's
own doc-quadrant gate flags as how-to-owed: a reference table cannot carry
a sequence. Covers enablement, the dispatch-capacity gate's honest
"most runtimes today: no effect" case, and the two accepted trade-offs.

An earlier reasoning pass (recorded in .gsd/phase/.../70-docs.json before
this commit) had incorrectly claimed #3034 shipped with no equivalent
how-to page, as precedent for skipping one here. That claim was false —
docs/how-to/enable-parallel-reviewer-lanes.md exists and is indexed. The
phase gate caught the omission before merge; corrected here.

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

* docs(#3777): backfill changeset PR number

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

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 18:57:17 -04:00
Tom Boucher
1db726ebbf feat(#3806): canonize the Review Dispositions Ledger contract (#4345)
* test(#3806): add parity tests for the Review Dispositions Ledger contract

Failing-first: asserts references/planner-reviews.md, workflows/plan-phase.md,
and agents/gsd-plan-checker.md agree on a single canonical "Review Dispositions
Ledger" heading, its round-scoping, L##@{sha} anchor format, and append-only
supersession rule. These fail until the canon and its two references are added.

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

* feat(#3806): canonize the Review Dispositions Ledger contract

Promote the existing planner-reviews.md Step 4 return-payload tables
(Review Feedback Addressed/Deferred) into a canonical `## Review
Dispositions Ledger` PLAN.md section, stated once in planner-reviews.md
and referenced (not restated) from plan-phase.md's
<review_incorporation_contract> and gsd-plan-checker.md's Review
Incorporation dimension. Adds round-scoping (`### Round {N} —
{REVIEWS_sha}`), a `L##@{sha}` line-anchor format so a REVIEWS.md
reference survives the file being rewritten each round, and an
append-only supersession rule. Scoped to part 1 only per the
maintainer's approved-feature verdict — the deterministic lint/check
verb (part 2) is explicitly deferred to a follow-up.

Also: ADR-3806 recording the decision, a docs/features/ fragment
(FEATURES.md is generated), and a changeset fragment.

Closes #3806

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

* fix(#3806): fenced-example count bug and lint findings from review

- tests/plan-review-convergence.test.cjs: the "heading exactly once"
  test counted the canonical heading text globally, so it also matched
  the illustrative fenced-code example in planner-reviews.md that shows
  the same heading as sample content, always failing 2 !== 1. Rewritten
  as a bounded line scanner that skips fenced blocks (found by an
  isolated adversarial review pass). Also bounded an unbounded regex
  quantifier over readFileSync content flagged by
  local/no-unbounded-quantifier.
- docs/features/review-dispositions-ledger.md: match house fragment
  style (bold-lead paragraphs, not #### headings) per the Standards-axis
  review; regenerated docs/FEATURES.md.

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

* fix(#3806): fit reference-cite fix within size hard caps; ack growth

Trims the plan-phase.md / gsd-plan-checker.md reference-cite text to a
single short clause pointing at gsd-core/references/planner-reviews.md
(also fixes the bare `references/planner-reviews.md` cite the #3576
shipped-reference-cites gate rejects), bringing both files back under
their SIZE hard caps and the plan-phase.md phase6 shrink-only baseline.
Both files still grow slightly versus origin/next, acknowledged below
per ADR-2719's emitted-drift-ack contract.

Emitted-Drift-Ack-Growth: gsd-plan-checker.md — adds a short pointer (in the existing Review Incorporation bullet) to the canonical Review Dispositions Ledger location (#3806); stays within the LARGE hard cap.
Emitted-Drift-Ack-Growth: plan-phase.md — adds a short pointer (in the existing review_incorporation_contract bullet) to the canonical Review Dispositions Ledger location (#3806); stays under the XL hard cap and the phase6 shrink-only baseline.

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

* fix(#3806): correct malformed Emitted-Drift-Ack-Growth trailer block

The previous commit's two Emitted-Drift-Ack-Growth trailers were
separated from the Co-Authored-By trailer by a blank line, so git's
own trailer parser (which tests/helpers/emitted-runtime.cjs reads via
`%(trailers:key=...)`) only recognized the last contiguous block
(Co-Authored-By) and treated the Ack-Growth lines as ordinary body
text — invisible to the emitted-attribution gate, not malformed data.
Restating them here as one contiguous trailer block, git log over the
PR range aggregates trailers from every commit, so this is additive.
Emitted-Drift-Ack-Growth: gsd-plan-checker.md — adds a short pointer (in the existing Review Incorporation bullet) to the canonical Review Dispositions Ledger location (#3806); stays within the LARGE hard cap.
Emitted-Drift-Ack-Growth: plan-phase.md — adds a short pointer (in the existing review_incorporation_contract bullet) to the canonical Review Dispositions Ledger location (#3806); stays under the XL hard cap and the phase6 shrink-only baseline.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#3806): isolate the ack-trailer paragraph as its own trailer block

Git's trailer parser requires the trailer paragraph to be the message's
final paragraph, preceded by a blank line, and to contain nothing but
trailer-shaped lines. The prior commit's blank line before the trailer
lines was missing, which folded the leading Emitted-Drift-Ack-Growth
lines into an ordinary prose paragraph.

Emitted-Drift-Ack-Growth: gsd-plan-checker.md — adds a short pointer (in the existing Review Incorporation bullet) to the canonical Review Dispositions Ledger location (#3806); stays within the LARGE hard cap.
Emitted-Drift-Ack-Growth: plan-phase.md — adds a short pointer (in the existing review_incorporation_contract bullet) to the canonical Review Dispositions Ledger location (#3806); stays under the XL hard cap and the phase6 shrink-only baseline.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(#3806): backfill PR #4345 into changeset and ADR

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

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 18:50:27 -04:00
Tom Boucher
ea91268d02 ci(#4335): shard release.yml rc/finalize unit-suite tests (#4338)
* ci(#4335): shard release.yml rc/finalize unit-suite tests

The finalize job's unsharded unit-coverage step outgrew the 30-minute job
timeout that was already raised once for this exact symptom (#2280): run
33988966357 finished all tests with 0 failures at 28m26s, then got cancelled
~80s into the post-test coverage merge — a phase that historically completes
in 54-101s. The suite's wall-clock time, not a hang, ate the budget.

test.yml already fixed the identical cliff for its own full-scope lane
(#2952, #3057) by sharding the unit suite 3 ways with a separate merged
coverage-gate job. Apply the same pattern to rc and finalize (rc has the
byte-identical unsharded shape and would hit the same wall next): each gains
a `*-test` matrix job (raw coverage only, no report/gate) and a
`*-coverage-gate` job that merges the shards' raw V8 dumps before enforcing
the existing gsd-core/bin/lib coverage floor. rc/finalize now depend on their
gate job instead of running the suite inline.

Updates release-coverage-scope.test.cjs's exact-count assertion for the new
command surface and adds release-shard-lane-sharding.test.cjs to pin
shard-set completeness and gate wiring, mirroring ci-full-lane-sharding.test.cjs.

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

* Potential fix for pull request finding 'CodeQL / Cache Poisoning via execution of untrusted code'

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* Potential fix for pull request finding 'CodeQL / Cache Poisoning via execution of untrusted code'

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* fix(#4335): close CodeQL cache-poisoning and missing-permissions findings

CodeQL flagged the PR (10 actions/cache-poisoning/poisonable-step errors, 4
actions/missing-workflow-permissions warnings) on release.yml.

Remove `cache: 'npm'` from every actions/setup-node step in the file (7
occurrences, not just the 4 newly-added jobs the alerts pointed at) —
restoring an npm cache before running install/build code in a
write-permissioned job is exactly the shape this query targets, and the
same pattern was already present unchanged in create/rc/finalize. These are
short CI/release jobs; losing npm's install cache costs a few seconds per
job, closing the finding everywhere it appears in this file rather than
only where the alert happened to land on a changed line.

Add explicit `permissions: contents: read` to rc-test, rc-coverage-gate,
finalize-test, finalize-coverage-gate — the four new jobs had no
permissions block at all and inherited the ambient default. Matches
validate-version's existing least-privilege pattern; create/rc/finalize
keep their own broader write/publish scopes unchanged.

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

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-09-05 18:49:35 -04:00
Tom Boucher
c20675cc4d fix(#3819): widen executor's pre-commit guard beyond worktree mode (#4343)
* fix(#3819): widen executor's pre-commit guard beyond worktree mode

The pre-commit protected-branch assertion in the executor agent (#2924)
only fired inside a Claude Code worktree and matched a hardcoded
five-name branch list. It never ran in an ordinary checkout and never
covered this repo's own default branch ("next"), so gsd-executor could
commit planning-repo documents directly onto a shared checkout's
default branch with no PR ever created.

Widen the guard to run in every isolation mode, and resolve the
protected branch via the repository's actual default branch (with the
existing five-name list retained as a fallback when the resolver
itself cannot be invoked) plus any configured git.protected_branches.
Add a git.allow_default_branch_commits escape hatch for projects that
intentionally execute on their default branch. Also point the
separate <final_commit> commit helper back at the same guard, so it
cannot be sidestepped by that path.

Emitted-Drift-Ack-Growth: gsd-executor.md — widened pre-commit protected-branch guard (#3819); tightened comments to stay under the size cap.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(#3819): backfill changeset PR number

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

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 18:35:42 -04:00
Tom Boucher
4e1c449281 enh(#3811): add hooks.commit_types config surface to gsd-validate-commit (#4340)
* enh(#3811): add hooks.commit_types config surface to gsd-validate-commit

Extends the opt-in Conventional Commits hook with a hooks.commit_types
config array that adds project-specific types to the 10 built-ins
without replacing them. Configured values pass a safe-token filter
before reaching the compiled regex, so a config entry can never alter
the pattern's structure. The regex alternation, the human-readable
error text, and a new typed valid_types JSON field all derive from one
list instead of the two hand-synced copies this replaces.

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

* chore(#3811): backfill changeset PR number

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

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 18:35:09 -04:00
Tom Boucher
7533cb4454 fix(#4104): park the prohibition-enforcement hang fixture on a settling timer (#4331)
* test(#4104): add self-exit regression for the hang fixture (RED at this sha)

* fix(#4104): park the hang fixture on a settling timer instead of a busy loop

The prohibition-enforcement hang fixture busy-looped `while (true) {}`, so a
worker orphaned by a killed runner burned a core forever. It now parks on a
10s settling setTimeout — still hung for any enforcement bound, ~0% CPU if
leaked, and guaranteed to self-terminate. A regression test spawns the exact
served body and asserts it self-exits with no signal.

* test(#4104): harden the self-exit regression (signal/spawn-error paths)

* changeset(#4104)

* changeset(#4104): backfill PR number

---------

Co-authored-by: sim <sim@local>
2026-09-05 18:10:04 -04:00
Tom Boucher
17e163f15c docs(#4333): document the ADR Amends/Amended-by convention (#4334)
* docs(#4333): document the ADR Amends/Amended-by convention

Two patterns for amending an accepted ADR are established practice —
an in-place `## Amendment (YYYY-MM-DD)` section, and a separate ADR
that declares `Amends` with a reciprocal `Amended by` back-link — but
only the first was ever written down. #4030 shows the cost: a
contributor concluded no ADR owned a contract that ADR-857 already
covers, because nothing said the second pattern (used by ADR-1244 and
ADR-2782 to extend ADR-857 itself) existed.

Document both patterns in docs/contributor-standards.md, note the
Amends/Amended-by reciprocity rule in docs/adr/README.md alongside the
existing Supersedes/Subsumes rule (and that it isn't yet gated by
scripts/gen-adr-index.cjs the way those are), and point CONTRIBUTING.md's
new-ADR process at the amendment path for revisiting an existing one.

Closes #4333

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

* docs(#4333): fix imprecise Amends/Amended-by precedent citations

Orthogonal review caught two inaccuracies: PR #1643 doesn't match the
in-place dated-section pattern (it rewrites the original Decision text
rather than appending an untouched dated section), and ADR-1244's
relationship to ADR-857 is prose ("extended by"), not the structured
Amends/Amended-by header field. ADR-2782 is the verified precedent for
the structured field pair — its one Amends field names four targets
(857, 894, 1016, 1244), all four carrying the reciprocal back-link.

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

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 16:45:57 -04:00
Tom Boucher
2f920c5af3 fix(#4097): evidence preservation no longer sweeps the run's own input copies into .review-diagnostics/ (#4329)
* test(#4097): failing-first regression — run input copies swept into .review-diagnostics

The present_results preserve+cleanup glob `_DIAG_MD=( "$RUN_DIR"/gsd-review-*.md )`
matches both lane outputs and the run's own assembled input copies (prompt,
instructions, roadmap, per-plan copies, project/context/research/requirements,
per-lane trimmed prompts) because both share the gsd-review- prefix.

Seed the full input-copy set into the existing runWriteReviewsFlow fixture and
assert (a) the diagnostics dir holds exactly the lane report + .err sidecar and
no input basenames, and (b) an inputs-only run creates no diagnostics dir at all
and still cleans up. Both RED against the shipped block.

* fix(#4097): preserve lane output only — exclude the run's input copies from evidence

The preserve+cleanup glob treated every gsd-review-*.md in RUN_DIR as lane
evidence, but the workflow itself writes the run's assembled INPUTS there under
the same prefix (prompt, instructions, roadmap, per-plan copies, project/
context/research/requirements, per-lane trimmed prompts). Filter _DIAG_MD by
basename against that closed input set instead.

Direct glob iteration + case filter — identical under bash and zsh (#4099/
#4109), nullglob-safe (#2962), and the exclusion list is closed and owned in
this step: a future input basename cannot silently rejoin the evidence set.
Lane reports, diagnostic stubs and non-empty .err sidecars are unchanged.

Emitted-Drift-Ack-Growth: review.md — #4097 narrows the present_results evidence-preservation glob: the closed input-basename exclusion list (case filter) plus its rationale note are deliberate additions so a future input basename cannot silently rejoin the evidence set.

* changeset(#4097): fixed — review diagnostics no longer sweep run input copies

* changeset(#4097): backfill PR number 4329

---------

Co-authored-by: sim <sim@local>
2026-09-05 16:45:43 -04:00
Zy Deng
4c60879b5d fix(#4132): verify durable runtime surface sources (#4182)
* fix(#4132): verify durable runtime surface sources

* chore(#4132): record PR number in changeset

* test(#4132): cover rejected commands source alias

* fix(#4132): reject aliased package fallback

* test(#4132): cover rejected agents source alias

* test(#4132): cover partially aliased marker provider

* fix(#4132): reject partially aliased source providers

* test(#4132): cover routed source identity probes

* fix(#4132): route installed source identity probes

* refactor(#4132): tighten installer source metadata

* test(#4132): cover corpus trust boundary attacks

* fix(#4132): close installed corpus trust gaps

* refactor(#4132): keep installer authority private

* fix(#4132): preserve private installer fallback

* test(#4132): preserve fixture source authority

* fix(#4132): reject overlapping source fallback

* fix(#4132): avoid redundant installed corpus reads

* refactor(#4132): simplify provider resolution

* test(#4132): sync install tree fixtures after rebase

---------

Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-05 15:32:52 -04:00
Dennis Alexis Valin Dittrich
1017898cb9 fix(#3771): make remediation examples non-binding and surface revision conflicts (#3916)
* fix(#3771): separate the binding property from the advisory remediation

Checker findings fused "what property failed" with "how to fix it" into a
single `fix_hint` and never marked which half binds. The checker rendered
every hint under a "must fix" heading, the orchestrators injected the issues
verbatim and ordered targeted updates, and the shared revision references
mapped each hint to a prescriptive strategy — so a contract-following planner
applied a hint literally even when a smaller mechanism satisfied the same
property, or when the hint contradicted a locked decision. There was no
channel to report that conflict, and every attempt burned a revision
iteration.

Checker side: every issue now carries a binding `required_property` (the
invariant that failed) plus its evidence and severity, and `fix_hint` is
labelled non-binding wherever it appears — including the human-facing blocker
rendering, so "must fix" unambiguously names the property and never the
example.

Planner side: revision re-checks locked decisions, capability guidance and
existing plan constraints before editing; satisfying a blocker through a
smaller valid alternative counts as addressing it; and a hint that conflicts
with any of those returns `REVISION_CONFLICT` carrying the conflict and the
alternatives considered. Orchestrators route that to user choice or the
configured plan-review convergence loop without consuming retry budget.

Also applied to the UI-spec revision loop and the gap-plan hint, and the
generic pattern's stray `suggested_fix` field name is reconciled to the
plan-checker's `fix_hint`.

Nothing legitimately binding is weakened: blockers still block, severity
still gates, iteration caps and stall escalation still fire, and required
task fields and decision coverage still hold.

Refs #3771

* test(#3771): pin the binding/advisory split across the revision chain

Locks the separation at every link that carries it: the checker's issue
schema and blocker rendering, the planner's constraint re-check and
REVISION_CONFLICT return, the generic pattern's reconciled field names, and
each orchestrator's conflict routing without retry-budget consumption. Also
pins what must not have been weakened — blockers, severity gating, iteration
caps and stall escalation.

Red against the pre-fix prose: 32 of 34 assertions fail (the 2 that pass are
the preservation checks, correctly).

Refs #3771

* chore(#3771): add changeset fragment for the remediation-binding fix

* chore(#3771): acknowledge the remediation-binding growth

Five runtime-loaded files grow: the two checkers carry the binding/advisory
split where the model reads it (a `required_property` on every dimension
example, since a schema the examples contradict teaches the examples), and
the three orchestrators carry the REVISION_CONFLICT route, which has to live
with the `iteration_count`/`revision_count` state it declines to spend.

Deletes tests/emitted-drift-acks/3172-stated-failing-direction.json: it is
fully spent on next and still owned plan-phase.md, so it walls off a key it
can no longer clear (#3078). Its removal is the documented remedy for the
duplicate-key collision, not drive-by cleanup.

* fix(#3771): close the review gaps in the conflict contract

Adversarial review (Codex, Antigravity) found four real defects in the first
pass, each confirmed against the source before acting:

- The UI checker's structured return still ordered `Fix: {exact fix required}`
  and "list each BLOCK dimension with exact fix required". The dimension
  examples had been marked non-binding but the rendering the researcher
  actually reads had not — the same omission this issue is about.
- `ui-phase` and the canonical `revision-loop` flow incremented their counter
  BEFORE dispatching the reviser, so "do NOT increment on REVISION_CONFLICT"
  was unreachable prose: the iteration was already spent. The increment now
  sits on the return path in both.
- The conflict gate offered "accept as-is", which is an early exit from a
  still-failing blocker — a weakening the brief explicitly forbids. The three
  options are now adopt an alternative / override the constraint / amend the
  constraint; every one resolves the conflict. Accepting an unaddressed blocker
  remains available only at the unchanged iteration-cap escalation.
- The convergence route was declarative: nothing in
  plan-review-convergence.md could receive a conflict. plan-phase now records
  it in REVIEWS.md — the channel that loop already consumes — convergence
  refuses to declare convergence over an open entry, and routing back into a
  run convergence itself started is explicitly excluded as a cycle. `quick` has
  no REVIEWS.md and no phase, so its convergence branch was dead prose and is
  deleted in favour of asking the user.

Also reconciles the last two drifted field names (`finding`, `affected_field`)
to the plan-checker schema, and repairs a silent no-op: the few-shot
`required_property` insertion never applied because those lines are
blockquoted, and the test's own block filter was anchored on indentation only,
so a vacuous loop passed over zero blocks. Both are fixed and the filter now
asserts it found blocks.

Refs #3771

* chore(#3771): extend the growth acknowledgment for the review round

plan-review-convergence.md joins the list: the conflict route needed a
receiving end, and it lands on the seam that loop already reads (REVIEWS.md)
rather than a new mechanism. The plan-phase, ui-phase and gsd-ui-checker
entries gain the second-pass reasoning — an executable convergence branch, the
increment moved onto the return path, and the structured return that still
ordered an exact fix.

* fix(#3771): make the conflict route bounded, ordered, and owned

Round-2 adversarial review found five more defects, each confirmed in the
source before acting:

- The convergence gate sat AFTER `gsd_run state planned-phase` and the success
  banner, so a run could write and announce convergence over an unresolved
  conflict. OPEN_CONFLICTS is now read from REVIEWS.md and is part of the
  converged CONDITION, evaluated before any write.
- plan-phase's cycle-exclusion ("unless this run was invoked by convergence")
  was not a question the orchestrator can answer at runtime. plan-phase now
  never invokes convergence at all — it records the conflict when a phase
  REVIEWS.md exists and resolves it with the user in-place, which removes the
  cycle instead of describing it.
- Closure had no owner. plan-phase writes the row, so plan-phase strikes it
  resolved; convergence only reads. An open row is a live blocker, never a
  stale artifact.
- Declining to increment the counter removed the only bound on the conflict
  path: an agent returning the same conflict forever would loop unattended. A
  conflict naming the same `required_property` twice in a row is now a stall
  and escalates through the existing gate.
- verify-work's gap-plan revision loop hands `<revision_context>` to
  gsd-planner and so inherits the whole contract, but stated none of it and
  could not handle the conflict return. It is now covered like the others, and
  is in the test's orchestrator table.

Refs #3771

* chore(#3771): acknowledge the round-2 growth

verify-work.md joins the list — the flow the second review found missed — and
the plan-phase, ui-phase and plan-review-convergence entries gain the
round-2 reasoning: the gate moved ahead of the state write, the convergence
hand-off replaced with a runtime-checkable record-and-resolve, and the
recurrence bound that replaces the counter the conflict path stopped spending.

* fix(#3771): make the convergence gate countable and stop the conflict fall-through

Third adversarial round (Antigravity) found three defects:

- The OPEN_CONFLICTS pipeline had no `grep -v '~~'` despite its own comment
  claiming one, and `grep -c '^| '` also counts a markdown table's header and
  separator rows — every resolved conflict would have read as open and
  convergence would have deadlocked instead of converging. plan-phase now
  records each conflict as a `- [ ]` checklist line and flips it to `- [x]`, so
  the gate is an exact fixed-string match with no table parsing.
- "then continue below" fell through to the checker spawn, so a SECOND
  REVISION_CONFLICT would have been handed to the checker as though it were a
  revised plan. plan-phase, quick and verify-work now re-evaluate the return
  from the top of the conflict handler; ui-phase already looped back.
- revision-loop.md still described plan-phase routing a conflict to the
  convergence loop instead of asking — the behaviour round 2 removed. Recording
  is now stated as being in addition to asking, never instead of it.

Refs #3771

* chore(#3771): bring the changeset in line with what shipped

Two review rounds widened the change after the fragment was written:
verify-work's gap-plan revision and the convergence loop are covered, two
more drifted field names are reconciled, and the conflict path carries an
explicit recurrence bound.

* fix(#3771): declare and emit the REVISION_CONFLICT marker

check:contract-drift on CI caught what local lint never reached: four
workflows dispatch on `## REVISION_CONFLICT`, but no agent declared or emitted
it — an orphan consumer, matching a marker nothing produces. The shared
reference (planner-revision.md Step 7b) described the return; the agent
definitions did not carry it.

gsd-planner and gsd-ui-researcher now emit the marker in-fence alongside their
other return markers, and both registry rows in agent-contracts.md declare it.
gsd-planner's Consumed by gains the two workflows that dispatch on it and were
missing from the row.

The gate is right: a return contract belongs where the agent is defined, not
only in a reference the agent happens to load.

Refs #3771

* chore(#3771): acknowledge the return-marker growth

gsd-planner.md and gsd-ui-researcher.md each gain the REVISION_CONFLICT
marker that check:contract-drift requires them to emit.

* fix(#3771): hoist the shared conflict protocol out of the workflows

Two CI failures, both correct gates:

- tests/few-shot-calibration.test.cjs pins the plan-checker calibration file
  at exactly 4 examples (2 positive, 2 negative). The example added in the
  first pass broke that balance — and described PLANNER behaviour in the
  CHECKER's calibration set, which is the wrong surface for it. Removed; the
  smaller-alternative rule is already normative in gsd-plan-checker.md and
  planner-revision.md, and pinned by the regression suite.
- tests/phase6-capstone-conformance.test.cjs (ADR-857 phase 6, #1168) requires
  plan-phase.md to stay BELOW its pre-phase-6 baseline of 94519 bytes. The
  inline conflict block pushed it to 94988.

The fix for the second is the one that should have been made first: the
record/resolve/close protocol and the recurrence bound were identical in four
workflows, and revision-loop.md — which plan-phase already @-imports — is what
a shared contract is for. The protocol now lives there once; plan-phase states
only its bindings (which counter, which artifact, which next step) and points
at it. plan-phase.md: 94988 -> 92739, under the ratchet with headroom, and the
four-way duplication is gone.

quick, ui-phase and verify-work do not import the reference, so they keep their
inline statements. The suite asserts each rule against what the runtime
actually loads for that orchestrator, not against the file in isolation.

Refs #3771

* docs(#3771): state the shared-protocol relationship accurately

Three of the four revision-bearing workflows do not @-import revision-loop.md,
so 'follows it verbatim' overstated the coupling. Only plan-phase defers; the
others restate the rules inline and this section is the authority they must
agree with.

* refactor(#3771): name the authority instead of restating it four times

Self-review finding: the same ~700-byte reviser paragraph was inlined in four
prompts while gsd-planner already loads planner-revision.md whenever
<revision_context> is present (agents/gsd-planner.md:555, :588) — a fifth copy
of the same contract. Four places to edit in lockstep is precisely the drift
class this PR exists to fix; the generic pattern calling the field
suggested_fix while the checker emitted fix_hint is what that looks like after
a year.

Each prompt now carries only the load-bearing clauses and names the authority
it summarises. Deliberately NOT reduced to a bare pointer: these are LLM
prompts, and a contract stated only in a file the reader is supposed to fetch
is the failure mode of this very bug. The saving is modest (~50 bytes each) —
the point is the named source of truth, not the bytes.

Suite: dropped seven assertions that pinned heading text and bold-lead
phrasing a reword would break without changing what the runtime is told; their
neighbours already pin the same contract by content. 519 -> 499 lines, 61
tests. Red gate against origin/next: 56 of 61 fail.

Refs #3771

* fix(#3771): sanitize agent-authored conflict text and bound total conflicts

Cross-AI review (agy/Gemini) raised two security findings. I confirmed the
first by running the gate's own awk against a crafted file and reproduced it
exactly; the second is a real hole in my bound.

1. FAIL-OPEN in the convergence gate. The conflict text is agent-authored and
   is appended to REVIEWS.md, which the gate scans with an awk that stops at
   the next '## ' heading. One line of agent text beginning '## ' ends that
   scan early, so conflicts below it are never counted and convergence declares
   success over a live blocker. Measured: 3 open conflicts, awk returned 2.

   Fixed at the write boundary, which is the trust boundary: every field has
   newlines and tabs collapsed to spaces and a leading '#', '-', '|' or fence
   stripped, so one conflict is exactly one line. Both producing agents now
   declare their fields single-line plain text, and the reader states the
   invariant it depends on so a later edit cannot silently break it. Verified:
   3 open + 1 resolved now counts 3; missing file and absent section count 0.

2. The recurrence bound was 'same required_property twice in a row', which an
   agent alternating property names never trips, leaving the un-incremented
   conflict path unbounded. Now bounded twice: the repeat rule catches the
   common case, and the THIRD conflict return of a loop escalates whatever
   property it names. A conflict still never consumes a revision iteration;
   this cap is separate from and additional to the revision cap.

Rejected from the same review: deleting 'a planner that reaches
required_property by a smaller or different mechanism has addressed the issue
in full' from the CHECKER prompt as misplaced. It is load-bearing exactly
there. A checker that does not know a different mechanism counts will re-flag
the issue on re-check, which is the revision loop that never terminates. The
argument offered for deleting it, that the checker evaluates the new state
independently, describes the failure mode.

Refs #3771

* fix(#3771): fail closed on an unverifiable convergence gate

Second cross-AI pass (agy, this time with the full files rather than the diff)
found two more, both real:

1. The gate read REVIEWS_FILE with `2>/dev/null || echo 0`, so an unreadable or
   empty path counted as ZERO open conflicts and converged. That path is
   resolved a few lines earlier by a pre-existing unquoted
   `ls ${phase_dir}/${padded_phase}-REVIEWS.md` (line 346, not touched by this
   PR), which yields an empty string rather than an error when the path
   contains a space. Unverifiable is not clean: the gate now tests -z and -r
   first and BLOCKS. Verified both branches.

   The unquoted ls itself is left alone deliberately — it predates this change
   and belongs to the reviews lookup, not the conflict gate. Fixing it at my
   own boundary removes its effect on this gate without widening scope.

2. REVIEWS.md is writable by the review agent, which could flip a `- [ ]` to
   `- [x]` or delete the section and forge the state of a blocking gate. The
   section now declares a single writer: /gsd:plan-phase appends and closes,
   every other agent leaves it byte-for-byte alone, readers read.

Also trimmed a clause that explained the increment ordering by reference to
what the file said before this PR. Commit history is not instruction, and
these files are prompts.

Rejected: the claim that quick's conflict gate deadlocks autonomous pipelines
by asking the user. Its existing max-iteration escalation in the same file
already asks the user the same way; this adds no new interaction class.
Noted but out of scope: the per-dimension YAML example blocks and the shim
boilerplate duplicated across agent prompts both predate this change.

Refs #3771

* fix(#3771): count conflicts by line shape, not by section

CodeRabbit review on the rehearsal PR. Five findings, all valid, all applied.

The best one is a deletion. The convergence gate scanned between
'## Plan-Revision Conflicts' and the next '## ' heading, and that scan stops at
the FIRST heading it meets — so one stray '## ' line hid every conflict beneath
it and returned 0, converging over a live blocker. Reproduced: section-scan 0,
shape-scan 1. Sanitizing at the write boundary does not cover a hand-edited,
legacy, or foreign-written REVIEWS.md, so the reader needed its own guarantee.

It now matches the conflict line SHAPE anywhere in the file:

  grep -c '^- \[ \] .*required_property:'

No section bookkeeping, nothing a heading can truncate, and it composes with the
writer's sanitization (which strips a leading '-' from agent text, so agent prose
cannot forge the shape). Verified: injected heading -> 1, all resolved -> 0.

The other four:

- Both checkers told the author never to emit a contradictory fix_hint, then
  offered an escape hatch that put the forbidden route in the hint anyway. They
  now name NO route in that case and state only that the property conflicts with
  the constraint. A hint carrying a forbidden route is applied by anyone who
  trusts hints.
- The REVISION_CONFLICT marker description in gsd-planner.md was narrower than
  planner-revision.md: it covered a contradictory hint but not an unreachable
  required_property. A planner reading only the agent file would have burned
  retry budget on the case the reference routes to a conflict.
- The few-shot calibration examples used uppercase BLOCKER/INFO while the schema
  defines blocker/warning/info. Pre-existing, but it is the same schema-vs-example
  disagreement this PR exists to end, and the file was already being edited.
- verify-work's re-entry instruction existed but sat after the Bounded clause, so
  the paragraph read "re-spawn ... stop re-spawning ... after re-spawning". The
  re-entry now immediately follows the re-spawn, and states that only a
  non-conflict return may reach the checker or increment iteration_count.

Refs #3771

* fix(#3771): resolve the contradictory scope_sanity severity examples

Sixth CodeRabbit finding, posted outside the diff range and missed on my first
read — I had claimed all findings were addressed after reading only the five
inline comments. This one was in the review body.

agents/gsd-plan-checker.md carried TWO scope_sanity examples with identical
metrics (5 tasks, 12 files) and OPPOSITE severities: warning in Dimension 5,
blocker in <examples>. Line 872 states "2-3 tasks/plan good, 4 warning, 5+
blocker" and the severity table lists warning as "Scope 4 tasks (borderline)",
so the warning example contradicted both.

ADR-2629 Decision 5's "over budget is a WARNING, never a blocker" does not
excuse it: that rule governs the smart-zone TOKEN estimate (the estimate-check
verb, lines 299-306), which is a different axis from task count. Verified in
source before touching it.

The contradiction is pre-existing but this PR made it binding and visible:
severity is now declared part of the binding payload, and both examples were
given the same required_property, so they now disagree on the severity of an
identical finding about an identical property.

Deviating from the proposed correction, which was warning -> blocker: that
would duplicate the <examples> entry outright (same tasks, files, severity).
The Dimension 5 example is instead made a genuine 4-task borderline warning, so
the file keeps one worked example per severity and the thresholds, the severity
table and both examples finally agree.

Refs #3771

* fix(#3771): stop laundering a grep error into zero open conflicts

Seventh CodeRabbit finding — from a SECOND review round my own CR-4 push
triggered, which I had not looked for. This one is a regression I introduced
while fixing the previous fail-open.

CR-4 replaced the truncatable section scan with:

  OPEN_CONFLICTS=$(grep -c '^- \[ \] .*required_property:' "$REVIEWS_FILE" || true)

`|| true` masks every grep failure. grep exits 1 for "no matches" (a legitimate
zero) but 2 for a read error, and `|| true` turns both into an empty capture
that `${OPEN_CONFLICTS:-0}` renders as 0. If REVIEWS.md is removed or becomes
unreadable between the -r check and the scan, the gate reports no conflicts and
convergence proceeds. Proven: unreadable file -> captured empty -> 0.

The status is now inspected, and only exit 1 counts as zero; anything else
blocks.

My first attempt at this fix was itself wrong and my own harness caught it: I
wrote `if ! grep ...; then grep_status=$?`, but `!` inverts the status, so `$?`
in that branch is 0 and every failure reads as success — the clean-file case
printed "BLOCKED (grep exit 0)". The status must be read in the ELSE branch of a
non-negated `if`, which is what CodeRabbit proposed. Both traps are now pinned
by tests.

Verified end to end: all resolved -> 0, no conflicts at all -> 0, injected
heading -> 1, unreadable file -> BLOCKED with grep exit 2.

Refs #3771

* test(#3771): execute the conflict gate instead of reading it

CodeRabbit round three: 0 actionable, 1 nitpick — "these assertions inspect
Markdown source only; they do not prove that grep status 1 produces zero
conflicts or that a scan error exits before convergence." Rated Trivial. It is
the most valuable finding of the three rounds.

This gate has been wrong three times: a section scan a heading could truncate, a
`|| true` that laundered grep's error status into zero, and an `if !` whose `$?`
reported the negation rather than the command. Every one of those passed the
text assertions that existed at the time. I proved each fix by hand in a shell,
and none of that proof lived in the suite.

The gate is one self-contained fenced block, so the test now extracts it from
the workflow — located by content, not line number — writes it to a script and
RUNS it against fixtures: two open plus one resolved counts 2; no matches counts
0 and does not fail; a conflict below an injected `## ` heading still counts; an
unreadable path and an empty path both BLOCK with a non-zero status and no zero
count on stdout.

Non-vacuity proven by mutation rather than asserted. Reverting the gate to each
of its three historical broken forms reds the suite:

  section-scan awk  -> 7 failures (5 in the gate cases)
  || true           -> 4 failures (3 in the gate cases)
  if ! (negated $?) -> 4 failures (3 in the gate cases)
  restored          -> 69 pass, 0 fail

The prose assertions stay: they are the right instrument for a prompt. This
covers the one part of the change that is real shell an orchestrator executes.

Refs #3771

* test(#3771): route the gate harness through the shared test helpers

ESLint's project rules caught three violations in the new harness: an unbounded
execFileSync (DEFECT.UNBOUNDED-SUBPROCESS — an unbounded spawn is an indefinite
hang, and on macOS CI that is how a stuck run stops reporting instead of failing)
and two raw fs.rmSync calls, which skip the Windows-EBUSY retry budget that
helpers.cleanup carries.

Now uses createTempDir/cleanup from tests/helpers.cjs and passes an explicit
30s timeout. Suppressing the rules was available and would have been the wrong
call: both exist because of real CI failure modes on platforms I am not testing
on.

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

The pr: field is drift-checked against the PR event payload, so it cannot be
written before the PR exists. Set to 3916.

* fix(#3771): close revision conflict persistence gaps

Use the authoritative review artifact, keep conflict and normal retry paths disjoint, and enforce one writer-reader grammar so malformed state fails closed.

Emitted-Drift-Ack-Growth: diagnose-issues.md — #3771 marks the gap-plan remediation hint non-binding while keeping root_cause authoritative
Emitted-Drift-Ack-Growth: gsd-plan-checker.md — #3771 separates binding required_property evidence from advisory fix_hint examples across the checker contract
Emitted-Drift-Ack-Growth: gsd-planner.md — #3771 declares the REVISION_CONFLICT return used when remediation contradicts governing constraints
Emitted-Drift-Ack-Growth: gsd-ui-checker.md — #3771 applies the same binding-property and advisory-hint split to UI review findings
Emitted-Drift-Ack-Growth: gsd-ui-researcher.md — #3771 defines the UI revision producer's structured REVISION_CONFLICT return
Emitted-Drift-Ack-Growth: plan-phase.md — #3771 routes and persists bounded revision conflicts before spending the normal retry budget
Emitted-Drift-Ack-Growth: plan-review-convergence.md — #3771 adds the fail-closed owned-block parser and prevents convergence over open conflicts
Emitted-Drift-Ack-Growth: review.md — #3771 emits and preserves the canonical writer-owned conflict block across review regeneration
Emitted-Drift-Ack-Growth: ui-phase.md — #3771 routes UI revision conflicts to resolution before consuming revision_count
Emitted-Drift-Ack-Growth: verify-work.md — #3771 gives gap-plan revision the same bounded conflict route before iteration_count

* test(#3916): guard rebases against schema drift

Load the current-base progressive-disclosure examples so every integrated issue remains bound by required_property after branch reconciliation.

* test(#3916): skip the extracted-gate suite's bash spawns on win32

Third review round's sole survivor: runConflictGate()/withReviews() spawn
bash against a Node-native temp path built by createTempDir(), which is
backslash-separated on the Windows CI lane and not a path Git Bash is
guaranteed to accept (DEFECT.WINDOWS-TEST-PORTABILITY, matching the
observed CI failure at revision-remediation-binding.test.cjs:844,
ENOENT on a path Windows read as a directory separator). No eslint rule
catches it since the call has neither a chmod nor a `bash -c` form.

Guards the four call sites with the repo's existing skipOnWin32
convention (describe/test `{ skip: IS_WINDOWS }`) rather than
normalizing the harness path to forward slashes, which would defeat the
one test whose purpose is proving the production gate does NOT rewrite
a literal backslash in a POSIX filename.

* fix(#3916): backfill changeset pr field to the fork validation PR number

* fix(#3771): forbid silently accepting an open plan-revision conflict at max-cycles escalation

The max-cycles escalation prompt only surfaced HIGH_COUNT and ACTIONABLE_COUNT; an open
plan-revision conflict (OPEN_CONFLICTS > 0) was never disclosed there, and "Proceed anyway"
could exit successfully over it — exactly the failure mode this PR exists to close (a success
banner over an unresolved conflict nobody resolved). Blockers still block: withhold "Proceed
anyway" and route to Manual review whenever a conflict is open.

* fix(#3771): do not hard-block REVISION_CONFLICT persistence when no REVIEWS.md exists yet

A phase's first-ever revision cycle can return REVISION_CONFLICT before any REVIEWS.md has been
written — REVIEWS_PATH is then legitimately empty, not a corrupt or deleted file. The persistence
gate's own accompanying prose already says the record channel applies 'when REVIEWS_FILE is
non-empty', but the bash condition never checked that, so it hard-blocked every conflict on a
brand-new phase regardless of whether persistence was even expected to run. Require a non-empty
REVIEWS_FILE before treating a missing file as an error.

* chore(#3771): raise the plan-phase.md ADR-857 host-loop ceiling to 96700

The frozen pre-phase-6 ceiling (94519) collided on rebase: this PR's own
REVISION_CONFLICT persistence/routing gate is core planner control flow, not an
un-extracted optional feature, and landed alongside an unrelated, already-merged
same-file growth (the #4.6 context-drift pre-check) already on next. Same
rationale #1298 already established for execute-phase.md's ceiling.

* chore(#3916): backfill changeset pr field to the upstream PR number

* fix(#3771): make the writer-side REVISION_CONFLICT sanitize step real shell

The Conflict Return record channel sanitized agent-authored fields via a
prose instruction ("Sanitize each agent-authored field before appending")
for the orchestrator LLM to apply by hand, while the reader-side gate in
plan-review-convergence.md parses the same slot with real, executed awk.
Flagged Minor across two review rounds (round 4, round 6) since no code
performed the sanitize anywhere.

plan-phase.md's Conflict Return step now runs a real bash gate: sanitize
each field (collapse newline/tab to space, strip a leading #/-/|/fence),
build the one-line record, skip the append if an identical line already
exists (idempotent), insert before the writer-owned end delimiter, and
fail closed if that delimiter is missing rather than silently dropping
the conflict.

tests/revision-remediation-binding.test.cjs extracts and RUNS the new
fence (matching how the reader gate is already tested), composing it
with the existing reader gate: hostile-field fast-check fuzzing, a
repeated-conflict idempotency check, and a missing-delimiter fail-closed
check that the file is left byte-for-byte unchanged on failure.

Emitted-Drift-Ack-Growth: plan-phase.md — #3916 turns the writer-side
REVISION_CONFLICT sanitize+insert step into real, executed shell instead
of a prose instruction, matching the reader gate's existing rigor

* fix(#3916): backfill changeset pr field to the fork validation PR number

Fork CI's changeset-lint reads the real PR number from its own event
payload; the fragment still carried the upstream number from the last
sync, so the DEFECT.CHANGESET-PR-FIELD-DRIFT check failed on this fork
PR. Re-backfill to the upstream number before the final push.

* fix(#3771): close the awk -v forgery and same-session close gaps agy found

Adversarial review (gemini-3.8-flash-high via the internal agy review
lane) on the full PR found two BLOCKERs against the just-added
writer-side conflict gate:

1. `awk -v line="${LINE}"` decodes escape sequences in its argument, so
   a literal two-character `\n` in agent-authored text became a real
   newline inside awk, splitting the appended record across two
   physical lines. `tr` only strips actual control bytes, so it never
   saw this — it defeated the exact forgery the gate exists to
   prevent, both the reader's zero-count and the writer's own
   idempotency check. Fixed by passing LINE/END through awk's
   ENVIRON, which is not escape-decoded.

2. A conflict resolved and re-spawned within the same plan-phase
   session was never flipped from `- [ ]` to `- [x]` — the record
   channel bullet said "plan-phase closes it," but no step did. Only
   a *separate* `--reviews` re-entry (line ~622, still prose-only)
   closes conflicts; the in-session resolve path left them open
   forever, permanently blocking convergence. Fixed by carrying the
   just-written line in `PENDING_CONFLICT` and closing it in the
   `Otherwise` branch before the checker re-spawns.

Also fixed a MAJOR: docs/COMMANDS.md described the `--max-cycles`
escalation gate as uniformly offering "proceed or review manually,"
but the code (this PR's own change) withholds "Proceed anyway"
specifically when a plan-revision conflict is open — only manual
review is offered in that case. Docs now say so.

Not applied: the reviewer's `\r` truncated to plain tr from a MINOR
that also asked for temp-file permission preservation across `mktemp`.
Applying `chmod --reference` is not portable to macOS/BSD `chmod`, so
this is left as a documented low-severity tradeoff — the temp file now
sits alongside REVIEWS.md (same filesystem, atomic `mv`), which was
the same finding's more substantive half. Also not applied: a
suggested `gsd_run review record-conflict` CLI subcommand to
deduplicate the two `awk` blocks — a new command plus wiring is out of
scope for a review-remediation fix.

tests/revision-remediation-binding.test.cjs adds regression coverage
for both BLOCKERs: a literal-backslash-n hostile field composed with
the reader gate, and a close-gate extraction that verifies the flip to
`[x]`, the reader's count dropping to 0, and a fail-closed path when
the pending line is missing.

Emitted-Drift-Ack-Growth: plan-phase.md — #3916 fixes an awk -v escape-
decoding forgery and adds the missing same-session conflict-close step
an adversarial review found in the writer-side gate

* chore(#3916): backfill changeset pr field to the upstream PR number

Fork-validation CI needed pr: 1 to pass its own changeset-lint; restore
pr: 3916 before this push reaches open-gsd/gsd-core.

* fix(#3771): trim plan-phase.md prose back under the XL byte cap

Merging origin/next's unrelated growth pushed plan-phase.md 473 bytes
past the workflow-size-budget XL cap and the ADR-857 phase-6 baseline,
both tripped by CI after review approval. Removed an unpinned inert
bash comment and tightened connective prose in three REVISION_CONFLICT
bullets; no executable shell or test-pinned substring changed.

* chore(rehearsal): pin changeset pr field to fork rehearsal PR #25

Scratch-only commit for the rehearsal branch's own CI. Will not be
carried onto the branch backing upstream #3916 — that keeps pr: 3916.

* fix(#3771): address CodeRabbit findings on the REVISION_CONFLICT protocol

Fork rehearsal PR #25's first CodeRabbit pass surfaced 7 findings against
the already-approved #3916 diff; each verified against current code
before fixing (none hallucinated):

- plan-phase.md: writer-side awk gates now strip a trailing \r before
  comparing lines, matching the reader gate (plan-review-convergence.md)
  -- a CRLF REVIEWS.md previously made both writer gates fail closed.
- plan-phase.md: the close-fence's REVIEWS_FILE/PENDING_CONFLICT/
  CONFLICT_RESOLUTION were read without ever being (re)defined in that
  fence -- shell state does not survive across separate fenced blocks
  (same convention already documented in review.md). Added the explicit
  recompute/set instruction.
- revision-loop.md: previous_conflict_property was never reset after a
  normal (non-conflict) revision, so a later, unrelated conflict on the
  same property could be misread as a repeat and escalate prematurely.
- gsd-plan-checker.md / few-shot-examples/plan-checker.md: two example
  required_property strings were unconditionally binding in a way their
  own dimension's rules aren't (no-analog RESEARCH.md fallback; tasks
  that create no functions), now scoped to match.
- quick/steps/plan-checker-loop.md: added the same disjoint
  "Otherwise (not REVISION_CONFLICT)" branch plan-phase.md already had,
  closing an ambiguity between the conflict and non-conflict return paths.
- revision-remediation-binding.test.cjs: the REVIEWS_PATH init-order
  assertion used indexOf() without checking for -1, so it would pass
  vacuously if either anchor were renamed away.

Also restores an "Export the row's CONFLICT_*" instruction I had cut in
the prior byte-budget trim -- checked non-pinned by tests, but it was the
only text telling the agent to set those vars before the awk block reads
them via ENVIRON.

Net growth from these fixes required reclaiming bytes elsewhere in
plan-phase.md (verified against every pinned substring in
revision-remediation-binding.test.cjs) to stay under the XL tier's
hard 98304-byte cap; final size 98245 bytes.

* fix(#3771): resync the #4079 shrink-only mirror to the current PRE_PHASE6 line

tests/plan-phase-background-wait-wakeup.test.cjs (landed on next via an
unrelated #4079 PR, merged in by this branch's next-sync) mirrored
plan-phase.md's phase6 shrink-only ceiling as a hardcoded local constant
(94519) rather than reading tests/phase6-capstone-conformance.test.cjs's
PRE_PHASE6 value. That value has since been legitimately raised twice
during this PR's own review (94519 -> 96700 -> 98300) to accommodate the
REVISION_CONFLICT persistence/routing gate. The two branches' independent
histories left the mirror stale post-merge -- not a textual git conflict,
but the same class of thing. Resynced to 98300.

* fix(#3771): address round-2 CodeRabbit findings on the conflict gates

CodeRabbit's re-review of the previous remediation commit found two real
issues in what it had already flagged:

- Both writer-side awk CRLF fixes used \`sub(/\r$/, "")\` directly on \`\$0\`,
  which mutates it in place -- \`{ print }\` then emitted the CR-stripped
  copy for every passed-through line, silently rewriting an unrelated
  CRLF REVIEWS.md to LF on any insert or close. Now compares against a
  separate \`cur\` copy and prints the original, untouched \`\$0\`.
- The close-fence's "recompute REVIEWS_FILE/PENDING_CONFLICT" prose
  implied in-fence derivation, but the fence has no such code and the
  test harness (\`runCloseGate\`) deliberately supplies all three as
  pre-set env vars -- matching how the open fence's "Export the row's
  CONFLICT_*" instruction already works. Reworded to "export ... in the
  same invocation", matching that established, test-verified pattern
  instead of promising logic that isn't there.

Added a regression test proving the CRLF fix no longer touches
passthrough lines (red against the mutate-in-place version, green now).

* fix(#3771): use a CRLF-safe check in the new passthrough regression test

local/no-crlf-fragile-split forbids splitting readFileSync content on a
literal \n (Windows git-autocrlf checkouts yield \r\n). My CRLF
passthrough-preservation test from the previous commit did exactly that
to inspect the first line. Replaced with a direct startsWith() check
against the known CRLF-terminated header, which needs no split.

* test(#3771): assert the record itself is inserted in the CRLF passthrough test

CodeRabbit nitpick (round 3): the passthrough-preservation test checked
gate status and the pre-existing line's CRLF ending, but never asserted
the new REVISION_CONFLICT record was actually written.

* fix(#3771): address agy/gemini-3.8-flash-high adversarial review findings

Full-PR adversarial review (internal /gsd-review antigravity lane,
gemini-3.8-flash-high) surfaced 9 findings; each verified against current
code before fixing (none hallucinated):

HIGH:
- quick-batch/steps/plan-checker-loop.md never received the
  required_property/fix_hint binding language or REVISION_CONFLICT
  handling this PR added everywhere else -- a genuinely unmigrated
  producing context. Migrated to match quick/steps/plan-checker-loop.md,
  and added it to the ORCHESTRATORS consistency battery in
  revision-remediation-binding.test.cjs so future drift is caught
  automatically.
- The close-fence's PENDING_CONFLICT was an agent-supplied env var that
  had to exactly reconstruct a five-field sanitized line across a
  multi-minute subagent dispatch -- fragile, and a scalar var also meant
  a second simultaneous conflict silently dropped the first on overwrite.
  Redesigned to match the open conflict by CONFLICT_DIMENSION/
  CONFLICT_PLAN identity instead: the agent re-supplies two short,
  already-tracked identifiers rather than reconstructing the full
  sanitized text, and each conflict resolves independently regardless of
  how many are open. Updated the test harness's runCloseGate contract to
  match, and added a two-open-conflicts regression test.

MEDIUM:
- plan-phase.md's `--reviews` replanning path told the reader to "flip
  the matching line to [x]" in prose only, with no executable path to
  it -- pointed it at the same close gate used in step 12.
- plan-review-convergence.md's reader-gate awk tolerated a blank line
  before the opening delimiter but not before the heading that follows
  it; a formatter or LLM writer inserting one would hard-abort
  convergence on an otherwise well-formed REVIEWS.md. Added the same
  tolerance already granted above it, with a regression test.

LOW:
- Clarified that the escalation destination for a stalled conflict is
  the same iteration/revision-count cap gate already defined in each of
  quick, quick-batch, ui-phase, and verify-work, rather than an
  undefined "stall" concept.
- Clarified "twice in a row" means no successful revision intervened,
  matching revision-loop.md's now-explicit previous_conflict_property
  reset.
- Fixed gsd-ui-researcher.md's stale rationale text, copied verbatim
  from planner-revision.md: ui-phase presents the conflict table
  directly to the user, it does not persist to a shared file scanned by
  heading.

Net growth again required reclaiming bytes in plan-phase.md (verified
against every pinned substring in revision-remediation-binding.test.cjs)
to stay under the XL tier's hard 98304-byte cap; removed a now-dead
PENDING_CONFLICT assignment in the process. Final size 98258 bytes.

* fix(#3771): scope row 48's quick/steps guard away from plan-checker-loop.md

tests/gsd-quick-batch-quick-regression.test.cjs's row 48 (#3676) flagged
this branch's quick-batch/steps/plan-checker-loop.md migration (the agy
HIGH finding) as a violation, because it also edits
quick/steps/plan-checker-loop.md for the same underlying #3771 protocol
fix.

Verified against git history before scoping: 2f64e6230 (#3676's own
landing commit) CREATED quick-batch/steps/plan-checker-loop.md as a new,
independent 119-line file, never a call-site into quick/'s copy. Row
48's "shared primitives, never edits the ordinary quick command" premise
was never about this specific file -- it was always meant to carry its
own per-flow copy of whatever revision-loop contract applies, same as
ui-phase.md/verify-work.md throughout this PR. This is the same
false-positive class the row's own comments already document scoping
away twice (#3730, #2529 round 40); excluded plan-checker-loop.md from
its touched-quick-steps check with the same evidence trail.

* chore(#3771): point changeset pr field at upstream PR 3916

---------

Co-authored-by: davdittrich <davdittrich@gmail.com>
Co-authored-by: CI Rebase Check <ci@gsd-redux>
Co-authored-by: Test <test@test.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-05 15:16:38 -04:00
Tom Boucher
26b8e9abad fix(#4306): forward real bytes through the #3912 A6 stderr-bytes mocks (#4328)
Same defect class as the bug #1008 fault-injection mocks: fabricated
a return byte count without ever calling the real fs.writeSync,
silently discarding any write to fd 2 landing during the mocked
window instead of letting it reach the real pipe.

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 14:42:26 -04:00
Tom Boucher
13309a1921 docs(#4286): record doc-writer/roadmapper loop-contribution roles as out-of-scope (#4330)
Closes #4286

The request's premise doesn't hold: gsd-doc-writer and gsd-roadmapper aren't
dispatched from any of the 12 loop hook points the capability-contribution
mechanism reaches, so admitting them as agentRoles there wouldn't solve the
problem as filed. The real underlying gap (per-workflow contribution points
for standalone workflows) has no concrete design proposed yet.

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 14:20:54 -04:00
Michel Moreira
86b745b48b fix(#4270): forward Codex spawn model routing (#4281)
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-05 14:20:26 -04:00
Tom Boucher
7ff196c505 fix(#4096): honor --dry-run in todo complete and write completion keys inside the frontmatter fence (#4325)
* fix(#4096): honor --dry-run in todo complete and upsert completion keys inside the frontmatter fence

* review(#4096): tighten todo complete flag rejection to any dash-prefixed token

* chore(#4096): backfill PR number in changeset

---------

Co-authored-by: sim <sim@local>
2026-09-05 13:49:58 -04:00
Tom Boucher
3d03ae65e6 fix(#4094): withhold all four STATE.md progress counters under the milestone-unbounded guard (#4322)
* test(#4094): failing-first matrix for withholding all four progress counters

* fix(#4094): withhold all four progress counters under the milestone-unbounded guard

completed_phases/total_plans/completed_plans are accumulated from the same
phaseDirs walk as total_phases, so the #3354/#3573 withhold condition makes
them equally untrustworthy — yet only total_phases was withheld, and every
resyncing state.* write silently clobbered the three stored siblings with the
under-scoped disk numbers. Extend the withhold-then-fall-back-to-stored
pattern to all three siblings: null sentinels in the disk-scan cache value,
three new stored-counter readers threaded through all three
buildStateFrontmatter call sites, and the same cached-else-stored consumer
fallback. Milestone-bounded projects are untouched (gate-conditional).

* fix(#4094): scope-requires for the new test block, keep the (#3573) warning token, and update two #3578 rows to the withheld-counter contract

- the #4094 describe sat after the closing brace of the section that owned
  the module-level beforeEach destructure, so it needs its own local requires
  (mirroring the #3642 block);
- the #3573 warning keeps its literal '(#3573)' tag (asserted by an existing
  test) with '#4094' appended as a separate token;
- two #3578 status-guard rows in tests/state.test.cjs asserted the pre-#4094
  unconditional disk-scan assignment of completed_phases under the
  roadmap-absent withhold — exactly the silent clobber #4094 removes; the
  status-guard conclusion (must not fire) is unchanged, the counter-value
  assertions now pin the withheld contract.

* test(#4094): lint conformance — splitLines for the persisted-progress parser, local seeder, scoped rmSync disable

* changeset(#4094)

* changeset(#4094): backfill PR number

---------

Co-authored-by: sim <sim@local>
2026-09-05 13:01:28 -04:00
Tom Boucher
2e1ede6d99 fix(#4093): give advance-plan's zero-labeled-fields failure a disk-derived recovery decline (#4318)
* test(#4093): regression matrix for advance-plan zero-labeled-fields decline

* fix(#4093): give advance-plan's zero-labeled-fields failure a disk-derived recovery decline

* refactor(#4093): collapse IIFE to a plain block (review finding)

* docs(#4093): document the advance-plan recovery decline + changeset

* chore(#4093): backfill PR number in changeset

* fix(#4093): budget lint-compiled-artifact-sync's tsc compile as a compile, not a probe

---------

Co-authored-by: sim <sim@local>
2026-09-05 10:46:37 -04:00
Tom Boucher
d9d16551b7 fix(#4091): publish update-check cache via atomic temp+rename (#4313)
* test(#4091): RED — worker cache write must be rename-atomic

Structural regression guard: gsd-check-update-worker.js must not write the
shared per-package cache in place; assert temp+rename publish shape plus a
behavioral no-residue e2e.

* fix(#4091): publish update-check cache via atomic temp+rename

writeFileSync on the shared per-package cache truncates before writing, so
concurrent cross-runtime readers (statusline/banner) could see a torn or
empty record. Stage under a unique same-directory temp and rename into
place — POSIX rename(2) is atomic, so readers see the old or new record,
never a partial one. Degrade policy (#3582) unchanged: errors swallowed,
temp best-effort removed.

* refactor(#4091): hoist temp path, tighten uniqueness assertion (review)

* changeset(#4091): add fragment (pr backfill to follow)

* changeset(#4091): backfill PR 4313

---------

Co-authored-by: sim <sim@local>
2026-09-05 09:22:10 -04:00
Tom Boucher
b327331747 fix(#4086): resolve skills/ manifest keys at the runtime's actual skills root (#4311)
* fix(#4086): resolve skills/ manifest keys at the runtime's actual skills root

Codex installs skills to ~/.agents/skills (skills-kind home override), but
saveLocalPatches() and verify-reapply-patches.cjs resolved every manifest key
config-dir-relative only — every skills/ key missed, so user modifications to
Codex skills were never hash-compared, never backed up, and silently
overwritten on update; the reapply verifier false-failed the same keys with
fail_installed_missing.

configDir stays first (non-override runtimes byte-identical); the skills root
(same _resolveSkillsRootDir / skillsManifestPrefix seams the write side uses)
is a containment-guarded fallback when the config-dir path is absent.

* fix(#4086): drop unused test param; add changeset fragment

* chore(#4086): backfill PR number in changeset fragment

---------

Co-authored-by: agent-4086 <agent-4086@local>
2026-09-05 08:06:19 -04:00
Atirna
70f22e4643 fix(#4213): keep STATE.md progress surfaces synchronized (#4231)
* fix(#4213): keep STATE.md progress surfaces synchronized

* fix(#4213): clamp the shared progress bar and keep bold-first priority, changeset + property tests

- formatProgressMachineSegment clamps through clampPercentFromFraction
  (ADR-3180 Decision 7 kernel) with a 0 floor, so a hand-edited
  out-of-range persisted percent renders a clamped bar instead of
  throwing RangeError on repeat() inside the write seam
- stateReplaceProgressPercent restores the #2177 bold-first priority:
  **Progress:** anywhere in the body wins; a plain ^Progress: line is
  the fallback, so free text starting with Progress: cannot capture
  the rewrite ahead of the real status line
- cross-reference comment names the three consumers and the
  cmdStateSync sanctioned exception (ADR-3408 §8.3)
- CONTEXT.md: applyPostSyncPreservation reconciliation documented in
  the STATE.md Transition Module entry
- property tests (never-throws/well-formed, idempotency, round-trip,
  bold-first) + two regression rows through the CLI

---------

Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-05 07:56:06 -04:00
Adnan
dad16b6ef9 fix(#4141): ignore Stryker's sandbox in eslint's global ignores (#4178)
* fix(#4141): ignore Stryker's sandbox in eslint's global ignores

`stryker.config.mjs` sets `tempDirName: '.stryker-tmp'`, `.gitignore` ignores
it, and Stryker's own always-ignored list carries it. eslint's global
`ignores` did not, and there is no `.eslintignore`.

A mutation run that dies before cleanup — chunk timeout, CI cancellation,
Ctrl+C, a crash — leaves a full sandbox copy of the tree at
`.stryker-tmp/sandbox-*/`, and `npm run lint` then lints the copy. The
failure is not the duplicate-file failure that would suggest, which is what
makes it confusing: the `local` plugin is registered only in path-scoped
config blocks (`tests/**/*.cjs`, `scripts/**/*.cjs`, `hooks/**`), and a copy
at `.stryker-tmp/sandbox-*/tests/*.cjs` matches none of them, so every inline
`// eslint-disable-next-line local/<rule>` the original carries becomes
`Definition for rule 'local/<rule>' was not found` at the copy's path. The
error text names rules rather than paths, so the first read is "my change
broke the local plugin" — observed as 834 errors on a branch whose own diff
was clean, from a sandbox eight days stale.

CI never sees this (fresh checkout), so it is purely a local-contributor tax,
but a loud and misleading one.

The regression test asserts the invariant the way ESLint resolves it —
`isPathIgnored()` over real flat-config precedence, not a textual scan of the
ignores array — mirroring the #551 block it sits beside, and carries the
control that the real `tests/worktree.test.cjs` at the mirrored path is still
linted, so a pattern widened enough to swallow the tree cannot pass.

Scoped to the reported bug: `reports/` may deserve the same treatment, but no
lint failure has been reproduced from it, so it is not claimed here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017zgWzB96uz3LVKdJePYTLR

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017zgWzB96uz3LVKdJePYTLR

* chore(#4141): restore the file's 2-blank-line block separator

Review nit: the #4141 block left three blank lines before the RETIRED
block, where every other block boundary in this file uses two.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mw7WhYnrWdY8cmj8NSJj4R

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-05 07:37:13 -04:00
aaka3207
294ec29857 fix(#4053): quote decimal-shaped frontmatter scalars for spec YAML readers (#4165)
* fix(frontmatter): quote decimal-shaped scalars so a spec YAML reader preserves them

A decimal phase identifier written to STATE.md frontmatter (e.g.
`current_phase: 22.10`) was emitted BARE, because `scalarNeedsDoubleQuoting`
only asks whether a value can OPEN a plain scalar — which `22.10` can. A
YAML-spec reader (js-yaml, the statusline, any external tool) then reloads bare
`22.10` as the float 22.1, colliding with `22.1` and dropping the trailing zero.
gsd's own tolerant line-scanner (`extractFrontmatter`) round-trips the raw text
and so hid the defect; a spec reader does not.

Fix: `reconstructFrontmatter`'s general scalar path now also quotes numeric-
looking strings that are not plain all-digit integers (decimals, exponents,
sexagesimal, hex/oct/bin) via `generalScalarNeedsNumericQuoting`, reusing the
existing `YAML_NUMERIC_RE`. Every all-digit string — integer counts, phase
numbers, and leading-zero fixtures like `02` — stays bare, so the state-rebuild
idempotency baseline and the rest of the state corpus are unchanged. This also
quotes `gsd_state_version: 1.0` on write, which matches the authoritative
STATE.md template (`src/state.cts` already emits it quoted).

Regression test drives the real write path and asserts, via js-yaml, that
`22.1` and `22.10` no longer collide and read back string-typed; guards that
integers and free-text stay unquoted.

Fixes #4053

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBicDMJyh3AH56ZFbUsyxC

* chore(changeset): add Fixed fragment for #4053

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBicDMJyh3AH56ZFbUsyxC

* docs(frontmatter): trim the generalScalarNeedsNumericQuoting comment

Cut the over-long doc block down to the essential why and drop the inline
comment that repeated it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ZKeSj55VakqQajtoBgCTC

* docs(test): drop the #4053 explanatory comments from the touched tests

The assertions speak for themselves; remove the added narrative comments.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ZKeSj55VakqQajtoBgCTC

* fix(#4053): correct the trade-off comment, changeset PR number, and cover every claimed numeric form

Review follow-ups (trek-e):
- The doc comment claimed a plain integer round-trips harmlessly. That is
  false for leading-zero values (`02` -> 2, `017` -> 17 under js-yaml). Rewrite
  it to state the real, deliberate trade-off: all-digit strings stay bare
  because zero-padded ids (`plan: 01`, `phase: 02`) are the pervasive GSD
  convention and quoting them all is the blanket quoting #4053 asked to avoid;
  the loss is padding not identity (`02` and `2` normalize to the same phase,
  `22.1` and `22.10` do not).
- Changeset carried the auto-closed draft's number (4151); correct to 4165.
- Test exponent, hex, octal, binary and sexagesimal forms through js-yaml, and
  pin the leading-zero trade-off so the documented behaviour is asserted.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qc7VN4zTpTSDTS9JXM2cFB

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-05 11:17:41 +00:00
allcounter
723ea08dc2 fix(#4016): imperative-override injection patterns tolerate filler words (#4061)
* fix(#4016): imperative-override patterns tolerate filler words

The narrow imperative-override family tolerates no filler between the
verb and the noun, so a planted "Forget all of your instructions"
(measured in a real public transcript) matched none of the 14 patterns
and both consuming hooks stayed silent.

One combined filler-tolerant pattern is appended; the narrow four stay
untouched to keep the change merge-friendly. Known trade-offs, disclosed
in #4016: linter-doc prose like "ignore rules on a single line" now
trips a LOW advisory, and the overlap with the narrow patterns means one
sentence can count twice toward severity thresholds.

Regression tests assert the previously-missed phrasings fire in BOTH
consuming hooks (gsd-prompt-guard and gsd-read-injection-scanner), not
just in the raw pattern list, per the agent brief in #4016.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015aGr6fvmDMLznT7TvTXrsb

* chore(#4016): changeset fragment for PR #4061

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015aGr6fvmDMLznT7TvTXrsb

* test(#4016): pin the disclosed linter-doc FP as single-pattern LOW, never blocking

Review follow-up on PR #4061: the combined filler-tolerant pattern's
disclosed false-positive class (linter-doc prose such as "use
eslint-disable-next-line to ignore rules on a single line") was
documented in prose only. Two tests now pin it:

- the prose matches exactly ONE shared pattern (the #4016 combined
  pattern, not a narrow one), so it cannot silently start double-counting
  toward the 3+ HIGH threshold;
- through the real gsd-read-injection-scanner subprocess with
  security.injection_blocking=true, the prose yields a single-finding
  LOW advisory and no block decision — with an in-test positive control
  proving a 3+-pattern payload DOES block in the same directory, so the
  non-blocking assertion cannot pass vacuously.

Samples are fragment-built like the existing SAMPLES rows so this file's
own diff does not trip the CI injection scanner.

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

* fix(#4016): replace the five narrow imperative-override patterns with one superset

The first cut appended a filler-tolerant combined pattern next to the five
narrow verb patterns. Both consumers count one finding per matching pattern
toward the severity threshold, so the overlap made one sentence count twice:
"Ignore previous instructions. Forget your instructions." scored 2 (LOW) on
next and 3 (HIGH, blockable) on the branch. It also left `override` out of
the combined pattern.

Replace the narrow family (ignore x2, disregard, forget, override) with ONE
superset pattern over ignore|disregard|forget|discard|override. At least one
filler (all|of|the|your|my|system|previous|prior|above|earlier) must sit
between verb and noun, enforced by a lookahead with no repetition; the two
noun-less/bare forms the old list accepted (`disregard (all) previous`,
`forget instructions`) are kept as explicit tails so the new pattern is a
strict superset. Bare "override rules" / "ignore instructions" are ordinary
repo prose (6 measured hits across docs and source) and stay unmatched.

Corpus measurement over 3019 .md/.js/.cjs/.mjs files (injection-sample tests
excluded): the old family hit 2 lines, the new pattern hits 3, the only new
one being a documented injection example in planner-reversibility.md that
the old family missed (the issue's own class).

Tests: SAMPLES reshaped to the 10-entry list; superset proof table (17 legacy
phrasings, each matching exactly one pattern); five issue phrasings including
`override all of your previous instructions` counted exactly once through
both hook subprocesses; double-count regression (1 finding, LOW); design pin
that bare verb+noun matches nothing; linter-doc FP pin split into bare
(silent) and determined (single LOW, never blocks). All fragment-built; the
CI prompt-injection scanner reports 0 findings on every touched file.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012rvqL1wk6s8dQEXsFm4RB1

* chore(#4016): changeset body in the canonical bold-lead format

.changeset/README.md Format: a leading bold change sentence, then an em-dash
explanation. Also drops the verbatim planted phrase from the body so the
rendered CHANGELOG line does not trip the pattern it describes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012rvqL1wk6s8dQEXsFm4RB1

* fix(#4016): render a bounded pattern label in the prompt-guard advisory, pin plural prompts

Review round 4 of PR #4061 left two nits open.

1. gsd-prompt-guard.js pushed `pattern.source` verbatim into its typed
   finding and, through renderFinding, into the user-facing advisory. With
   the #4016 superset pattern that source is 300 characters, so a genuine hit
   surfaced an advisory dominated by a raw regex dump. The read scanner has
   trimmed its equivalent since #3523 (`\s+` -> `-`, strip `()\`, cut at 50).
   That transform is hoisted into hooks/lib/injection-patterns.js as
   `describePattern` and used by BOTH hooks, so one finding renders the same
   label everywhere. Byte-identical to the scanner's old inline output for
   all 10 patterns (measured). No new staging dependency: both hooks already
   require this module.

2. The noun alternation `prompts?` had no positive coverage for the plural
   branch. One filler-regression row now exercises `... previous prompts ...`
   and runs through the existing once-per-hook, exactly-one-pattern loops.

The parity test's prompt-guard count assertion moves off substring-matching
the advisory prose onto the typed `findings` surface added in #3546, per
CONTRIBUTING's raw-text-matching prohibition. New test: the superset source
exceeds the bound (positive control), the prompt guard never embeds it, and
both hooks carry the identical label in `findings[0].match`.

Tests: parity, read-scanner, kimi field-shadowing, prompt-injection-scan,
hooks-crash-policy, dead-exports: 206 run, 196 pass, 0 fail, 10 pre-existing
platform skips. eslint clean; changeset lint ok; hooks runtime-build-seam lint
ok; the CI prompt-injection scanner reports 0 findings on the PR diff.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GGp8kEB5zCDmJ6TYHP1Nj

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-05 06:58:45 -04:00
Behruz Nassre Esfahani
5d804dd287 fix(#3709): clear the context-monitor warn sentinel on PreCompact (#3808)
* fix(#3709): clear the context-monitor warn sentinel on PreCompact

The monitor's per-session warn sentinel survived a compaction, so once the
first CRITICAL of a session had fired, `lastLevel` stayed pinned at 'critical'
for the rest of the run. The hook was already wired to PreCompact (#772), but
read the event only at the very END, and solely to pick an output envelope.

Two documented behaviours died as a result:

  - "First warning always fires immediately" — the first warning of the
    post-compaction cycle was debounced instead.
  - "Severity escalation (WARNING -> CRITICAL) bypasses debounce" — computed as
    `lastLevel === 'warning'`, which can never be true again, so every later
    CRITICAL waited out the full five-tool-use debounce, exactly when an
    immediate warning matters most.

`criticalRecorded` was equally sticky: a session that compacted and later truly
ran out kept a /gsd:resume-work breadcrumb (#1974) describing the earlier
near-miss rather than the exhaustion that ended the run.

Reproduced first, with the issue's own literal repro, including the detail that
the compaction consumed a debounce slot (callsSinceWarn 0 -> 1).

The reset runs BEFORE the metrics read, deliberately: a post-compaction reading
is healthy again, so the ENOENT / stale / above-threshold branches would all
exit first and never reach it. Returning early also stops the compaction from
eating a slot of the cycle it was meant to restart. The event name is now read
once through a shared `readEventName()` helper, so this reset and the #2289
output allowlist cannot drift on what counts as "no event name".

Seven rows against a real sequence (the defect is state carried ACROSS calls, so
they need their own driver — the existing helpers delete the sentinel after each
invocation). Reverting the reset turns SIX of them red; the seventh is the
non-vacuity row asserting a NON-compaction event must not clear the sentinel,
which correctly passes either way.

AC4 initially passed with and without the fix — asserting `criticalRecorded ===
true` is vacuous when the seeded stale sentinel already carries it. It now seeds
a `staleProbe` marker that can only survive if the sentinel survives, so its
absence is what proves the state was rebuilt.

hooks/dist/ is gitignored and regenerated by build:hooks, so no committed dist
copy needs syncing.

Verified: `npm run lint:ci` exit 0; acceptance criteria 1-6 driven end-to-end
against the real hook.

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

* fix(#3709): reset ahead of the config gate, and pin the placement itself

Codex review of the #3709 fix, before opening the PR. Three findings, all in
this change's own new code.

1. `context_warnings: false` prevented the reset. The config early-exit sits
   ABOVE where the reset was placed, so a session that disabled warnings,
   compacted, then re-enabled them mid-session resurrected the stale sentinel
   and the original bug with it. Config is re-read per invocation, so that
   sequence is supported rather than hypothetical. The reset now runs ahead of
   the config gate: clearing the sentinel is CLEANUP, not a warning — state that
   must not outlive a compaction should not outlive it merely because warnings
   are switched off right now. It cannot emit anything from there, so the
   disabled contract is untouched.

2. Nothing pinned the "before the metrics read" placement. Every row wrote a
   fresh metrics file, so the reset could have been moved below the metrics
   read, the stale check, or the healthy-threshold exit with all seven rows
   still green — while a REAL PreCompact, which carries no fresh metrics and
   follows a recovery to healthy usage, silently kept its sentinel. Three rows
   now pin it: no metrics file at all, usage recovered to healthy, and warnings
   disabled. Each catches a distinct wrong placement — moving the reset below
   the config check reds the third; below the metrics read reds all three.

3. The absent-sentinel row proved nothing. `assert.doesNotThrow` was vacuous
   because the driver caught every child exit, so a hook that exited 1 on the
   ENOENT unlink would still have passed. The driver now returns the exit code
   and the row asserts it is 0.

Also corrected the `readEventName` comment: it said the event is "read once",
which is not literally true — there are two call sites. The point is one
DEFINITION of what counts as an event name, so the reset and the #2289
allowlist cannot drift; the comment now says that.

Verified: 60 rows in tests/perf-317-context-monitor-fs.test.cjs, 0 fail, with
both placement mutations driven to red and reverted.

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

* chore(#3709): backfill changeset pr number

The fragment shipped with the documented `pr: 0` placeholder, which the
changeset lint treats as always-silent, because the PR number does not exist
until the PR is opened. Backfilled to 3808 now that it does.

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

* fix(#3709): a compaction clears the stale reading too, not just the state

Review round 1. Major 1 was right and it mattered: clearing only the sentinel
traded a warning that never fires for one that fires when it must not.

The statusline bridge still holds the PRE-compaction reading, and STALE_SECONDS
is 60, so for up to a minute it still reads fresh and still says the context is
exhausted. With the sentinel gone, firstWarn is true, so the next PostToolUse
emitted a spurious CONTEXT CRITICAL immediately after the compaction that FREED
the context — and flipped criticalRecorded, spawning a false context-exhaustion
breadcrumb. That is the same breadcrumb inaccuracy #3709 exists to fix, re-entered
from the other side. Reproduced before fixing, exactly as the review described.

A compaction now invalidates the warning state AND the reading that produced it.
Removing the bridge loses nothing: the statusline owns that file and rewrites it
on every render, and its absence is already the "no reading yet" state a fresh
session starts in, which exits silently.

Two things my own verification caught while fixing it:

  - The first attempt did NOTHING. metricsPath was declared below the PreCompact
    block, so referencing it hit the temporal dead zone, threw, and the outer
    catch swallowed it into a silent exit 0. The probe still printed "silent",
    which looked like success but was the old debounce. metricsPath is now
    hoisted beside warnPath.

  - The new Major 1 row was VACUOUS. The driver's `metrics: false` DELETES the
    bridge, but the defect is a bridge that is still there and still reads fresh,
    so the row passed on the ENOENT early-exit rather than on the fix. Only the
    sentinel-only mutation exposed it. The driver grew a `metrics: 'keep'` mode
    that leaves the stale file in place; both Major 1 rows now red under that
    mutation.

Also from the review:
  - Minor 1 — the compaction-abort path is now stated in the source rather than
    left silent, including why a conditional reset (SessionStart source "compact")
    is out of scope for this fix.
  - Minor 2 — docs/context-monitor.md completed: PreCompact wiring and the early
    return under How It Works, a table of all three things the reset clears, the
    breadcrumb guard, the warnings-disabled interaction, and the never-block
    property under Safety.
  - Minor 3 — changeset trimmed from ~1,400 chars of implementation narration to
    the user-visible change.
  - Nit 1 — a failed unlink (Windows EPERM/EBUSY) no longer leaves the bug
    silently intact: the file is neutralised in place instead, with a shape safe
    for each (an empty sentinel, a timestamp-0 bridge).
  - Nit 2 — reviewer-process narration removed from shipped test source. The
    remaining "Codex" mentions are pre-existing and name the RUNTIME.
  - Nit 3 — the debounce-slot row now asserts the observable consequence (the
    first post-compaction warning fires) rather than repeating AC1's assertion.
  - Nit 4 — the file docblock now lists the folded-in blocks and asks the next
    contributor to extend it.

Verified: `npm run lint:ci` exit 0.

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

* fix(#3709): the unlink-failure fallback truncates to empty, matching deletion

The fallback wrote well-formed neutral values, and neither was equivalent
to the deletion it stood in for: '{}' parses, so firstWarn was false and
the first post-compaction warning was debounced — AC2 undone on exactly
the path the fallback exists for — and '{"timestamp":0}' was never stale
(the guard is `metrics.timestamp && ...`), so the flow reached emit with
remaining === undefined and injected a literal 'Usage at undefined%'.
Truncating to '' makes JSON.parse throw on both reads: the sentinel read
keeps firstWarn true, the bridge read falls to the outer catch and exits
0 silently (review of #3808, Blocker 1).

The branch is now executed for real: an EPERM is injected into the
child's fs.unlinkSync via --require preload — method monkeypatching,
never chmod 0o000, which root bypasses under Docker/CI (Blocker 2). Both
rows proved failing-first against the neutral-value fallback. The
boundary trios at WARNING=35 / CRITICAL=25 are completed on the emit
path with 34, 26, and 24 (Major 3); 36/35/25 were already pinned.

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

* fix(#3709): the truncation fallback refuses to follow a planted symlink

The per-session files live in a shared sticky tmpdir, where an unlink
failing EPERM is exactly what another user's planted file produces — and
a planted SYMLINK would make the fallback's plain truncating write empty
out its TARGET, weaponising the hook against any file its own user can
write. Open with O_WRONLY|O_TRUNC|O_NOFOLLOW instead: a symlink fails
ELOOP into the same give-up arm. On Windows the constant is absent and
'|| 0' keeps the fallback alive there, where the held-handle case it
exists for occurs and temp dirs are per-user. Found by Codex review;
the new row proved failing-first against the writeFileSync fallback
(victim file truncated to zero bytes).

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

* fix(#3709): refuse non-regular files everywhere, not only where O_NOFOLLOW exists

Codex round 2: '|| 0' removed the no-follow protection exactly where it
cannot be expressed as an open flag — Windows, whose tmpdir is NOT
guaranteed per-user (TEMP/TMP overrides, system-temp fallback). An
lstat isFile() guard now rejects symlinks and every other non-regular
shape on all platforms before the truncating open; O_NOFOLLOW stays, as
the lstat->open substitution-race backstop where the platform has it.
The symlink row additionally asserts the planted link SURVIVES the call,
so a preload match that stops engaging can no longer pass the row
vacuously off a successful unlink.

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

* test(#3709): tolerate the Windows give-up, still outlaw neutral values

Both windows-latest CI lanes fail the two EPERM rows deterministically:
the runners hold freshly written files with a share mode that allows
DELETE (every real-unlink row passes) but refuses a truncating
write-open, so the fallback's give-up arm engages — which is the
fallback working as designed, not the defect the rows exist to catch.
The rows are now platform-aware: POSIX still requires exact truncation
and the behavioural follow-ons; Windows accepts truncated-or-untouched
but still rejects the Blocker-1 regression class (a parseable neutral
value is never legal anywhere), with the follow-ons gated on the
truncation actually landing. Also corrects the hook comment: libuv
defines O_NOFOLLOW as 0 on Windows — a no-op, not an absent constant.

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

* fix(#3709): a compaction watermark closes the window bridge deletion only narrowed

Round-3 Major 1: the statusline is an uncoordinated process that
re-writes the bridge on every render, so a render landing between the
PreCompact clear and the compaction's completion re-created the
PRE-compaction reading under a CURRENT timestamp — past STALE_SECONDS,
into a spurious post-compaction CRITICAL and a false exhaustion
breadcrumb: the exact failure the deletion was added to prevent.
PreCompact now also writes claude-ctx-<id>-compacted.json ({at}) and the
metrics read drops any reading not STRICTLY newer than it — which also
covers unstamped/zero timestamps once a compaction happened. Written
unlink-then-O_EXCL so a planted file or symlink is never followed;
failure degrades to the old narrowing. Docs and changeset now describe
the watermark instead of overclaiming for the deletion.

Round-3 Major 2: DEBOUNCE_CALLS and STALE_SECONDS get their trios — the
gate increments BEFORE comparing, so seeds 3/4/5 pin 4-debounced,
5-emits, 6-emits; ages 59/60/61 pin the strict >. The child's clock is
pinned via a --require preload (a wall-clock boundary row would flip on
one second of startup delay). timestamp-0's falsy bypass is pinned
directly as characterized behaviour. Mutation-proven: dropping
O_NOFOLLOW, <= for <, and >= for > each red exactly one row.

Minors: the symlink row's comment now names the lstat guard it actually
pins, and a preload-blinded-lstat row drives the O_NOFOLLOW substitution
-race backstop for real (3); absence assertions use warnRaw so a
corrupt leftover cannot pass as deleted (4); the Windows give-up is an
explicit t.skip, never a silent if (5); readEventName is total via
String(), keeping #2289's side-effects-always-run contract for
malformed event names, with a row (6); the PreCompact rationale lives
once in docs/context-monitor.md with the code keeping only line-level
constraints (9); the changeset is release-note-sized (10).

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

* fix(#3709): the grace window covers the compaction's duration, not just its start

Codex on the first watermark cut: the watermark stamps the compaction's
START, so a statusline render one second later — still mid-compaction,
still the old reading — passed 'strictly newer' and re-fired the false
CRITICAL. Readings inside COMPACT_GRACE_SECONDS (60) past the watermark
are now dropped: the window covers the compaction's own duration, a
healthy reading dropped there behaves identically to an accepted one
(it exits above-threshold anyway), and a genuine exhaustion warning is
delayed at most one window after a compact. A watermark stamped ahead
of the reader's clock is ignored — a clock step backwards or a stray
file must degrade to plain staleness, never mute the monitor
indefinitely. Both proven failing-first.

readEventName is strict about TYPE, not coerced: String() rendered
['PreCompact'] as 'PreCompact' and would run the reset off a malformed
payload. typeof: every non-string is 'no event' — silent, side effects
intact — with rows for the number, hostile-object, and array-wrapped
cases. The lstat-claim preload arm now writes an engagement marker the
substitution-race row asserts on, so a match string that silently stops
matching can no longer let the row pass off the real lstat guard.

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

* chore: retrigger CI — the previous wave was cancelled by an Actions outage, zero job failures

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

* fix(#3709): drive the compaction rows on the clock, not on a future stamp

Round-4 review raised three majors, all in the test scaffolding around the
fix rather than in the fix itself.

Major 2 (taken first — it is the cheapest and it unblocks Minor 6): call()
passed process.env to the child unmodified, so two rows depended on ambient
GEMINI_API_KEY. The preserved Gemini fallback is `eventName === "" &&
!!process.env.GEMINI_API_KEY`, and readEventName returns "" for every
malformed name, so with the key set the malformed-event row's `stdout === ''`
assertion failed outright — reproduced by running it under GEMINI_API_KEY=x.
call() now takes an explicit env, the way the sibling runMonitor helper in
this file always has, and both rows pin the variable unset. (The array row
survived an ambient key only because its reading was debounced — incidental,
not independence, so it is pinned too.)

Major 1: the AC2/AC3 rows drove the hook with a bridge stamped 62 seconds in
the FUTURE — a shape hooks/gsd-statusline.js cannot produce, since it always
stamps Math.floor(Date.now()/1000) on the same clock. They proved "the
sentinel was cleared" while their assertion messages claimed the documented
immediate-warning behaviour, which is gated behind the grace window and went
unexercised. Both rows now run the real sequence on the clock-pinning preload
this PR already added for the STALE trio: PreCompact at a fixed instant, then
a normally-stamped render one second past the window. Verified non-vacuous —
stubbing the sentinel unlink reds both.

Major 3: COMPACT_GRACE_SECONDS, the one constant this PR introduces, was the
only threshold without a limit-1/limit/limit+1 trio, in a PR that adds full
trios for four pre-existing ones. The seeded offsets were +0, +1 and +61; the
boundary itself (+60) and limit-1 (+59) were untested. Added, driven by
advancing the reader's clock rather than post-dating the reading, so the
reading is never ahead of the reader and only the grace gate can drop it.
Verified against three mutations — `>` to `>=`, the constant to 59, and the
constant to 61 — each of which reds exactly one row of the trio.

No production code changed. Verified: 85/85 in this file, lint:ci exit 0,
and the two Minor-6 rows now pass under GEMINI_API_KEY=x as well as unset.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAbQy7n4mLMB7h3TnZ8GdG

* fix(#3709): harden the watermark read and pin the thresholds it introduces

Codex review of the full PR found four majors. All reproduced here against
the real hook before fixing.

MAJOR — the watermark was write-hardened but read-untrusted. PreCompact
already refuses to follow or overwrite a planted object (unlink-then-O_EXCL),
but the read was a bare readFileSync, so anything the write side gave up on
was followed by every later invocation. In a shared sticky os.tmpdir() that
is a mute primitive — a planted recent watermark suppresses monitoring — and
a symlink to a FIFO stalls a synchronous read. Measured against the
pre-hardening file: a symlink to a planted watermark WAS honored and muted
the monitor. The read now uses the same lstat + O_NOFOLLOW pair the sentinel
path uses, plus a size bound; symlink, directory and oversized cases are all
refused, with a plain-file control proving watermarks still work.

MAJOR — the `now + 5` skew tolerance was an unnamed, untested threshold. It
is now WATERMARK_SKEW_SECONDS with a +4/+5/+6 trio, verified against two
mutations (`<=` to `<`, and the constant to 6), each of which reds one row.
This is the same class as round 4's Major 3, one layer up.

MAJOR — the malformed-event row shared one session across both subcases, so
the hostile-object iteration's `assert.ok(s.warn())` passed off the sentinel
the `42` iteration left behind. A regression throwing before the bookkeeping
would have kept it green — vacuous for exactly the subcase it exists for.
Fresh session per subcase, with an explicit no-sentinel precondition.

MAJOR — the stale-reading row's non-vacuity is an artifact of call()'s future
stamp: with a production stamp the watermark suppresses the same reading, so
the row cannot isolate bridge deletion. The two guards genuinely overlap
inside the window, so no end-to-end row can separate them; the comment now
says so and points at the direct pin (s.metrics() === null) instead of
claiming an isolation it does not have.

Docs corrected where measurement contradicted them: the window NARROWS the
race rather than covering the compaction's duration, and the delay is not
bounded by the window alone — first recovery is watermark+61s with no skew
but watermark+66s at the accepted +5s skew. Aborted compactions are muted
the same way. The truncation fallback is documented as best-effort, which is
what the code and the Windows rows already do.

Verified: 89/89 in this file, lint:ci exit 0, symlink/directory/oversize all
refused where the pre-hardening file honored them, both new trios
mutation-checked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAbQy7n4mLMB7h3TnZ8GdG

* fix(#3709): move the PR's two new exits onto the declared-policy vocabulary

#3911 / ADR-3889 migrated this hook off raw process.exit() while this PR was
in review, replacing every exit with hooks/lib/hook-exit.js's allow(), which
forces each call site to name its crash policy. The PreCompact reset and the
watermark gate are added by THIS PR, so they did not exist to be migrated and
came through the merge as the only two raw exits left in the file — caught by
the new local/require-registered-exit rule. Both are ALLOW: a compaction is
never blocked by this hook, which is the policy the rest of the file declares.

Caught only in CI, not locally: `npm run lint` runs eslint with --cache, and
the cached entry for this file predated the new rule, so a warm local cache
reported clean. Re-verified with the cache cleared.

allow() terminates rather than throwing, which matters for the watermark call
site because it sits inside a try/catch — a throwing helper would unwind into
that catch and silently drop the grace-window mute. Verified behaviourally,
not by reading: the grace trio, the skew trio and the non-regular-file rows
all still pass.

Verified: lint:ci exit 0 with a cold eslint cache, full suite exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAbQy7n4mLMB7h3TnZ8GdG

* fix(#3709): harden the routine sentinel writes and the read beside them

Round 7 ruled that the three routine debounce-accounting writes to the warn
sentinel must match the three writes this PR already hardened: leaving the
fourth unhardened beside them is the asymmetry that invites the defect back.
They now go through one writeSentinel() helper using the compaction
watermark's own unlink-then-O_EXCL shape, rather than a second policy — the
unlink removes any existing object, and O_EXCL then refuses to create through
one, so a write can only land on a fresh regular file this process made.

The routine READ beside them was the last bare readFileSync on warnPath, and
the same rationale applies to it verbatim; the watermark's read was hardened in
round 4 for exactly this reason. Same lstat + O_NOFOLLOW + size bound. Its
scope is stated in the test rather than overclaimed: lstat establishes that the
sentinel is a plain regular file, not that it is trustworthy, so a cross-owner
regular file at the predictable path is still read and is left as a disclosed
pre-existing residual.

Also fixes an accept-direction regression this PR introduced and six rounds of
review missed. readEventName collapsed an ABSENT event name and a MALFORMED one
onto the same '', and the preserved Gemini fallback keys off eventName === "",
so with GEMINI_API_KEY set a malformed payload began emitting an AfterTool
envelope. At the merge-base, data.hook_event_name.trim() threw on a truthy
non-string after the side effects and nothing was ever emitted. Measured
base-vs-head with a fresh sentinel per run: 42, ['PreCompact'] and {} all went
silent -> EMITS, while an absent name and 'PostToolUse' were unchanged.
readEventName now returns '' only for an absent name and null for a
present-but-non-string one; both call sites compare for equality only, so every
well-formed payload behaves identically.

Five new rows, each proven fail-first with the mutations attributed separately:
reverting the writes reds the write-through and non-regular rows, reverting the
read reds the mute and non-regular rows, and reverting the absent/malformed
split reds the Gemini row. The changeset's "behaves like a fresh session" is
narrowed to name the 60-second suppression window and the best-effort reset.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018FUAVz49BghqxoJgwt7EW9

* test(#3709): pin both 4096-byte read bounds at their boundaries

Round 8 asked for limit-1/limit/limit+1 coverage on the size bound the
round-7 sentinel read-hardening introduced (gsd-context-monitor.js:335).
The existing refusal row pads to 8192 -- a full 4096 bytes clear of the
fence -- so `>` vs `>=`, or an off-by-one in the constant itself, was
invisible to it.

Covers the sibling bound too. The identical check guards the round-4
WATERMARK read at :278 and its refusal row pads to 8192 in exactly the
same way; the review's own rationale (this file already holds
WATERMARK_SKEW_SECONDS to a boundary trio, so an uncovered bound is the
odd one out) applies to it unchanged. That half is a class sweep of a
pre-existing bound and is test-only -- say the word and it comes out
without touching the rest.

Both trios assert on observable hook output rather than an internal
error. Sentinel: an honored {callsSinceWarn:1,lastLevel:'warning'} keeps
the debounce arm taken at remaining=30, so nothing is emitted, while a
refused one falls back to first-warn defaults and emits. Watermark:
honored mutes (stdout empty), refused leaves the warning. The 4097 row is
the non-vacuity control for the two accept rows. Payloads are sized by
measurement, with Buffer.byteLength asserted to equal the target, not by
arithmetic on an assumed prefix width.

Proven fail-first in both directions, with the hook restored after:
`> 4096` -> `>= 4096` reds both trios (94/96); `> 4096` -> `> 4097` reds
both trios (94/96); restored, 96/96. Under both mutations only the two
new rows fail -- the pre-existing 8192-padded rows stay green, which is
the review's fencepost claim demonstrated rather than assumed.

* fix(#3709): correct the changeset's mute-window claim and a superseded comment

Both from the pre-push Codex pass on the full PR.

The changeset said readings are "suppressed for up to 60 seconds after a
compaction starts". That is false at the accepted skew boundary, and this
repo's own docs/context-monitor.md already carried the accurate figure:
first recovery is watermark+61s with no skew and watermark+66s for a
watermark at the +5s skew limit. Measured independently at +64 silent,
+65 silent, +66 warning. The changeset now states the window plus the
accepted skew, matching the doc rather than contradicting it.

A comment in the malformed-event row still described readEventName as
returning "" for every malformed name. Round 7 superseded that: a
present-but-non-string name returns null and only an ABSENT one returns
"", so a malformed payload can no longer reach the Gemini fallback at
all. Marked as historical and corrected. The GEMINI_API_KEY pin stays --
the row is about readEventName's typing, not the fallback, and an ambient
key would still change what it measures.

Codex's three Major findings are not taken, on attribution rather than
logic; the reasoning is in the PR reply. In short: the watermark does not
exist at the merge-base at all (0 occurrences), so "base emits, HEAD
mutes" compares a new feature against its absence rather than showing a
regression; and the base sentinel read is a bare readFileSync, which
blocks on a planted FIFO exactly as the hardened read would, so the
TOCTOU stall is not introduced here. The underlying limits -- watermark
provenance, and lstat->open races on a non-symlink substitution -- are
real, pre-existing, and already offered to the maintainer as follow-ups.

* fix(#3709): read both sentinels through one hardened helper; state the two limits precisely

Round 9's Major, with a correction to its premise, and both Minors.

The review names "watermark read/write helpers this PR adds" that a call
site at :238-250 duplicates inline. There are no such helpers: this PR
adds readEventName and writeSentinel, the latter a write-side primitive a
read cannot call, and :238-248 is base code the diff never touched. What
IS duplicated is the hardened READ. The watermark read (round 4) and the
warnPath read (round 7) are the same ten lines twice -- lstat, isFile and
a 4096-byte bound, O_RDONLY|O_NOFOLLOW, readSync, close -- differing only
in the path variable and the error string, and that is two copies to keep
in step by hand. Now one function, readSentinel(target), beside
writeSentinel. Refusal throws; both callers already wrapped the read in a
try/catch that degrades to "no file", so behaviour is unchanged by
construction.

Proven rather than assumed: with the helper replaced by a bare
readFileSync in a complete scratch tree, exactly the five hardened-read
rows in tests/perf-317-context-monitor-fs.test.cjs go red -- round 7's
symlinked and non-regular sentinel and its size bound, round 4's
non-regular watermark, round 8's watermark size bound -- so the helper
carries both call sites' guarantees and the rows pin it. 96/96 with the
helper in place.

Minor, drop vs delay: the grace-window comment said "dropped" on one line
and "delayed" three lines later, and docs/context-monitor.md said
"delayed". A genuine exhaustion reading inside the window is skipped, not
queued: its warning and its #1974 breadcrumb both fire on the next reading
after the window, so both are delayed when a later reading comes and lost
when none does -- a session ending inside the window records neither.
Comment and docs now say exactly that, and that the loss is accepted over
trusting a reading that may be the pre-compaction value under a fresh
timestamp.

Minor, ordering: the PreCompact unlink and the debounce
writeSentinel(warnPath) are two writers with nothing serialising them; a
debounce invocation that read pre-compaction state and lands its write
after the unlink would resurrect the sentinel the reset removes. The hook
relies on the host dispatching a session's hooks one at a time, which
Claude Code does and the other runtimes are assumed to. Stated at the
reset as an assumption, with the lock-file alternative named and not
taken.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TadqrpTE2m6gCB7CaNNLcy

* fix(#3709): write the compaction watermark through writeSentinel

Review of #3808, round 10. The PreCompact watermark write was the block
writeSentinel was lifted from in round 7, and it kept its own inline copy
of unlink-then-O_EXCL a few lines below the helper. Round 9 flagged that
write-side duplication; the round-9 reply misread it as the read side and
unified only the reads. The write now calls the helper too, so the hook
holds one copy of the hardened write, not two.

Behaviour is unchanged: same unlink-then-O_EXCL sequence, same flags,
same best-effort outer catch. The one difference is that writeSentinel
closes the descriptor in a finally, where the inline copy leaked it if
writeSync threw before closeSync.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7bQLXAKubb4EFLtCPiLiL

* fix(#3709): read the statusline bridge through the same hardening as the sentinels

Review of #3808, round 11. `metricsPath` is built one line from `warnPath` and
`watermarkPath` — same tmpdir, same predictable `claude-ctx-{sessionId}` shape,
same threat model this PR documents at length for its siblings — and it is the
only one of the three read on EVERY invocation. It was also the only one still
reached by a bare `readFileSync`, so the symlink-follow and the symlink-to-FIFO
stall that rounds 4 and 7 closed on the other two stayed reachable here, on the
file's highest-traffic path. It now goes through `readSentinel` like the rest.

The 4096-byte bound is ample for it: the statusline writes four fixed fields
(`gsd-statusline.js`), about 140 bytes with a UUID session id, so no legitimate
bridge approaches it. A refusal lands in the same rethrow an unreadable or
malformed bridge already did.

The comment introducing `readSentinel` claimed the warn sentinel was "the one
bare readFileSync". Read as scoped to `warnPath` that was true, but it reads as
a claim about the file and it is not one — the bridge kept its own until this
round. Corrected rather than left to mislead the next reader.

Round 11 Minor: `readSentinel` discarded `fs.readSync`'s return value and
assumed the buffer was full, so a file truncated between the `lstat` and the
read left a zero-filled tail. It now refuses a short read. Stated plainly
because it was measured: this guard has NO observable behavioural delta —
deleting it leaves the new row green, because the NUL tail makes `JSON.parse`
throw one line later and both paths degrade to "no sentinel". It is a
consistency fix in a function whose purpose is refusing to trust what it read,
and the test comment says exactly that rather than implying coverage it lacks.

Five rows added: the bridge refusing a planted symlink (with an attacker-chosen
reading that WOULD warn if followed, so silence is proof), a non-regular bridge,
an oversized bridge, the shrink path end to end, and the direction that matters
most — a healthy bridge still warns, so the hardening is not a mute. Proven by
mutation: reverting the bridge to `readFileSync` reddens two rows. The shrink
injection carries an engagement marker for the same reason the lstat-claim one
does, learned the same way: the hook rewrites the sentinel later in the
invocation, so a size check afterwards passes whether the truncation landed or
not.

An independent full-PR pass on this round added two more, both taken:

`writeSentinel` discarded `fs.writeSync`'s return value, and a short write is
permitted by the syscall — so a truncated sentinel could reach disk and every
later read would reject it, silently losing the debounce accounting or the
watermark this write exists to record. It now loops until the payload is
written, as Node's own `writeFileSync` does, with an explicit no-progress guard.
Pinned by a row that injects a one-byte first write; reverting the loop reddens
it.

The directory row's comment claimed it pinned the `lstat` isFile() check. It
does not — measured: deleting that condition leaves the row green, because
reading a directory fails on its own a line later. The comment now says the row
pins the outcome, and names the symlink row as the one that pins isFile().

DISCLOSED, NOT FIXED HERE — a session id long enough to push the derived
filenames past NAME_MAX. The bridge is `claude-ctx-{id}.json`; the sentinel and
watermark add longer suffixes, so on a 255-byte limit the watermark stops fitting
at a 230-character id and the sentinel at 233. Measured base-vs-HEAD at 233+:
base is SILENT, HEAD emits the warning, because the bare `writeFileSync` base
used threw ENAMETOOLONG out of the warning path while `writeSentinel` degrades
best-effort and lets the warning through. That is an accept-direction delta and
it is in the delivering direction — base swallowed a warning the user should
have seen, which is this issue's own failure class. The underlying limit is a
property of the per-session filename scheme, shared by two files that predate
this PR, and bounding session ids belongs to whatever writes them
(`gsd-statusline.js`), not to the sentinel logic. Happy to fold a length guard
in here if you would rather have it in this PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRMEuzNMWn3gs5uUW2ghcF

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-05 06:29:06 -04:00
Dennis Alexis Valin Dittrich
f9bb489363 fix(#4204): isolate verify:post CLI test capability discovery from real HOME (#4293)
tests/loop-hooks-verify-post-e2e.test.cjs's runCli stripped ambient GSD_*
env vars but never sandboxed HOME, so the spawned gsd-tools CLI
subprocess fell through to os.homedir() (capability-loader.cts's
overlayRoots and capability-state.cts's resolveCapabilityRuntimeState
both thread process.env['GSD_HOME'], which falls back to os.homedir()
when unset). Any capability genuinely installed at ~/.gsd/capabilities
on the machine running the suite (e.g. beads, markdown-linting) leaked
into the verify:post registry and inflated the file's exact-count
assertions (3 -> 5 active hooks, 0 -> 2 on the all-off case, etc).

Switch runCli to helpers.cjs's installSpawnEnv(), the helper ~370 other
test files already use for this: it sandboxes HOME/USERPROFILE to a
per-file mkdtemp'd fixture and clears the full config-location env list
(GSD_HOME, GSD_RUNTIME, CLAUDE_CONFIG_DIR, etc.), so the CLI subprocess
sees only the core registry regardless of what's installed on the host.
The pure resolveLoopHooks() tests in the same file were already
unaffected — they call the resolver directly with realRegistry,
bypassing CLI env resolution entirely.

Fixes #4204

Co-authored-by: Test <test@test.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-05 05:58:29 -04:00
Dennis Alexis Valin Dittrich
5869febb16 enhance(#4155): invalidate verification results when covered inputs change (#4290)
* enhance(#4155): invalidate verification results when covered inputs change

readVerificationStatus() now recomputes a deterministic sha256 fingerprint
over a VERIFICATION.md's declared covered_files (phase PLAN/SUMMARY,
requirements, implementation files in the verified change set) and returns
stale on any mismatch, fail-closed when a covered file is missing,
unreadable, or escapes the project root. Legacy reports with no fingerprint
metadata keep the prior SUMMARY-mtime staleness check unchanged.

The verifier computes covered_digest via the new verification.fingerprint
CLI command rather than by hand, since a digest is deterministic math, not
an LLM-estimated value.

* chore(#4155): backfill fork PR number in changeset

* fix(#4155): trim gsd-verifier.md fingerprint instructions to fit LARGE tier byte cap

* fix(#4155): address CodeRabbit findings on fingerprint fail-closed behavior

Partial fingerprint metadata (one of covered_files/covered_digest present,
the other missing or malformed) now fails closed to stale instead of
silently downgrading to the legacy mtime-only check. computeCoveredDigest
also canonicalizes with realpathSync before re-confining, so an in-root
symlink whose target escapes the project root can no longer produce a
matching digest. gsd-verifier.md restores the completeness requirement and
checklist item trimmed by the earlier size-budget fix, within the LARGE
tier byte cap.

* chore(#4155): acknowledge gsd-verifier.md growth for the #4155 fingerprint instructions

Emitted-Drift-Ack-Growth: gsd-verifier.md — adds the covered-input fingerprint instructions and frontmatter fields the #4155 verification staleness mechanism requires; trimmed to stay within the LARGE tier byte cap

* fix(#4155): address gemini adversarial review findings

computeCoveredDigest now threads the caller-supplied opts.fs seam through
its confinement and read paths instead of always using raw node:fs — a
caller like planning-inspect.cts's containmentEnforcingVerificationFs (GAP
2, #2790 follow-up) was silently bypassed for covered-input reads. The
project-root anchor itself still canonicalizes through real fs (it is a
trusted value the caller derived, not attacker-influenced covered-input
data); only per-file candidate reads go through the injected seam.

Covered-file paths are now canonicalized (./ prefixes, redundant slashes,
internal .. segments) before becoming dedup/sort/hash keys or confinement
subjects — closes both a spurious-stale false positive (two spellings of
the same file hashing differently) and a confinement gap (an internal ..
segment that doesn't start the string).

gsd-verifier.md now states covered-file paths are project-root-relative,
not phaseDir-relative, closing an ambiguity that would have made a real
verifier agent's first fingerprint invocation fail closed.

defaultFsImpl's methods now late-bind through fs.<method> rather than
capturing function references at module load — the earlier direct-capture
form was invisible to existing tests' t.mock.method(fs, 'statSync', ...)
seams, a real regression caught by the full suite (not the reviewer).

* fix(#4155): catch a plan/summary added to the phase dir after verification but never declared

The content digest only recomputes hashes for paths the verifier actually
declared in covered_files — it had no way to notice a plan or summary
added to the phase directory after verification if that new file was
never declared, silently regressing behind the legacy mtime check it
replaces (which scans the live directory, not a declared list).

findUncoveredCurrentArtifact re-scans the live phase directory for every
current *-PLAN.md/*-SUMMARY.md and requires each to be represented in
covered_files, closing that gap; a directory scan failure fails closed to
stale rather than silently skipping the check.

CONTEXT.md's Verification Module entry corrected to describe the
fingerprint path's stricter fail-closed FS-error contract (routes to
stale) instead of the module's original degrade-to-safe one (missing /
not-stale), which only the legacy path still keeps.

* refactor(#4155): extract canonicalizeCoveredFiles, add real nested-project e2e test

computeCoveredDigest and cmdVerificationFingerprint each normalized/deduped/
sorted covered_files independently — one shared helper now backs both
(gemini review's ponytail-lens finding).

Adds one CLI-to-readVerificationStatus test against a genuine
.planning/phases/NN-x/ project with an implementation file outside
.planning/ entirely, closing the review finding that prior #4155 unit
fixtures put phaseDir directly under an ownerless tmpdir (findProjectRoot
falls back to phaseDir itself there) and never exercised real multi-level
path resolution.

* fix(#4155): route computeCoveredDigest through real fs, fail closed on unreadable plans/

Two independent review rounds (opus critical-reviewer + opus ponytail +
agy, run twice) found two instances of the same fail-open class:

- computeCoveredDigest's per-file reads routed through the caller's
  injected fsImpl. planning-inspect.cts passes a `.planning/`-confined
  containment fs into readVerificationStatus's opts.fs, so any covered
  implementation file outside `.planning/` (mandatory per the issue)
  made the confinement wrapper throw, which was caught and turned into
  a stale digest -- reporting every fingerprinted phase permanently
  stale via `planning.inspect`, regardless of actual drift. Per-file
  reads now always use real node:fs, matching the pre-existing
  treatment of root canonicalization; the realRel-vs-realRoot check is
  the real confinement boundary for this data and needs no seam.

- allCurrentArtifactsCovered's try/catch never fired (scanPhasePlans
  reports readdir failures via a `scope` field, it never throws), so
  an unreadable nested plans/ dir was silently treated as "zero
  artifacts, all covered" instead of failing closed. Now branches on
  scope !== SCOPE.COMPLETE.

Also, per ponytail's second-round findings: reverted an unwarranted
FINGERPRINT_VERSION bump and digest length-prefix from the first fix
(no v1 digest has ever existed -- the feature is unreleased -- and the
prefix closed a collision that grants no capability beyond what a
writer of covered_files already has more cheaply); removed a
verifier-facing escape-hatch instruction whose own example was a case
that should trigger staleness, not bypass it; corrected CONTEXT.md
references to the renamed allCurrentArtifactsCovered and a stale
"unconditional" rescan claim; simplified the isStale derivation,
removed dead FsLike members, and tightened test coverage.

Regression tests for both fail-open bugs are included and were each
confirmed to fail against the pre-fix code before the fix landed.

full test suite: 2558/2560 pass, 2 skipped, 0 fail

* fix(#4155): trim gsd-verifier.md under the LARGE size cap

Fork CI caught what my local runs missed: the superseded/nested-plans
instruction added earlier pushed gsd-verifier.md to 49299 bytes,
147 over the LARGE tier's 49152-byte hard cap
(tests/agent-size-budget.test.cjs). Tightened the #4155 instruction's
wording and dropped a redundant inline comment tag; no content lost.

* chore(#4155): point changeset at the upstream PR number

pr: 19 was the fork PR opened for internal review-lane CI; now that
open-gsd/gsd-core#4290 exists, the changeset field must match it per
CONTRIBUTING.md's release-notes convention.

---------

Co-authored-by: Test <test@test.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-05 05:42:52 -04:00
Behruz Nassre Esfahani
5ad9a36f35 fix(#4255): resolve reviewer-lane effort from the lane, not from gsd-plan-checker (#4275)
`review-lane plan` resolved every cross-AI reviewer lane's reasoning effort by
spawning `query resolve-execution gsd-plan-checker --host <slug>`. The agent id
was a hardcoded literal, so `--host` chose only the argv RENDERING while the
LEVEL always came from the installed plan-checker's frontmatter — `low` under
every shipped model profile. Every prompt-fed lane therefore ran at a fast
structural verifier's effort, and because the rendered argument is a CLI config
override it silently beat the effort the operator had configured for that CLI.
At `low` a large source-grounded prompt makes a model end its turn with no final
message, so the lane came back empty and its stub read as a crash.

Effort is a property of the review, so the lane declares it. Two new fields on
ReviewerLane — `effortConfigKey` (`review.effort.<slug>`) and `defaultEffort` —
carried through each capability manifest and the generated registry, set on the
three lanes with an argv effort channel and null on the other nine. A new pure
`resolveLaneEffort()` resolves config key -> lane default -> nothing, where
"nothing" emits no effort argument at all and the reviewer CLI's own
configuration decides; `inherit` selects that path explicitly and an
unrecognized level falls back to the lane default rather than being forwarded to
a CLI that would reject it. The host's negotiated effortSurface still gates the
rendering, so ADR-1239/#2481's trust boundary holds on this path too. Resolving
in-process also removes up to twelve subprocess spawns per review.

The empty-output stub now names the effort the lane ran at and distinguishes a
clean exit from a timeout kill, a non-zero exit, and a process that never ran —
`status` is null for both a timeout and a signal, so those were indistinguishable
before. The hint is hedged: a clean empty exit is most often a model stopping
short, but it is also consistent with a CLI writing its output elsewhere.

Also: the capability validator now knows both fields, rejects a malformed key or
an out-of-vocabulary default, and rejects a default declared without a config
key (a level the operator could never override). An existing end-to-end row in
tests/effort-surface-axis.test.cjs asserted the old coupling; it now configures
the lane's own key and pins the decoupling in the same real spawn, with the
agent execution tier set to a level that must not appear.

Emitted-Drift-Ack-Growth: review.md — the effort/model resolution-order table this fix adds. The workflow is where an operator looks to find out which knob set a lane's model and effort; leaving the new key undocumented there is the same invisibility that made the plan-checker coupling survive this long.

Emitted-Drift-Ack-Growth: review.md — the effort/model resolution-order table this fix adds. The workflow is where an operator looks to find out which knob set a lane's model and effort, so leaving the new key undocumented there is the same invisibility that let the plan-checker coupling survive.

Claude-Session: https://claude.ai/code/session_01CRMEuzNMWn3gs5uUW2ghcF

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-05 05:25:44 -04:00
Dennis Alexis Valin Dittrich
925a363879 enhance(#4032): apply configured agent tool grants (#4238)
* test(4032): add failing installed-agent grants contract

Cover global and project agent_tools precedence at the real Claude installer seam before adding implementation.

* feat(4032): apply configured agent tool grants during staging

Resolve selector-level global and project config once per staging call, then append validated grants before runtime conversion.

* test(4032): cover host grant and quoted MCP contracts

Exercise installed host artifacts and prove ZCode must treat quoted MCP scalars like plain MCP grants.

* feat(4032): apply configured agent tool grants across runtimes

Move augmentation and scalar identity into the converter seam so every staged artifact preserves host policy.

* fix(4032): register agent tool grants in configuration

Accept documented agent_tools config without unknown-key warnings.\n\nKeep installer fixtures on the shared temporary-directory helper.

* fix(4032): translate configured MCP grants for Kilo

Reuse the converter-owned scalar decoder so quoted canonical grants reach Kilo's native permission keys without altering other host policies.

* fix(4032): decode YAML-escaped tool grants

* fix(4032): emit valid inline agent tool grants

* fix(4032): reject invalid trailing-colon grants

* test(#4032): cover cross-review remediation gaps

* fix(#4032): close cross-runtime grant gaps

* test(#4032): expose Kimi global project context

* fix(#4032): preserve Kimi project config context

* chore(#4032): add release note

* test(#4032): expose fork review regressions

* fix(#4032): address fork review findings

* test(#4032): make byte-stability assertion portable

Compare repeat installs at one root so platform-specific path rendering cannot
masquerade as an agent_tools behavior change.

* chore(#4032): bind changeset to upstream PR 4238

* fix(#4032): address trek-e review findings (2,3,4,5,6,7,8)

Fixes fail-closed decode-failure handling in ZCode's mcp__ stripper,
a comment-only `tools:` header mis-parse that silently dropped
configured grants, and a naive comma-split that could tear a quoted
scalar containing a literal comma. Documents Kilo's inherent
`{server}_{tool}` MCP-permission-key collision (external, fixed
format — not ours to widen) and locks the existing first-seen-wins
resolution in with a regression test.

Opts kimi/kimi-code out of the ADR-1235 pre-converter path-rewrite
step: routing Kimi through that pipeline (needed so project-scoped
agent_tools selectors reach it) was short-circuiting Kimi's own
neutralizeKimiAgentPrompt, which expects the original ~/.claude/gsd-core
text rather than a pre-rewritten Kimi path.

Extends the fast-check token pool and per-runtime install coverage
with the missing comment/comma/broad-runtime cases the prior review
flagged as untested.

* docs(#4032): add CONTEXT.md glossary entries for agent_tools resolver + pre-converter step

Documents readGsdEffectiveAgentTools (Install Model Override Resolver
Module) and the appendAgentTools pre-converter pipeline step (Runtime
Artifact Conversion Module), per contributor-standards.md's
new-seam glossary requirement (finding 1).

* fix(#4032): address agy adversarial review findings

An agy (gemini-3.8-flash-high) adversarial pass over the prior review-fix
commit found the fixes for findings 3, 4, 6 and 8 had unfixed sibling gaps,
plus a genuine new regression and two CONTEXT.md inaccuracies:

- ZCode's comment-only `tools: # note` header matched the inline-value
  branch instead of falling through to the block-list scan, so a following
  mcp__* item leaked through unstripped — the exact defect finding 4 fixed
  in appendAgentTools, unfixed in this sibling function.
- Reverted capabilities/kimi-code/capability.json's noPathRewrite: true.
  kimi-code uses the standard 'agents' kind with converter: null (not
  kimi-agents — confirmed by reading the descriptor, not its prose
  description), so it never went through the pipeline change finding 5
  fixed, and disabling its path rewrite broke every ~/.claude/ embed in
  its shipped agents instead.
- decodeToolScalar never stripped a trailing ` # comment` from a bare
  (unquoted) scalar, so a comment after a block-list item, or after an
  appended grant on an inline line, became part of the "tool name" —
  fixed at the source (one call site fixes every consumer).
- appendAgentTools's comment-index scan wasn't quote-aware, so a `#`
  inside a quoted scalar (`"mcp__server #1"`) was mistaken for a comment
  start and corrupted the quote.
- parseFrontmatterTools (Kimi/Qwen's tool-list reader, downstream of
  appendAgentTools's own output) had the same naive comma-split and
  comment-only-header gaps as findings 4 and 6, unpatched.
- The all-runtime smoke test's presence assertion was built on a guessed
  omit-list; empirically only 7 of 17 runtimes keep an arbitrary mcp__
  grant recognizable, replaced with a verified allowlist.
- CONTEXT.md claimed a `project:<agent>` selector prefix that does not
  exist (project override is a same-key merge across two config files)
  and mislabeled stageAgentsForRuntimeWithConverter's module.

* fix(#4032): address full-PR review (Opus critical/ponytail + agy)

A whole-PR pass (critical-code-reviewer + ponytail-review on Opus, plus a
second agy full-source adversarial pass) surfaced defects the earlier
finding-scoped passes couldn't reach:

- appendAgentTools corrupted a `tools:` line whose ENTIRE value is a
  leading quoted scalar (`tools: "Read"` -> `tools: "Read", Write`,
  invalid YAML) — there is no safe line-surgical rewrite here, so it now
  refuses to touch that shape instead of emitting broken frontmatter.
- decodeToolScalar's malformed-trailing-quote check ran BEFORE comment
  stripping, so a bare tool name with a quote inside its own trailing
  comment (`Bash # note: "internal"`) was wrongly rejected. Reordered.
- findUnquotedCommentIndex (added in the prior remediation commit) was
  built on a wrong model of YAML: a `#` after whitespace starts a real
  comment in a plain scalar regardless of nearby quote characters —
  verified against the actual parser. The one case that DOES need
  protection (a leading quoted scalar) is now refused outright above, so
  the quote-tracking scan was dead weight solving a problem that no
  longer reaches it. Removed; reverted to the plain `[ \t]#` scan.
- Kilo has a SEPARATE agent-frontmatter parser (convertClaudeToKiloFrontmatter,
  distinct from the buildKiloAgentPermissionBlock fixed earlier) with the
  same comment-only-header and naive-comma-split gaps as findings 4 and 6
  — unfixed in both its src/ and bin/install.js copies. Fixed in both,
  exporting splitToolScalars for bin/install.js to reuse rather than
  reimplementing it.
- Pipeline docstring in stageAgentsForRuntimeWithConverter still listed 5
  steps, omitting appendAgentTools (now step 3 of 6).
- docs/CONFIGURATION.md didn't state that a --global install still
  discovers agent_tools from the cwd's .planning/config.json (confirmed
  intentional and already covered by a dedicated test, not a bug).
- Removed install-engine.cts's deps.cwd injection seam: zero callers or
  tests ever populated it.

Two claims from this round were verified and rejected, not fixed:
prototype pollution via a `__proto__` selector key (empirically confirmed
`Object.prototype` is never touched — only reassigns the resolver's own
local object's prototype, with no observable effect), and a `*` grant
value crashing YAML parsing as an alias reference (empirically confirmed
it parses as plain scalar text, no crash). A pre-existing, unrelated
defect (extractFrontmatterField returns null for block-list `tools:` on
Copilot/Antigravity/Cursor/Codex/Qwen, affecting two shipped agents
today) was filed as a follow-up rather than fixed here — it predates
#4032 and isn't caused or worsened by this PR.

* fix(#4032): update stale slug-derivation-drift-guard fixture line

normalizeKimiSkillName's real closing brace moved from line 616 to 635 as a
side effect of this PR's edits to runtime-artifact-conversion.cts; the
MAJOR-1 fixture's hardcoded realEndLine had gone stale.

* fix(#4032): address CodeRabbit findings on projectDir threading and flow-sequence tools

bin/install.js's installAgentsKindStandalone call site omitted the projectDir
argument the function already supports, so a global install through this
legacy branch silently fell back to the runtime config dir instead of
process.cwd() when resolving project-scoped agent_tools grants — inconsistent
with the sibling installOpencodeFamilyArtifacts call site, which already
threads it correctly.

appendAgentTools' leading-quoted-scalar bailout did not cover a YAML flow
sequence (`tools: [Bash, Read]`): splitToolScalars tore it apart on the
in-sequence commas and appended past its closing bracket, producing invalid
frontmatter. Extended the bailout regex to also refuse a value starting with
`[`, matching the same "whole node, nothing may follow" reasoning already
applied to quoted scalars.

---------

Co-authored-by: CI Rebase Check <ci@gsd-redux>
Co-authored-by: Test <test@test.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-05 04:52:45 -04:00
Dennis Alexis Valin Dittrich
e8800287d5 enhance(#4153): fail closed unresolved update targets (#4237)
* test(#4153): cover unresolved update target

* fix(#4153): fail closed unresolved update target

* test(#4153): require a concrete recovery installer

* fix(#4153): use concrete unresolved recovery command

* chore(#4153): bind changeset to fork PR

* test(#4153): cover portable update diagnostics

* fix(#4153): keep update diagnostics portable

* fix(#4153): harden update version diagnostics

* test(#4153): reject jq in update version checks

* test(#4153): expose step-local parser gap

* fix(#4153): keep JSON parsing step-local

* docs(#4153): align update target guidance

* test(#4153): expose workflow runtime fallback

* test(#4153): expose resolver runtime fallback

* fix(#4153): leave unknown workflow runtime empty

* fix(#4153): stop inferring Claude for unknown targets

* test(#4153): preserve Claude workflow targeting

* test(#4153): preserve known runtime directory identity

* fix(#4153): recognize Claude workflow paths

* fix(#4153): reuse known runtime directory identities

* chore(#4153): acknowledge emitted workflow growth

The fail-closed diagnostic and known-runtime preservation deliberately add 48 emitted bytes.

Emitted-Drift-Ack-Growth: update.md — explicit unresolved-target diagnostics and known-runtime preservation

* test(#4153): expose missing Windsurf workflow contract

* docs(#4153): document Windsurf update targets

* chore(#4153): bind changeset to upstream PR

* fix(#4153): gate unresolved-target exit before the VERSION-missing fallback

The VERSION-missing bullet in get_installed_version sat before the
UPDATE_TARGET_UNRESOLVED exit and shared its trigger condition (version
0.0.0). An LLM agent reading the workflow top-to-bottom could satisfy
"proceed to install" without ever reaching the fail-closed exit this
PR adds, reopening the ill-defined mutating path #4153 closes. Reorder
so the unresolved-target gate runs first and scope the VERSION-missing
bullet to require an already-resolved target.

Also drop two vacuous mutationSpies entries: they checked '--sync'/
'--reapply' (commands/gsd/update.md content) against `step`, a slice of
workflows/update.md — always -1 regardless of correctness. Those routes
bypass get_installed_version entirely and are already covered by
install.test.cjs, reapply-patches.test.cjs, and
skill-frontmatter-contract.test.cjs.

* chore(#4153): point changeset pr field at fork PR #10 for fork CI

* test(#4153): guard RUNTIME_DIRS/update.md table parity, confirm narrowing intent

Nit 1: update.md's PREFERRED_RUNTIME prose and RUNTIME_DIRS
(src/update-context.cts) are two independently maintained copies of the
same runtime->dir mapping with no parity check; add one so a future
edit to either surface without the other fails loudly instead of
silently drifting.

Nit 2: call out in the changeset that a custom --config-dir matching no
known runtime, marker file, or env var now resolves unresolved instead
of silently defaulting to claude -- this narrowing is intentional, it's
the fail-closed behavior #4153 asks for.

* fix(#4153): drop dead $UC fallback in check_latest_version's uc_field, cover unresolved-runtime fast path

agy (gemini-3.8-flash-high) adversarial review of the full PR:

1. check_latest_version's uc_field() copy-pasted get_installed_version's
   `${2:-$UC}` fallback, but every call site here passes $2 explicitly and
   $UC does not exist in this step's scope -- dead, misleading reference.
   Use $2 directly.
2. No unit test covered resolveUpdateContext's preferredConfigDir fast path
   returning runtime: '' for a custom --config-dir matching no RUNTIME_DIRS
   suffix, marker file, or env var (the exact fail-closed case #4153 adds).
   Added.

A third finding (update.md:90 using /gsd:update vs docs using /gsd-update)
was investigated and rejected: /gsd:update is the actual registered
Claude Code command name (commands/gsd/update.md name: gsd:update) and is
locked by this PR's own test (tests/update-workflow.test.cjs); /gsd-update
is a separate, pre-existing, intentional prose convention used in
audience-facing docs (README/INVENTORY/FEATURES). Not a defect.

* chore(#4153): backfill changeset pr field to upstream PR #4237

---------

Co-authored-by: CI Rebase Check <ci@gsd-redux>
Co-authored-by: Test <test@test.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-05 04:17:21 -04:00
Cody Anderson
77e2472ca0 enhance(#4221): replace installer Read() deny rules with a managed secret-read guard hook (#4236)
* feat(#4221): gsd-secret-read-guard PreToolUse hook + registration

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

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

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

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

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

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

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

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

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

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

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

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

Review round 1 (trek-e):

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

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

---------

Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-05 04:00:08 -04:00
Dennis Alexis Valin Dittrich
d0d542e478 fix(#4183): resolve root phase Fallow base (#4215)
* test(#4183): reproduce root phase Fallow base failure

* fix(#4183): resolve root phase Fallow base

Fall back to the phase root commit when its parent is unresolvable.

* chore(#4183): add release note

* chore(#4183): bind changeset to fork PR

* docs(#4183): describe root fallback precisely

* chore(#4183): bind changeset to upstream PR

---------

Co-authored-by: CI Rebase Check <ci@gsd-redux>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-05 03:32:17 -04:00
Dennis Alexis Valin Dittrich
eedb6b5431 enhance(#4107): sequence external review after internal fixes (#4206)
* enhance(#4107): sequence external review after internal fixes

Teach the planner to finish internal review and accepted fixes before opening a PR known to trigger automatic external review. If an open-time property exists, re-check it immediately before opening with nothing intervening; post-open CI, review, changeset, and tracking work may follow.

Emitted-Drift-Ack-Growth: gsd-planner.md — issue #4107 adds the review-before-publish ordering rule

* chore(#4107): add PR #11 changeset

* chore(changeset): link upstream PR 4206

* fix(#4107): ground external-review terms and tighten ordering test

Addresses trek-e review on PR #4206:
- Ground 'known automatic external review' and 'open-time property' with
  concrete anchors (CodeRabbit App / .coderabbit.yaml, not-behind-base).
- Suffix the antipatterns heading with (#4107), matching sibling sections.
- Replace vacuous negative assertion with inverted-order fixtures that
  prove the ordering regexes reject bad phrasing, not just co-occurrence.

* fix(#4107): make directionality fixtures genuinely adversarial

agy (gemini-3.8-flash-high) adversarial review found the two negative
fixtures added in 584ec1cda were vacuous: they proved the ordering regexes
require certain keywords, not that they reject inverted order — the bad
strings simply omitted required tokens rather than reordering them.

- Rebuild both fixtures to contain every required token, reordered/negated,
  so a real reordering would still slip past a weaker regex.
- Drop the unsupported 'changeset' mention from the Wave 4+ antipatterns
  example — gsd-core/workflows/ship.md never references changeset work,
  so naming it here implied a step this rule doesn't actually govern.

* fix(#4107): make the full review-then-fix-then-open sequence explicit

CodeRabbit (fork PR #11) flagged that the planner prose only ordered
accepted fixes before PR open, without explicitly naming 'run internal
review' as its own earlier step, and that no fixture tested the planner
text's own wording for inversion (only the antipatterns example had one).

- Prose now reads 'run internal review and apply the accepted
  internal-review fixes before the final open'.
- Added a planner-text-specific inverted-order fixture alongside the
  existing antipatterns-example one.

---------

Co-authored-by: Test <test@test.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-05 03:14:27 -04:00
Dennis Alexis Valin Dittrich
a262ad6b61 fix(#4148): dispatch wave-pre step hooks (#4185)
* fix(#4148): dispatch wave-pre step hooks

External capabilities can render step hooks before a wave, but the execute workflow consumed only contributions and silently skipped every step. Reuse the shared dispatch contract before executor spawning and pin the capability-validator boundary with a red-first regression.

Emitted-Drift-Ack-Growth: execute-phase.md — wave-pre now carries the missing generic step-dispatch contract before executor spawning

* test(#4148): pin wave-pre dispatch ordering

* test(#4148): pin wave-pre dispatch contract

* chore(#4148): bind upstream changeset PR

* chore(#4148): restore fork changeset identity

* fix(#4148): align wave-pre dispatch contract

Mirror the sibling wave-post all-shapes clarification while pruning redundant prose so the rebased workflow remains below its frozen byte ceiling.

Emitted-Drift-Ack-Growth: execute-phase.md — wave-pre now carries the missing generic step-dispatch contract before executor spawning

* chore(#4148): restore upstream changeset identity

* fix(#4148): align wave-pre capability guidance

* docs(#4148): identify wave-pre manifest input

Name the third-party manifest trust origin at the wave-pre dispatch boundary so the reviewer-requested validation guidance matches wave-post.

Emitted-Drift-Ack-Growth: execute-phase.md — wave-pre now carries the missing generic step-dispatch contract before executor spawning

* docs(#4148): preserve execute-phase byte budget

Remove a redundant advisory label while retaining the non-blocking contract, keeping the reviewer-required trust-boundary wording at the enforced 93,400-byte ceiling.

* fix(#4148): mark wave-pre manifest-input validation as security-relevant

Reviewer nit on PR #4185: wave-pre's step-dispatch sentence had the
(third-party manifest input) parenthetical but dropped the ⚠ marker
that wave-post's parallel sentence (execute-phase.md:1044) carries,
losing the visual flag that this validation is security-motivated.

Trims the redundant "of one" from "not one shape of one" to reclaim
the 4 bytes the marker adds — the ADR-857 byte-margin gate
(tests/claude-orchestration.test.cjs) leaves zero slack at the
93,400-byte ceiling.

* fix(#4148): trim wave-pre step-dispatch prose to clear ADR-857 byte ceiling

Merging next's unrelated growth (#3990's TDD_APPLICABLE conditional) pushed
execute-phase.md 116 bytes past the 93,400-byte ceiling, failing CI on all
three platforms. The security-relevant ⚠ marker and ref.command validation
call-out (added per prior reviewer nit) are preserved verbatim per the
pinned regression test in capability-registry.test.cjs; only the
non-pinned connective prose is trimmed.

* fix(#4148): recalibrate execute-phase.md self-imposed margin, restore security marker

next grew execute-phase.md by ~230 bytes across two unrelated merges during
this fix (#3990's TDD_APPLICABLE conditional, then a further step-extraction
commit), consuming this test's own self-imposed 93,400 safety buffer under
ADR-857's actual, unmodified 93,600 ceiling (docs/adr/857-capability-system.md:22).
The wave-pre step-dispatch sentence cannot shrink further without dropping one
of the pinned substrings this same test file asserts on (kind=="step",
loop-hook-dispatch, never blocks or redirects executor spawning, Validate
`ref.command`).

Raises the self-imposed margin to 93,550 (still 50 bytes under the real,
untouched ADR ceiling) and restores the ⚠ marker the prior reviewer round
required for the ref.command validation call-out, which byte pressure had
dropped.

* fix(#4148): restore full ref.command validation wording, drop self-imposed margin

Adversarial review (agy/gemini-3.8-flash-high) flagged two issues in the prior
CI-recovery commit:

1. Trimming "in-context before any shell use" from the step-dispatch warning
   weakened the inline operational instruction (the reader is told WHAT to
   validate but not the specific in-context-not-shell mechanism the referenced
   loop-hook-dispatch.md:45-51 threat model requires). Restored it - the merge
   with next since the last commit freed enough real margin (77 bytes under
   the untouched 93,600 ADR-857 ceiling) to afford it without any margin
   change.

2. The prior commit self-imposed margin bump (93400 to 93550) was, on
   reflection, the wrong lever: it is a number this PR invented, not an ADR
   value, and re-bumping it every time next grows execute-phase.md is a
   losing pattern (already needed twice in one session). Removed the
   redundant assertion; the same line existing bytes-under-93600 check
   against the real, frozen ADR-857 ceiling (docs/adr/857-capability-system.md:22)
   is the actual invariant and is untouched. workflow-size-budget.test.cjs
   tier hard cap (98304 bytes, extract-not-bump by design) remains the
   correct backstop for runaway growth.

---------

Co-authored-by: CI Rebase Check <ci@gsd-redux>
Co-authored-by: Test <test@test.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-05 02:56:37 -04:00
Adnan
0f3959516c fix(#4106): drop the orphaned test:mutation:since script (#4179)
* fix(#4106): drop the orphaned test:mutation:since script

`"test:mutation:since": "stryker run --incremental --since origin/next"`
could not run. Stryker has no `--since` flag — verified against the pinned
`@stryker-mutator/core` 9.6.1, whose options schema has no `since` property
and whose CLI exits `error: unknown option '--since'`.

It went unnoticed because nothing invokes it. `.github/workflows/mutation.yml`
uses the per-module matrix instead: `scripts/mutation-matrix.cjs` computes the
changed covered modules and each shard runs its own
`npx stryker run --incremental --mutate "${MUTATE_GLOB}"`. That path works, so
no gate ever exercised the script.

It is still worth removing rather than leaving: its name makes it the obvious
thing to reach for when a reviewer asks for a mutation score on changed scope,
and the Commander parse error it produces says nothing about the matrix being
the real entry point — the next person has to reverse-engineer mutation.yml to
find that out.

Delete rather than repoint. The issue offered both, and the maintainer had not
picked; delete is the option that stays scoped to the reported bug. The
suggested replacement (`node scripts/mutation-matrix.cjs --base origin/next`)
emits a CI matrix rather than running mutants, so shipping it under a
`test:mutation:*` name would be a second, differently-shaped claim, and
documenting the per-module invocation in CONTRIBUTING.md is enhancement-shaped
work that belongs in its own issue. The discovery command is named in the
changeset instead. Flipping this PR to the pointer script is a one-line change
if that is preferred.

The regression test asserts that every `stryker` script in package.json passes
only flags Stryker's own options schema defines. It carries its own control —
the historical `--incremental --since origin/next` string is checked to still
be rejected by the same predicate — so the sweep cannot go quietly vacuous if
the remaining invocations lose their flags.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017zgWzB96uz3LVKdJePYTLR

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017zgWzB96uz3LVKdJePYTLR

* docs(#4106): mark the changeset docs-exempt

`type: Removed` makes docs-lint require a `docs/` change. Nothing under
`docs/` or in CONTRIBUTING.md ever referenced `test:mutation:since` — the
script was orphaned and could not run — so there is no documented behaviour
to update. Using the gate's own per-fragment escape hatch rather than
weakening the changeset type to dodge the requirement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017zgWzB96uz3LVKdJePYTLR

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-05 06:21:02 +00:00
Behruz Nassre Esfahani
c6efe2905c fix(#4087): stage the hook helpers the Codex bundle's hooks require (#4117)
* fix(#4087): stage the hook helpers the Codex bundle's hooks require

CODEX_HOOKS_TO_COPY is a flat, hand-maintained filename allowlist that never
recursed, and Codex is excluded from installSharedHooksBundle() — the path that
stages hooks/lib/ for full-bundle runtimes — by an !isCodex gate. Excluding
hooks/lib/ was a correct scoped decision for #3579 until #3911 (2ea5efc15) gave
gsd-context-monitor.js a real require('./lib/hook-exit.js'). From then on every
fresh --codex install staged the hook without its helper and the hook died with
MODULE_NOT_FOUND at module load, before its own try/catch, on every event Codex
registers it for. The install still exited 0, so nothing surfaced it.

Reproduced before changing anything, in a sandboxed CODEX_HOME: four hooks
staged, no lib/, and the installed hook exiting 1 on "Cannot find module
'./lib/hook-exit.js'".

Rather than hand-add today's three helpers — which re-breaks the next time a
Codex-bundled hook grows a lib dependency, exactly how this regressed — the
transitive-require walk already written for Cursor in 704859e9c is extracted out
of writeCursorHooksJson into an exported stageTransitiveHookLibs(), Cursor is
rewired onto it, and the Codex copy loop calls it. bin/install.js already
required that module, so this adds no new seam. Cursor's staged set is
byte-identical to base, compared file by file.

Extraction surfaced a latent defect in that walker, fixed here: its regex read
`./X` and `./lib/X` identically, but from a hook SCRIPT a bare `./X` is a
sibling in hooks/ — gsd-check-update-worker.js requires
`./managed-hooks-registry.cjs`, which is not a lib — so it demanded
hooks/lib/managed-hooks-registry.cjs and the fail-loud guard threw. Seeds now
match only `./lib/X`; lib files still match both, which is the
sibling-within-lib case 704859e9c exists for. Cursor never exposed it because
none of its scripts carries a bare sibling require.

Three further grammar gaps closed after review, each in the fail-closed
direction: an extensionless `require('./lib/x')` is valid CommonJS and was
resolved literally, failing the install on a legitimate require — now resolved
through .js/.cjs and written under its resolved name; a NESTED `./lib/sub/x.js`
could not be expressed by the character class and was a SILENT miss, the one
failure mode this function exists to remove — now refused loudly; and a capture
carrying no alphanumeric character is prose, not a module name — hooks/lib/
injection-patterns.js documents this very mechanism with the literal string
require('./lib/...'), which captured `...` and sent the resolver hunting for
hooks/lib/... . The scan is still not comment-aware, which is disclosed at the
call site rather than papered over.

Seeded from the entries THIS invocation staged rather than probing the
destination, so a file left by an earlier install whose source is no longer
allowlisted cannot contribute helpers for a hook that is no longer shipped.

The #3579 boundary holds: three of ten helpers ship, gsd-graphify-rebuild.sh
among those correctly absent. Seven rows — three driving a real install into a
sandboxed config dir (with HOME sandboxed for the child, since Codex's skills
kind resolves from os.homedir() and the #3712 guard rightly refuses otherwise)
and four pinning the discovery grammar directly. All proven fail-first; the
set-equality row also reds on over-staging, which the count-based version it
replaced did not catch.

Fixes #4087
Fixes #4098

Emitted-Drift-Ack-Hash: hooks/lib/hook-exit.js — newly emitted for codex because the installer now stages the helpers its hooks require; the helper's own content is unchanged
Emitted-Drift-Ack-Hash: hooks/lib/cli-exit.js — newly emitted for codex as hook-exit.js's transitive require; the helper's own content is unchanged
Emitted-Drift-Ack-Hash: hooks/lib/exit-code-registry.js — newly emitted for codex as cli-exit.js's transitive require; the helper's own content is unchanged
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018FUAVz49BghqxoJgwt7EW9

* chore(#4087): add changeset

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018FUAVz49BghqxoJgwt7EW9

* fix(#4087): stage the hooks/lib helpers the Windsurf guards require

Review of #4117, verified as asked and reproduced against a real install.

Windsurf sets hostBehaviors.skipSharedHooksInstall, so like Cursor it never
reaches installSharedHooksBundle -- the only other stager of hooks/lib --
and writeWindsurfHooksJson staged its two Cascade guards without the
helpers both require at module load: gsd-windsurf-pre-write.js requires
./lib/hook-exit.js and ./lib/git-probe.js, gsd-windsurf-pre-command.js
requires ./lib/hook-exit.js. stageTransitiveHookLibs had one call site,
Cursor's.

Measured on a fresh `--windsurf --global` install into a sandboxed HOME:
the installer exited 0, hooks/ held only the two scripts and package.json,
and executing either installed guard exited 1 with "Cannot find module
'./lib/hook-exit.js'" -- so every pre_write_code and pre_run_command event
failed at load while the install reported success. The same command with
`--cursor` staged four helpers and its hook ran, which is the control.

Pre-existing rather than introduced here: at merge-base 05092ff36 the same
three require lines exist and writeWindsurfHooksJson already staged no
lib/, and this PR's diff carried no reference to Windsurf. Fixed here
anyway because the helper this PR extracted is the right tool and a second
runtime is a few lines onto it.

writeWindsurfHooksJson now calls stageTransitiveHookLibs after staging its
scripts, with the same gsd: -> gsd- transform the scripts receive, so a
helper is rewritten the same way as its caller. The install-tree fixture
regenerates with exactly hook-exit.js, git-probe.js, cli-exit.js and
exit-code-registry.js added and no other fixture moved. Two rows execute
the INSTALLED guards, beside the Codex rows they mirror; the existing
windsurf-hooks-bridge rows run the guards from source and test behaviour,
a different question, and are left as they are. Both new rows fail-first
against the unfixed compiled artifact -- gsd-core/bin/lib, which is what
bin/install.js loads -- on the MODULE_NOT_FOUND assertion.

Emitted-Drift-Ack-Hash: hooks/lib/git-probe.js — first staged for Windsurf, whose pre-write guard requires it; the file itself is unchanged
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TadqrpTE2m6gCB7CaNNLcy

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-05 05:49:37 +00:00