Commit Graph

681 Commits

Author SHA1 Message Date
Tom Boucher
86bebcefa2 refactor(#3216): bind milestone identity to the canonical locator (#3226)
* refactor(#3216): widen milestone-window guard to literal-## matchers

The guard keyed only on the `#{N,M}` quantifier plus a literal version or
phase-lookahead token. getMilestoneInfo hand-rolls its milestone-heading match
with a literal `^##`/`## ` and an interpolated ${escapedVer}, so it satisfied
neither token and the guard reported a clean zero on a file carrying live
re-derivations (#3171, #3197) — a zero it did not earn.

Widen token (a) to a literal 2-6 `#` run, admitted ONLY inside a heading-MATCHER
literal (a regex literal, or a string/template handed to new RegExp) so a
heading-BUILDING template is not mistaken for a re-derivation. Widen token (b)
with the grouped `v(\d+(?:\.\d+)+)` shape and an interpolated version
placeholder.

Ships BEFORE the consolidation per ADR-3180 s7.2: a guard widened afterwards
measures an already-cleaned surface. It is expected to be RED until the
consolidation lands.

* test(#3216): failing-first milestone-identity single-owner suite

63 tests across two files, from the matrix in .gsd/phase/. Section H of
milestone-window-single-owner.test.cjs covers the 21 input classes of the
design's behavior table plus its negative space; milestone-window-drift-guard
covers the widened tokens and proves the exemption is function-scoped, not
file-scoped.

Copy count is 3 found by the guard, not 1 per the epic (ADR-3180 Amendment 3's
standing rule, holding for the fourth consecutive phase): both getMilestoneInfo
sites plus cmdRoadmapAnalyze's milestone enumeration at roadmap.cts:454, which
carries the same #3171 truncation and #3197 phase-heading confusion.

Expected RED until the consolidation lands.

* refactor(#3216): bind milestone identity to the canonical locator

getMilestoneInfo hand-rolled two milestone-heading regexes inside the owner's
own file. Both were wrong, differently: the STATE-version site's ^## anchor is
level-blind so [^\n]* absorbs a third #, and the fallback site had no anchor at
all, so '## ' matched from the second # of '###'. Against
'### Phase 7: Close v3.3 gaps' the fallback returned {v3.3, gaps} (#3197). Both
captured names with [^\n(], truncating at a parenthetical (#3171).

Bind both to the canonical grammar. locateMilestoneHeadings becomes a
version-filtered view over one shared source, and a new version-agnostic
listMilestoneHeadings enumerates milestone headings for callers that need all
of them. getMilestoneInfo returns ScopedResult<MilestoneInfo|null>; the
{v1.0,'milestone'} default, which was output-identical to a real v1.0 project,
is deleted. The #2245 never-throws invariant is preserved.

Copy count: 3 found by the guard, not 1 per the epic. The third was
cmdRoadmapAnalyze's own milestone enumeration (roadmap.cts:454), carrying both
defects in the implementation the epic blessed.

buildStateFrontmatter and archivePhaseDirectories branch on scope: the first
writes null rather than a fabricated identity, the second falls through to its
dated-label fallback. A fabricated v3.3 passes ARCHIVE_VERSION_LABEL_RE, so it
would otherwise misfile phase history.

Also fixes an unsafe cast in init.cts that masked these type errors across five
call sites, which would have shipped undefined milestone fields under green tsc.

* fix(#3216): restore the #1761 unbounded guard and bullet precedence

Review and the first full-matrix run surfaced five real defects in the
consolidation, all fixed here rather than by relaxing the tests that caught
them:

- buildStateFrontmatter gated its isMilestoneBoundedInRoadmap check on the
  scope-gated milestone value, which is null on any non-COMPLETE scope, so the
  #1761 unbounded guard was silently skipped and state json reported a percent
  it must omit. It now gates on the STATE-asserted version, independent of
  identity scope.
- The rewrite lost #2135's precedence: the name-bearing progress-marker bullet
  is consulted before the heading again.
- A single-segment version (v3, no dot) did not resolve; the name-extraction
  fallback now accepts it.
- A version carrying regex metacharacters, or a $& / $1 replacement pattern,
  is matched literally.
- listMilestoneHeadings' heading field trimmed, so a CRLF roadmap no longer
  leaks a trailing carriage return into roadmap analyze's output.

Also emits milestone_version / milestone_name / current_milestone as explicit
null rather than omitting the key, so the prompt layer cannot render a bare
placeholder, and corrects an init.cts comment plus a cast left inconsistent.

* test(#3216): update milestone-identity expectations to the scoped contract

getMilestoneInfo returns ScopedResult<MilestoneInfo|null> and the
{v1.0,'milestone'} default is deleted, so the suites asserting the old shape
assert removed behavior. Updated rather than weakened: every touched call site
now asserts the scope explicitly against the frozen SCOPE enum.

roadmap-parser.test.cjs: 20 expectations moved to {value,scope}. The #1881
unreadable-vs-absent diagnostic assertions are untouched and still prove their
original point — only the return shape moved. One pre-existing assert.ok(info)
is now a specific UNSCOPED assertion, so that case is stronger than before.

new-milestone-clear-phases.test.cjs: the test asserting phases clear archives
under the v1.0 default now asserts the dated archived-<YYYYMMDD> fallback,
which is the deliberate consequence of deleting that default.

Two of this branch's own tests were also corrected after they drove the
implementation the wrong way: the parity test compared raw heading text and so
pushed a stray ## prefix into roadmap analyze's public output, and the hostile
metacharacter row demanded a pathological version resolve, which pushed a
widening of the ADR-locked \b boundary. Both now assert what the contract
actually requires.

* docs(#3216): document milestone identity and correct the CONTEXT.md entry

ADR-3180 s7.2 moves to Enforced and gains two rules that were unstated: the
name derives from the heading's own version token and drops a trailing status
marker, and a free-form legacy ROADMAP with no version anywhere is UNSCOPED
with no identity rather than a defaulted v1.0 (decided by the maintainer before
implementation, per s7's own rule that an unstated behavior is not decided).
Amendment 4 records Phase 6's validation, including that the copy count was a
lower bound for the fourth consecutive phase.

CONTEXT.md's Roadmap Parser entry described locateMilestoneHeadings as
boundary-matched with (?![\w.-]) — the alternative Amendment 2 tried and
REVERTED. The code uses \b and says so, and the ADR agrees; the revert updated
code and ADR and missed CONTEXT.md, which is the epic's own fixed-on-one-copy
failure class in the docs layer, on a file that is itself a PR gate.

* fix(#3216): persist the real version on a truncated identity

buildStateFrontmatter wrote null for BOTH milestone and milestone_name on any
non-COMPLETE scope, discarding a real version. ADR-3180 s7.2 rule 6: a version
known with no resolvable name is TRUNCATED carrying {version, name: null} —
'the version is a real answer, the name is a non-answer, and collapsing the two
is the failure this contract exists to prevent.'

The two fields are now gated by what is actually known: the version whenever one
exists (COMPLETE or TRUNCATED), the name only on COMPLETE. Never fabricated.

Caught by this phase's own Decision 4(c) consumer-output test, which is the
argument for asserting at the consumer rather than the owner — the owner was
correct throughout; only the consumer collapsed its answer.

* refactor(#3216): extract helpers and make cmdCommit's scope gate explicit

From the two-axis code review:

- init.cts repeated the identical getMilestoneInfo cast at five sites with
  copy-pasted comments — duplication inside a PR whose thesis is that duplicates
  get deleted. Extracted milestoneRecord(cwd); the one site-specific comment is
  kept, the four generic copies removed.
- getMilestoneInfo hand-built its { value, scope } literal at ten return points;
  a local scoped() constructor now does it once. Every per-branch rationale
  comment is preserved and no returned value or scope changed.
- cmdCommit gated the milestone branch name on plain truthiness, which is also
  true for TRUNCATED, so an unresolved identity drove branch creation
  incidentally rather than deliberately. It now gates on the SCOPE enum,
  accepting COMPLETE or TRUNCATED because both carry a real version, and the
  comment records why that differs from archivePhaseDirectories — which demands
  COMPLETE because it uses the value as a filesystem path component.

* test(#3216): cover the bare-version-in-prose truncated path

The spec review found the bareVersionMatch path — no STATE version, no
milestone heading, a version token only in prose — returning TRUNCATED with no
test exercising that exact shape, violating Decision 4's boundary-coverage
requirement.

* docs(#3216): record the missed Tier-2 surfaces and rule 5's corollary

Decision 3 requires an explicit call-out for EVERY Tier-2 change, and Amendment
4's first draft named eight surfaces while the change touched thirteen. Adds
cmdCommit's branch-name construction and the four init JSON bundles, an
incomplete list being the same defect in miniature that this epic removes.

s7.2 rule 5 gains a corollary separating two cases the original wording ran
together: no version token ANYWHERE is UNSCOPED, while a bare version token in
prose or a non-milestone heading is weak but real evidence and yields TRUNCATED
under rule 6.

* chore(#3216): set changeset fragment pr to 3226

---------

Co-authored-by: sim <sim@local>
2026-08-08 19:06:13 -04:00
Tom Boucher
b9f51836e6 refactor(#3180): ADR-3180 behavior contract + cross-surface drift guardrails (#3223)
* refactor(#3180): one owner for completion ratio, a prompt-layer drift guard, and a written behavior contract

The 2026-08-08 coverage audit on #3180 found the epic's copy counts were a
lower bound for the third consecutive time, and that two derivation families
had never been named at all.

ADR-3180 gains Decision 7 — a normative behavior contract that says what the
right answer IS for each derivation, not merely who owns it. A reviewer with
no written rule can only ask "does this look like the others", which is how a
fifth copy passes review. Decision 4 gains (d) scan surface is every authored
surface and an owner FILE is never exempt, only its named functions; and (e)
a surface that cannot be consolidated today ships ratcheted, never unguarded.

Completion ratio: `clampPercent` sat exported and unused beside six hand-inlined
copies of its own body across five modules. All six now route through it;
`clampPercentFromFraction` is added for the one caller that already held a
fraction. Every migration is behaviour-identical — clampPercent's first line IS
the `total > 0 ? … : 0` ternary each copy carried. Guarded by
lint-completion-ratio-drift.cjs, which reports zero re-derivations with no
file-level exemption.

Prompt layer: workflow markdown re-derives live-plan counting in raw shell
(#1762), invisible to every `src/`-scoped guard. lint-planning-prompt-drift.cjs
scans it with a shrink-only baseline of the 7 sites that exist today — new
sites fail, and a baseline entry that stops firing fails too, so an
acknowledgment can never outlive the thing it describes.

lint-milestone-window-drift.cjs stops exempting its owner file wholesale; only
the four named canonical functions are exempt now. The blanket exemption was
pointed at the one file most likely to grow the next copy, and it had.

Refs #3180

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

* docs(#3180): link Phases 6-8 sub-issues (#3216, #3217, #3218) from ADR-3180

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

* fix(#3180): address orthogonal review — consumer-output identity tests, count-keyed ratchet, property coverage

Five findings from the two orthogonal review passes, all fixed.

Decision 4(c) breach: the completion-ratio identity test asserted at the
OWNER, which is exactly the bypass that decision exists to close — a consumer
can call clampPercent and then post-process locally, leaving both the lint and
an owner-level test green. It now drives `roadmap analyze`, `query progress`
and `stats` and asserts on their own output, over a fixture containing a
`status: superseded` plan so a consumer that re-counted raw files would report
60 where the owner reports 75.

Decision 4(e) breach: ratchet entries named the epic (#3180) rather than the
issue that removes them. They name Phase 8 (#3218) now.

The ratchet keyed on (file, text) alone, so plan-phase.md's two byte-identical
sites were one indistinguishable key and migrating either would have left the
guard green with the other alive. Entries carry an occurrence count; fewer than
acknowledged fails as a partial migration, more fails as a new copy.

Adds the missing MAX_REGEX_LITERAL_LEN boundary coverage the sibling guard's
test already had, and the fast-check property tests CONTRIBUTING requires for
clamp/budget-limit functions.

Refs #3180

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

* test: stop wrapping a nested double-spawn in a 15s wall-clock budget (bug #641 probes)

`tests/ci-test-scope.test.cjs`'s `bug #641` block spawned `run-tests.cjs`
under PROBE_TIMEOUT_MS=15000; that child then spawned a nested `node --test`.
A fixed wall-clock budget around a double spawn, running inside a container
that is concurrently executing the full ~31k-test suite, fails by construction
under load.

Confirmed against three full matrix runs. Every failure was shaped
`null !== 0` — the child was KILLED, never an assertion about the thing under
test. One captured probe had already printed the correct resolution
(`suite="all" files=2: a.test.cjs b.test.cjs`) and was killed anyway. It
reproduces on `next` alone: 5 failures on linux-node22, 0 on linux-node24. The
victim subset varies by run and by lane.

What these tests are actually about is suite-token RESOLUTION — `unit` as a
bare token in --files/--files-from. Executing the seeded trivial files is
incidental and is the entire timeout surface, so the assertions move
in-process against the same functions `main()` calls, in the same order.
`parseArgs`, `selectExplicitFiles`, `selectFiles` and `walkTestFiles` are
exported for that; no behavior, signature or logic changed.

No coverage lost: `tests/run-tests-harness.test.cjs` already spawns the
harness for real and asserts exit codes end to end, on a 120s budget.

Pre-existing on `next`, fixed here rather than deferred.

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

* test: delete the three elapsed-time assertions

CLAUDE.md forbids asserting on wall-clock time. Three assertions did, and all
three are load-sensitive: on a saturated bench each can fail while the code
under test is correct. In every case the load-bearing assertion sits on the
line above and the timing line adds no discrimination.

run-with-timeout: the stated worry — "was this 124 the cap firing or the 30s
harness backstop?" — is already answered by the assertion above it. A backstop
kills by signal, which surfaces as status null, never 124. Observed directly
this session: three matrix runs produced exactly that null shape from killed
children.

normalize-test-command and context-predicates: both bounded a ReDoS check.
A threshold only ever separates "fast" from "slightly slow", which is bench
load, not correctness — catastrophic backtracking on 800 KB of input does not
take 251ms, it does not finish at all. A real regression therefore shows up as
the suite being killed on that test, which is louder and more reliable than a
number. The structural assertions (returned unchanged; cleanly rejected) are
what actually carry those tests, and they stay.

The sweep now reports zero elapsed-time assertions in tests/. The remaining
Date.now() uses are unique-path suffixes, barrier deadlines, fixture
timestamps and fake mtimes — none of them assertions.

Pre-existing on `next`, fixed here rather than deferred.

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

* chore(#3180): backfill changeset PR number (#3223)

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

* fix(#3180): key the prompt-drift ratchet on POSIX paths so it works on Windows

The baseline keys on (file, trimmed text). `file` came from scanTree's
`path.relative()`, which uses NATIVE separators, while the committed baseline
stores POSIX. On Windows every violation was therefore unmatched — reported as
FRESH — and every baseline entry matched nothing — reported as STALE. The guard
failed 100% of the time there, on both CI shards:

  ✖ scanRepo(repoRoot) matches the baseline exactly: zero fresh AND zero stale
    + { file: 'gsd-core\\workflows\\execute-plan.md', ... }

The remote runner this repo gates on is Linux-only and cannot see this class at
all; the GitHub Actions Windows lane is what caught it.

Normalization is unconditional — never gated on process.platform. A
platform-conditional normalizer makes the POSIX path the special case and
leaves the Windows branch unexercised on every other OS, which is the same
blind spot in a different place. It is applied at one seam inside
findPromptDrift, which builds `file` on every returned violation, so the
baseline key, the --update writer, the stderr report and the tests all consume
one normalized value.

The regression tests drive a Windows-shaped relPath directly and run on every
OS rather than skipping off-Windows — a test that only runs on the platform
where the bug lives is why this escaped. They include a sanity check that
un-normalized input does NOT match, so the assertion cannot pass vacuously.

Audited the three sibling guards: none keys against a committed cross-platform
baseline, and their exemption keys are path.join-built, so producer and
consumer share the native convention. Left correct code alone rather than
making them look alike. scripts/lib/drift-scan.cjs is untouched — normalizing
there would break those three on Windows.

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-08 16:05:17 -04:00
Tom Boucher
636ec92107 refactor(#3185): phase enumeration has one owner and a decidable scope (#3222)
* test(#3185): failing-first phase-enumeration single-owner suite

Covers the enumeration rows with direct code evidence: 999.* backlog dirs
listed by progress/stats, the phase-0 sentinel divergence, the #1324
letter-prefixed-decimal negative space, and the destructive-path find —
cmdPhasesClear carries a fifth sentinel copy (/^999(?:\.|$)/) that excludes
999 but not 0, so a 0-* directory roadmap.analyze preserves is deleted there.

Also covers the pass-all degrade, which is where the defect actually lives:
when the milestone window declares no phases the filter becomes a literal
() => true and its heading-side sentinel exclusion is unreachable. A fixture
carrying phase headings keeps the filter active and never reaches that path.

Named for the derivation, not a module: the suite drives commands, phase,
milestone, workstream-inventory and state, and both the phase and
phase-locator buckets are already at the per-module test-file cap.

Committed alone so the remote runner records the failure before the fix.

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

* refactor(#3185): phase enumeration has one owner and a decidable scope

Adds phase-locator.cts::listMilestonePhaseDirs as the single canonical owner
of "which phase directories belong to the current milestone". It applies the
milestone window AND the sentinel filter and returns a ScopedResult, so a
caller can tell a genuinely-empty milestone from an enumeration that could
not be scoped.

The sentinel test now runs against DIRECTORY NAMES and is unconditional.
getMilestonePhaseFilter excludes sentinels from its ROADMAP heading set, but
degrades to a literal () => true pass-all predicate when that set is empty --
at which point the heading set is never consulted and its sentinel exclusion
is unreachable exactly when it is needed. That degrade is the #3167 path, and
it is why stats already used the filter and still listed backlog directories.
The narrowing is sentinel-only: pass-all stays over-inclusive otherwise.

Sentinel copies deleted, canonical isSentinelPhaseId adopted:
  - cmdRoadmapAnalyze's local closure (parseInt === 0 || === 999), 2 call sites
  - cmdPhasesClear's /^999(?:\.|$)/ -- the DESTRUCTIVE path, which excluded
    999 but not 0, so a 0-* directory roadmap.analyze preserves was deleted

cmdStats also seeded rows from ROADMAP headings with no sentinel filter, so a
999 heading produced a row with no directory; that seed is filtered now.

cmdPhasesList routes only its ENUMERATION. --phase lookup searches the
physical set (scoping it would report an out-of-window phase as not found) and
--include-archived still merges archived dirs (they are by definition from
other milestones). Both exempt by documented reason, never a file allowlist.

Fixed inline, found while building: isDirInMilestone could not match a #1324
letter-prefixed-decimal directory (P0.0-foundation) to its own Phase P0.0
heading, so stats reported the phase with plans: 0 while its directory held
plan files. Defers to phase-id's extractPhaseToken rather than widening a
fourth bespoke regex; additive, so it can only admit directories.

Refs #3180. Closes #3185.

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

* refactor(#3185): route the last two enumeration re-derivations

workstream-inventory countRoadmapPhases counted every `Phase` heading across
the whole ROADMAP -- no window, no sentinel filter -- so it counted 999.*
backlog and Phase 0 and spanned every milestone the document ever had. Its own
caller already resolved a currentVersion and passed it to getMilestonePhaseFilter
elsewhere in the same file; this was the sibling copy that never got the fix.

state.cts phaseInventoryProvider enumerated phase dirs with its own
/^(\d+)-(.+)$/ convention regex and neither filter, so a rebuilt STATE.md
inventory carried backlog and sentinel directories as current-milestone phases.
A non-COMPLETE enumeration scope now throws to the outer catch as a real scan
failure rather than reporting a confident undercount, mirroring the per-phase
scanPhasePlans contract beside it.

Refs #3180 #3185.

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

* refactor(#3185): consolidate 23 sentinel re-derivations onto one predicate

The whole-repo drift guard (ADR-3180 Decision 4a, no file allowlist) found the
sentinel rule re-implemented 23 times across 8 modules, in three regex variants
plus four integer-comparison forms. Most tested 999 only, so Phase 0 slipped
through them while roadmap.analyze and the engine-wide convention (#1580) both
treat 0 and 999 alike. That disagreement is the defect class this epic removes.

All 23 now call phase-id's isSentinelPhaseId (SENTINEL_RANGES [0,999]). Sites:
init recommended-actions and backlog counts, milestone phase scan, the
phase-lifecycle progress table, phase.cts used-number collection and the four
renumber-on-remove guards, roadmap-parser's heading and bullet milestone
counts, roadmap get-phase fallbacks, and state's heading denominator.

Excluding Phase 0 at these sites is a deliberate behavior change and the point
of the consolidation — several carried comments already saying 0 should be
excluded while the literal beside them caught only 999.

Adds scripts/lint-phase-enumeration-drift.cjs, wired into lint:ci. It scans the
whole src/ tree with no file allowlist and reports both shapes: an independent
phases-dir enumeration, and an independent sentinel literal. Exemptions are
function-scoped with a written reason. The guard is comment-aware — its first
pass flagged JSDoc and a comment documenting that the code below uses the
canonical owner, which would have trained readers to exempt prose.

Refs #3180 #3185.

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

* refactor(#3185): resolve every phases-dir enumeration; drift guard reports zero

Per-site triage of the 31 remaining whole-repo guard hits, applying the rule
generalized from #3183's Amendment 1: a LOOKUP, DIAGNOSTIC, ARCHIVAL or
MUTATION pass wants the physical set; only "which phases belong to this
milestone" wants the scoped set.

Routed (10): init new-milestone phase_dir_count, init milestone-op fallback
count, init manager, init progress, milestone complete stats/dry-run/archive
move, phase complete's next-phase scan, state update-progress, state
frontmatter stats, and uat audit's active set.

Exempt with a written function-scoped reason (never a file allowlist): the
audit/UAT/verification sweeps that deliberately scan every directory to report
gaps, phase create/insert/rename/renumber mutations, single-phase lookups,
roadmap-upgrade's cross-milestone migration, cmdPhasesClear's whole-tree
destructive pass, and the reads that list a phase dir's FILES rather than
enumerating the phases dir at all.

Latent defects fixed by the routing: sentinel directories leaked into
cmdInitNewMilestone's phase_dir_count, cmdMilestoneComplete's stats, dry-run
AND ARCHIVE MOVE, cmdStateUpdateProgress, buildStateFrontmatter and
cmdAuditUat's active set — every one of those hand-rolled an isDirInMilestone
filter with no sentinel exclusion, so `milestone complete` was archiving
backlog directories.

scripts/lint-phase-enumeration-drift.cjs now reports 0 re-derivations and
npm run lint:ci is green.

Refs #3180 #3185.

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

* docs(#3185): document milestone-scoped enumeration and record ADR Amendment 3

Changeset fragment (Changed), CLI-TOOLS/COMMANDS/USER-GUIDE updates for the
scoped output of progress, stats, phases list, phases clear and milestone
complete, the CONTEXT.md Phase Locator glossary entry naming
listMilestonePhaseDirs, and ADR-3180 Amendment 3.

Amendment 3 records: the SCOPE contract held unchanged; the declared deviation
from Decision 1's provisional signature (the window needs cwd/ws, which the
locked roadmapContent parameter cannot supply); the copy count being a lower
bound for the third consecutive phase (4 scoped vs 54 found); the load-bearing
finding that the sentinel exclusion sat on the heading set and was unreachable
under the pass-all degrade; the two destructive-path defects; and the
generalized exemption rule.

Refs #3180 #3185.

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

* fix(#3185): wire scope to consumers; revert two wrong routings the suite caught

Review + remote runner findings, all fixed:

The three consumers computed the enumeration scope and threw it away, so
TRUNCATED/UNSCOPED/UNREADABLE collapsed into the same output as COMPLETE --
reproducing this epic's own output-identical-failure defect one layer up.
progress, stats and phases list now emit phase_scope (null on the phases list
--phase lookup path, which performs no enumeration).

Two routings were wrong and the suite proved it:

roadmap-parser's two milestone phase-count scans are reverted to the 999-only
literal. isSentinelPhaseId is BROADER than what it replaced: its legacy branch
runs /^0*(\d+)/ over "00.1", which backtracks to capture 0, so it read #2554's
decimal phase ids as sentinel milestone 0 and stopped counting them.

state.cts phaseInventoryProvider is reverted to the physical disk scan.
`state rebuild` is a RECONCILIATION pass -- scoping it made it throw on healthy
trees whose fixture resolves no window, swallowed the raw readdirSync fault
message #3057 B1 requires verbatim, and stopped it dropping orphan STATE.md
rows, which is the job.

Both are now function-scoped guard exemptions with written reasons, not
silent reverts. This is the consolidation trap named in the epic: a canonical
rule can cover MORE than the copy it replaces, and only real inputs show it.

Adds phases list coverage, a scope-branch test, and a drift-guard unit suite;
backports comment-awareness to the milestone-window and plan-count guards so
all three siblings share one false-positive profile; names #3161 alongside
#3167 in Amendment 3's subsumption record.

Refs #3180 #3185.

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

* fix(#3185): correct isSentinelPhaseId's decimal-zero misclassification

An isolated security review caught this branch committing the epic's own sin:
the over-broad predicate was worked around at ONE call site and left live at
the destructive ones.

isSentinelPhaseId's legacy branch ran /^0*(\d+)/, which backtracks so any id
whose leading digit run is all zeros before a non-digit captures 0 -- "0.1",
"00.1" and "0.2554" all read as sentinel milestone 0. Two pinned contracts
disagree with that: #2554 requires "00.1" to be counted as a real phase, and
the 999 icebox is a whole reserved milestone so "999.1" must stay sentinel.

The rule is asymmetric and now says so explicitly: 999 is sentinel with or
without a decimal part; 0 is sentinel only when bare. A decimal phase under
either is a real phase for 0 and reserved for 999, because 999 reserves a
MILESTONE while 0 reserves a PHASE.

Fixing the owner lets the earlier workaround go: getMilestonePhaseFilter's two
scans route through isSentinelPhaseId again and the guard exemption that
existed only to accommodate the defect is deleted. The state.cts cmdStateRebuild
exemption stays -- that one is a genuine reconciliation-wants-the-physical-set
case.

Also corrects tests/adr-612-bracket-grammar.test.cjs, which asserted
isSentinelPhaseId('0.1') === true and so had encoded the defect as expected
behavior.

Refs #3180 #3185.

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

* fix(#3185): keep isSentinelPhaseId's semantics — 0.x is layered, not wrong

Reverts the previous commit. The remote suite failed six tests proving it
wrong, and the reason is the sharpest finding of this phase.

An isolated security review observed that isSentinelPhaseId reads 0.1 and 00.1
as sentinel milestone 0 and judged that a defect against #2554. Correcting the
canonical predicate broke #2949. Both contracts are pinned and both are right,
because they ask different questions:

  #2554  is this dir part of the current milestone's phase SET?  -> count 00.1
  #2949  must this phase COMPLETE before the milestone closes?   -> 0.x sentinel

No single global predicate answers both. isSentinelPhaseId keeps its semantics
(0.x IS a sentinel, #2949), and the milestone-window layer keeps a narrower
999-only rule (#2554) as a function-scoped guard exemption with a written
reason — not a second silent copy.

That corrects how Decision 1 reads: "one owner per derivation" governs who
computes an answer, not how many questions share it. An over-broad canonical
rule is as much a defect as a divergent copy and fails worse, because it looks
like consolidation. Recorded in Amendment 3 as the lesson for Phases 4 and 5.

Where a review's inference about intent conflicts with a pinned contract, the
pinned contract wins; the finding is adjudicated, not fixed.

The boundary tables in the enumeration suite are corrected to assert 0.x IS a
sentinel, with the layering explained.

Refs #3180 #3185.

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

* chore(#3185): set changeset fragment pr to 3222

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

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 14:22:10 -04:00
Tom Boucher
66a4940d6f Merge branch 'next' into fix/2665-test-env-base-config-location-vars 2026-08-08 10:47:09 -04:00
Tom Boucher
342590c70e refactor(#3184): milestone windowing has one owner and a decidable failure signal (#3209)
* test(#3184): failing-first milestone-window single-owner suite

Covers the 50 input classes in the phase test matrix: scope classification
(genuinely-empty vs truncated vs unscoped vs unreadable), the section-end
owner's level boundaries, consumer-output identity per ADR-3180 Decision 4(c),
the milestone.complete refusal with negative proof that no directory moved,
the version-token boundary defect, drift-guard behavior, and three fast-check
properties over document-shaped generators.

Committed alone so the remote runner records the failure before the fix lands.

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

* refactor(#3184): milestone windowing routes through one owner

Three copies of the milestone section-end walk lived in roadmap-parser.cts —
two distinct computeSectionEnd function nodes plus an inline third in
getMilestonePhaseFilter's versionOverride branch. computeMilestoneSectionEnd is
now the sole owner and the other two are deleted, not kept in sync by comment.

The whole-repo drift guard found what the epic did not: state.cts held three
more re-derivations of the same vocabulary — two byte-identical milestone
bounding checks carrying a defect neither reported copy has (no boundary after
the version token, so v2.0 matched inside v2.0.1), and a milestone-sectioning
predicate. All three route through the owner now.

A composition-level duplicate appeared inside this change's own first pass:
getMilestonePhaseFilter and cmdMilestoneComplete each re-assembled a window out
of the owner's primitives, and had already diverged on whether to skip a closed
milestone heading. sliceMilestoneWindow is the one composition.

Windows now carry the ADR-3180 SCOPE discriminator, so a truncated window is
distinguishable from a genuinely empty milestone — those were output-identical,
which is the whole failure class. roadmap analyze emits it (#3165), and
milestone complete refuses to archive on anything but COMPLETE rather than
pass-all moving every phase directory on disk (#3166). The pass-all degrade is
preserved where its premise holds: making the filter deny-all would trade a
silent over-inclusive answer for a silent under-inclusive one on the read paths
that count with it.

extractCurrentMilestone keeps its signature — 200+ affected symbols across 41
files and 25 process flows — and is a one-line wrapper over the scoped owner.

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

* fix(#3184): fence-aware phase detection and one heading-selection owner

Review fixes from the two orthogonal passes.

The blocker: hasPhaseEntries matched ATX phase headings fence-aware via
tokenizeHeadings but tested the #2199 bullet form against un-stripped markdown,
so a fenced EXAMPLE of the bullet syntax counted as a real phase. A genuinely
empty milestone then classified TRUNCATED and milestone complete refused a
legitimate archive — a false positive in the destructive direction, worse than
the defect this phase set out to fix. Both that path and getMilestonePhaseFilter
own pre-existing bullet scan now run on stripFencedCode, since leaving one meant
the owner file gave two different answers to the same question.

The selection rule — locate, prefer the non-closed heading, else the first — had
been written three more times inside the file whose thesis is single ownership.
selectMilestoneHeading owns it; all three sites route through it. The copies were
behaviorally identical, so this is de-duplication with no observable change,
verified by probing that all three paths select the same heading.

roadmap analyze emitting a scope no consumer read left #3165's actual symptom
alive, so Route 0 in next.md now treats a non-complete scope as scan-failed
rather than as a clean empty scan, and the ADR amendment no longer overstates
what shipped.

Also: the scope refusal moved above the archive-directory create, so a refusal
leaves nothing on disk; the versionOverride comment names all four consumers;
COMMANDS.md documents the new guard beside its sibling.

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

* test(#2658): exclude the changelog from the malformed-path scan

The gate walks every emitted .md/.js/.cjs file in an installed tree and asserts
none contains `.claude/.trae/rules` or `.trae/.trae/rules`. CHANGELOG.md ships
into that tree, and its #2658 entry quotes both malformed paths while describing
the fix that removed them — so the release note documenting the fix trips the
fix's own regression test. Red on next before this branch.

The installer is correct: a probe over a real --trae --local install found 621
emitted files, exactly one hit, and it was gsd-core/CHANGELOG.md. The scan scope
was the defect, not the product.

Excluded by exact relative path rather than by loosening the patterns or skipping
all markdown — the emitted agent and command markdown is precisely what #2658 was
about, so the gate stays strong everywhere it matters.

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

* test(#3184): regenerate install-tree fixtures for the shared drift scanner

scripts/lib/ ships in the npm package and installer, so extracting the shared
tree-walk into scripts/lib/drift-scan.cjs adds one path to every runtime's
install tree. Regenerated via npm run gen:install-tree; the delta is exactly
that one path per fixture.

The two drift guards themselves do not ship (scripts/lint-*.cjs is excluded),
so only the extracted library moves. This matches the existing
scripts/lib/allowlist-ratchet.cjs precedent, which is likewise a lint-only
helper carried in the shipped tree.

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

* fix(#3184): restore the #730 sub-milestone boundary and narrow the refusal

The remote runner caught two regressions this branch introduced. Both were mine,
and neither review pass found them — only running the existing suite did.

The version-token boundary. I replaced locateMilestoneHeadings' \b with
(?![\w.-]), reasoning that v2.0 matching inside v2.0.1 was the same defect #2562
fixed in isMilestoneShippedInRoadmap. It is not the same question. A milestone
state of v8.0 legitimately selects the '## v8.0-B' sub-milestone section over a
closed v8.0-A sibling (#730), and \b is what allows it while the stricter
boundary forbids it — nine tests in roadmap-phase-fallback said so. Reverted to
\b; the state.cts consolidation is now a straight merge with no behavior change,
and the v2.0/v2.0.1 ambiguity is left exactly as it was. The ADR amendment and
the design doc no longer claim otherwise.

The refusal scope. I refused whenever the window was not COMPLETE, but #3166 is
about the TRUNCATED window specifically — the heading is found and the section
closes before the phase region, so pass-all archives everything. UNREADABLE and
UNSCOPED are pre-existing, legitimately handled states, and refusing on them
broke 'handles missing ROADMAP.md gracefully' and three archive tests. Narrowed
to TRUNCATED; docs corrected to match.

One of the new tests was also wrong: its fixture gave the shipped and current
milestones' phases the same numeric id, and the filter matches on that id, so it
could not have distinguished the two windows. Fixture corrected to exercise what
it claims to.

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

* fix(#3184): enumerate drift-scan.cjs for uninstall

The installer copies scripts/lib/ wholesale, but uninstall removes an explicit
set — deliberately, so a user's own helpers in that directory survive. The
extracted drift-scan.cjs was copied in and never enumerated, so it outlived
uninstall, left the directory non-empty, and the rmdir that follows failed.

Added to GSD_SCRIPTS_LIB_FILES, following allowlist-ratchet.cjs, which is
likewise a lint-only helper that ships there and is enumerated. Verified with a
real install-then-uninstall into a temp target: scripts/lib/ held exactly the
three GSD files and was gone afterwards.

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

* test(#3184): assert install and uninstall agree on scripts/lib and scripts/changeset

Found while shipping this phase, and fixed here rather than noted.

install() copies scripts/lib/ and scripts/changeset/ into the target WHOLESALE —
the comment at the copy site literally says "and any future lib helpers".
uninstall() removes them by hardcoded enumeration, deliberately, so a user's own
helpers in those directories survive. A wholesale writer paired with an
enumerated remover cannot stay in sync by construction: any file added to either
directory ships to every user and is then orphaned in their repo forever, since
it survives uninstall, leaves the directory non-empty, and the rmdir that follows
fails. Nothing reported this. 31,225 tests were green over it.

That is the same divergence class this epic exists to delete, sitting in the
installer, so it gets the same remedy CLAUDE.md prescribes for it: a parity
assertion that fails the moment the two surfaces disagree. The test compares each
directory's real contents against its enumeration and names the offending file
plus the constant to add it to.

Both enumerations are hoisted to module scope and exported, so the test asserts
on the actual arrays rather than pattern-matching the installer's source — no
allow-test-rule annotation needed. Proven non-vacuous both ways: empty diff on
the current tree, correct report when an unenumerated file is injected.

scripts/changeset/ turned out to carry the identical defect and is covered too.

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

* chore(#3184): backfill changeset PR number

Also narrows the wording to match the shipped behavior: the refusal fires on a
truncated window specifically, not on any non-complete scope.

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

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 09:50:35 -04:00
0xdhx
2a98b6b0b1 fix(#2665): keep the dot-home type guarantee #2755 chose
The rebase resolution annotated both hoisted descriptors and the local in
resolveKimiHooksTomlDir as ConfigHomeDescriptor. #2755 used DotHomeDescriptor
there deliberately: the union permits xdg / dot-home-nested / generic-agents-root
shapes, so the broader annotation drops the compile-time guarantee that this
resolver selects a dot-home descriptor and nothing else.

Runtime behaviour was already correct — all eleven paths resolve identically —
so this restores a type-level property, not a behavioural one. Both exported
constants are now pinned to DotHomeDescriptor as well, which is stricter than
the ConfigHomeDescriptor they carried since round 3; the interface stays
unexported and NON_REGISTRY_CONFIG_HOME_DESCRIPTORS keeps its
ConfigHomeDescriptor[] type, which the narrower constants satisfy as subtypes.

Found by the pre-push adversarial review of this round, which is the one claim
of six it refuted.
2026-08-08 05:50:49 -05:00
0xdhx
3c580b77dc fix(#2665): derive the second config-location family instead of hand-adding it
Review round 2 named GSD_HOME and KIMI_SHARE_DIR as missing from the derived
scrub set. Both premises confirmed; the prescribed remedy is not adopted
verbatim, because adding two more literals to a four-item hand list is the
pattern that reopened this bug three times. The census the derivation
generalizes over was partial, so the census is what widens.

Two structural gaps, both closed at the source:

1. KIMI_SHARE_DIR lived inside resolveKimiHooksTomlDir's body as an inline
   descriptor, resolvable but not ENUMERABLE. Hoisted to an exported
   KIMI_HOOKS_TOML_DESCRIPTOR and collected in
   NON_REGISTRY_CONFIG_HOME_DESCRIPTORS, which TEST_ENV_BASE now derives from.
   kimi is the sharp case: it owns TWO config homes (KIMI_CONFIG_DIR, already
   registry-visible, and this one), so a registry-only derivation looks
   complete and is not.

2. GSD_HOME is a different FAMILY, not a missing registry entry. The registry
   describes where third-party runtimes keep config; GSD_HOME decides where GSD
   keeps its own user-owned state ($GSD_HOME/.gsd/ — consent.json,
   defaults.json, capability overlays), read env-first ahead of os.homedir() by
   capability-loader, capability-consent, capability-state, capability-writer,
   config-loader, install-profiles and bin/install.js. Named as
   GSD_LOCATION_ENV_KEYS rather than folded into the descriptor array, since it
   does not resolve through resolveConfigHomeFromDescriptor.

GSD_AGENTS_DIR joins the same family (round 2, Minor): env-first and
unconditional in getAgentsDir, misdirecting a read rather than a write.

A census of every env-first first-party location var — the guard-shape question
this PR owes each round — now yields exactly one remaining unguarded name,
GSD_MODEL_CATALOG, and it is dead by precedence: the co-located candidate is
index 0 and the loop breaks on first success, so the env var can only win on a
tree that is already broken, and it redirects a read even then.

Derived set: 39 -> 42 keys. resolveKimiHooksTomlDir behaviour unchanged on both
the default and the KIMI_SHARE_DIR override path.
2026-08-08 05:50:36 -05:00
Tom Boucher
343835facc refactor(#3183): route live-plan counting through scanPhasePlans (#3199)
* refactor(#3183): route live-plan counting through scanPhasePlans

scanPhasePlans becomes the sole owner of the live-plan derivation. Twenty-one
independent re-derivations across seven modules now route through it, and
scripts/lint-plan-count-drift.cjs reports zero, scanning the whole repo rather
than an allowlist (ADR-3180 Decision 4a).

The epic scoped this at three copies. A whole-repo guard found twenty-six sites
across nine files, so Phase 1 absorbs every live-plan re-derivation and Phase 3
narrows to window plus sentinel enumeration.

Two sites are exempt with a documented reason rather than a bare allowlist:
audit.cts scans one quick task's own directory for a single completion record,
and gsd2-import.cts reads a foreign GSD-2 tasks/ layout during a one-time
import. Neither is a phase directory.

scanPhasePlans gains allPlanFiles (pre-supersession) alongside planFiles so one
owner answers both questions: verify.cts's numbering-gap check wants every plan
on disk, its pairing check wants the live set. Both fields are additive.

Highest-severity fix: cmdPhasePlanIndex, which feeds execute-phase wave
scheduling, was scheduling status:superseded plans into waves and reporting zero
plans for the post-#3139 nested layout.

filterPlanFiles and filterSummaryFiles are deleted; getPhaseFileStats orphaned
them and only their own tests still called them.

New leaf module src/planning-scope.cts carries the frozen SCOPE discriminator,
with its six-gate ripple closed: gitignore, inventory manifest, INVENTORY.md and
the CONTEXT.md glossary.

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

* docs(#3183): amend ADR-3180 for the Phase 1/3 boundary re-slice

The contract held; the phase boundary did not. The whole-repo drift guard found
26 re-derivations across 9 files against the epic's estimate of 3, and
cmdProgressRender re-derives both enumeration and plan counting on adjacent
lines, so DW4 was unsatisfiable within Phase 1's original file scope.

Records the amended scope, scanPhasePlans's new allPlanFiles field,
findOrphanSummaries, the two documented exemptions, the re-derived Tier-2
table, and the describeNonCanonicalPlans trap for later phases.

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

* fix(#3183): complete the canonical pairing rule and gate the naming diagnostic

The remote runner went red with 13 deterministic failures on both lanes,
and they were right: replacing verify.cts's canonicalPlanStem pairing with
summaryCandidates dropped a case the bespoke rule covered. A plan carrying a
descriptive slug after its id (68-01-scaffolding-PLAN.md) pairs with its
canonical-stem summary (68-01-SUMMARY.md), and summaryCandidates generated no
such candidate, so the plan read unsummarized.

The fix is to complete the one rule rather than restore a second:
summaryCandidates gains a canonical-id candidate, narrowed to fire only when an
id pair was actually extracted. countMatchedSummaries, findUnsummarizedPlans
and findOrphanSummaries all inherit it. The two-plans-one-summary collision
behaviour of the original rule is preserved deliberately and documented in
place.

Second defect, independently root-caused while verifying: routing the #2893
naming diagnostic through scanPhasePlans exposed it to the loose /PLAN/i
fallback, which is correct for counting and wrong for a naming check — a
non-canonically-named file was accepted as a valid plan and the diagnostic
went silent. cmdPhasesList, cmdFindPhase and cmdPhasePlanIndex now intersect
with a strict isCanonicalPlanFile predicate before reporting names.

Same class as the describeNonCanonicalPlans trap already recorded in ADR-3180:
a question about file naming wants the physical, strictly-matched set; only a
question about outstanding work wants the live set.

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

* chore(#3183): register planning-scope.cjs in the eslint migration list

tests/repo-invariants.test.cjs asserts every bin/lib/*.cjs is linted xor
ignored per its ADR-457 migration state. The new planning-scope module closed
five of the six .cts ripple gates - gitignore, inventory manifest, INVENTORY.md
and the CONTEXT.md glossary - but not eslint, because that one is enforced by a
test rather than by lint:ci, so the local pipeline stayed green while it was
missing.

Generated from src/planning-scope.cts, so the .cjs is ignored and the .cts is
linted, matching every other migrated module.

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

* fix(#3183): replace the plan-count drift detector with a literal tokenizer

CodeQL reported 4 high-severity js/redos alerts on REGEX_LITERAL_MD_RE, the
backtracking regex that finds "a regex literal mentioning PLAN/SUMMARY and an
escaped \.md". Five review rounds found it had two defects, not one:

  - EXPONENTIAL, then CUBIC. Its "any char" atom `(?:\\.|[^/\r\n])` let a `\.`
    pair be consumed either as one escape or as two class characters, which is
    exponential backtracking: 27,464ms on `"/\.mdplan" + "\.".repeat(28) + "X"`.
    Excluding `\` from the class killed that but left a cubic path — 23ms at
    N=200, 172ms at N=400, 1362ms at N=800 on `"/" + "PLAN\.md".repeat(N)` with
    no closing `/`. This guard is the last stage of `npm run lint:ci`, which CI
    runs on fork pull requests, so a crafted src/*.cts could stall the job.
  - A DETECTION HOLE. A character class holding a bare, unescaped `/` — e.g.
    `/SUMMARY[^/]*\.md$/`, an ordinary path-excluding filter — terminated the
    literal at that `/`, so the scan never reached `\.md` and the guard missed
    it entirely. (Classes holding an ESCAPED `\/` were already matched; the
    tests cover those separately as parity, not as regressions.)

Both defects have one root cause: regex-literal grammar — `\x` escapes, and
`/` inside `[...]` not terminating — is not expressible in a backtracking
regex. So the detector is now a tokenizer, not a regex.

readRegexLiteralAt reads the literal at a given `/` in a single left-to-right
pass with no backtracking, treating escapes as two-character units and
suppressing the `/` terminator inside a character class. findRegexLiteralMdMatch
restarts it at every `/` on the line, preserving the old "find anywhere"
behaviour; MAX_REGEX_LITERAL_LEN (400) bounds each read — including the
trailing-flag scan — which keeps the whole-line cost linear.

Results: cubic shape flat at 0.06-0.39ms out to N=3200 (25KB), exponential
shape 0.01ms at 28 reps and 0.00ms at 64, and the bare-`/` class shapes are now
caught. Differential against the old regex over 28,474 lines (those matching
FILENAME_TEST_RE but not PLAN_SUMMARY_LITERAL_RE, across src/tests/scripts/
gsd-core/bin/eslint-rules, excluding 265 lines with >6 backslashes on which the
old regex hangs): 6 differences, all the tokenizer returning the fuller or
newly-correct literal, 0 old-only misses. The `\.md` token stays
case-insensitive, matching the `/i` the old regex carried.

Also closes three holes in the same new file:

  - walk() tested entry.isFile(), false for a symlink, so a symlinked
    src/*.cts was silently unscanned — an evasion of a guard whose stated
    principle (ADR-3180 Decision 4a) is whole-repo discovery with no allowlist.
    It now resolves symlinks, but confined: file links must resolve inside the
    repo root, directory links inside the scanned dir itself. Every sibling
    drift guard in scripts/ uses the Dirent classification and never follows
    links, so following them unconfined would have made this the only linter
    able to read outside the tree — on fork PRs an arbitrary out-of-repo read
    whose matched fragments reach a public CI log. The narrower directory rule
    additionally stops `src/up -> ..` from sweeping the whole repo, and the
    skip list is now checked against resolved paths so `src/g -> ../.git`
    cannot reach .git/** or node_modules/**. Real paths are de-duplicated and
    files reported canonically, so a symlink alias cannot shift which
    FUNCTION_SCOPED_EXEMPTIONS key applies.
  - Both the reported fragment and the reported FILE PATH are attacker-
    controlled source text written straight to a CI log, and git permits
    control bytes in a filename. Both are now escaped — C0/C1/DEL plus the
    bidi and zero-width controls — so a crafted literal or filename cannot
    recolour the log, overwrite a line with CR, or fabricate a line that looks
    like this guard's own success output.

Regression coverage in tests/plan-count-single-owner.test.cjs: a child-process
probe over both pathological shapes (catastrophic backtracking is synchronous
and would freeze the suite rather than fail one test), the bare-`/` class
shapes verified to fail against the parent-commit blob, root-confinement tests
covering the outside-file, outside-directory, cycle, broken-link and duplicate
cases, direct isInsideRoot coverage including the sibling-prefix case that a
bare startsWith would let through, sanitizeForReport coverage, and
limit-1/limit/limit+1 coverage of MAX_REGEX_LITERAL_LEN derived from the
exported constant. The earlier structural assertion was dropped — it checked
for the substring `[^/`, which respelling the class as `[^\r\n/]` defeats
while staying exponential.

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

* chore(#3183): backfill changeset PR number

Restores b77931869, which a force-push during the ReDoS remediation dropped.

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

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 01:20:55 -04:00
Tom Boucher
3f349e551d fix(#3024): route sync-skills through the shipped gsd-tools instead of an unshipped install.js (#3195)
* fix(#3024): sync-skills workflow uses gsd-tools query skills-root instead of unshipped install.js

The sync-skills workflow Step 2 shelled out to gsd-core/bin/install.js --skills-root,
but install.js is not shipped in installed trees (only in the npm tarball root bin/).
Every /gsd-update --sync invocation failed with MODULE_NOT_FOUND.

Fix: added 'gsd-tools query skills-root <runtime>' subcommand (gsd-tools IS shipped)
that calls the same getGlobalSkillsBase function install.js used. Updated the
workflow to call gsd_run query skills-root instead of the dead install.js path.

Also documented the #3025 verbatim-cp limitation in Step 5 with a workaround.

* test(#3024): failing-first guards for the three defects in the adopted fix

The cherry-picked commit came from an aborted run that never executed its own
tests. Its raw-path assertion fails as written, which is the clearest evidence
the work never reached verification.

Covers:
- --raw must emit a bare path, not JSON (output() takes a third rawValue arg
  that routeSkillsRoot omits, so the raw branch never fires)
- an unknown, empty, whitespace, traversing, or metacharacter-bearing runtime
  must be rejected, not silently resolved to claude's skills root
- sync-skills.md must contain zero references to the unshipped install.js,
  including the guard's remediation text — the issue's second reported defect
- parity across every runtime in the registry, not three hardcoded ones, so the
  two entry points cannot drift

Also converts the adopted tests off a hand-rolled spawnSync onto the bounded
process seam, per CONTRIBUTING.

Fails before the fix. Verified via the remote runner.

* fix(#3024): make the skills-root query actually work and reach non-Claude runtimes

The cherry-picked commit never ran its own tests. Six defects, all fixed here.

--raw was ignored: output() is output(result, raw, rawValue) and the third
argument was omitted, so the raw branch never fired and the workflow captured a
JSON blob as SRC_SKILLS_ROOT. Every downstream cp -r then resolved against a
nonexistent path — the command would have shipped still broken.

An unknown runtime silently resolved to claude's skills root, because
getGlobalSkillsBase falls back rather than returning null, leaving the existing
=== null guard dead. The runtime id is now validated at the CLI boundary against
the shipped registry, so a typo'd --from/--to fails instead of reading from or
writing into the wrong runtime's tree.

getGlobalSkillsBase('vscode') threw a raw TypeError. vscode is non-installable
by descriptor, so it has no skills root — null is the answer, not a crash. The
resolver now short-circuits configHome.kind 'none', which also fixes the same
latent crash in install.js --skills-root vscode. Every caller already gates on
=== null.

sync-skills.md used gsd_run WITHOUT the canonical launcher preamble, so gsd_run
was undefined on non-Claude runtimes — the fix would have been dead in exactly
the place the original bug bit. Preamble propagated via sync-runtime-launcher.

Also registers skills-root in TOP_LEVEL_USAGE (the help/dispatch parity guard
caught it), removes the last two install.js references including the guard's
remediation text (the issue's second reported defect), and updates the stale
assertion that still described the removed contract.

Verified on the remote runner.

* fix(#3024): align the documented runtime list with the registry and gate both entry points

Isolated review returned BLOCK on two findings.

The workflow's Supported-runtimes list and its --to all expansion named grok and
gemini, neither of which is a registered runtime. Once this branch added
validation, --to all — a documented first-class feature — aborted. The list was
hand-copied prose shadowing the registry, so correcting it alone would drift
again; a parity assertion now fails in BOTH directions if the doc and the
registry disagree. vscode is excluded by name: it is installSurface 'none', so
syncing skills to it is meaningless and would abort.

bin/install.js --skills-root reached getGlobalSkillsBase with no own-property
gate, so --skills-root __proto__ silently resolved to claude's skills root. This
branch had just hardened the OTHER entry point to the same function; leaving one
of two parallel surfaces open is the same divergence class as the first finding.
Both now call one shared isRegisteredRuntimeId() rather than a copied check, and
the parity test covers the hostile ids so the two can never disagree again.

Also guards the workflow's root resolution: neither command substitution checked
its exit status and only the source had an existence guard, so a failed
destination resolution left DEST_ROOT empty and turned rm -rf "$DEST_ROOT/$SKILL"
into an absolute path at filesystem root. Both resolutions are now checked, and
Step 5 requires both roots to be non-empty and absolute before any destructive
command.

Verified on the remote runner.

* test(#3024): anchor the runtime-list parity extractor to the list span

The extractor captured (.+) to end of line, so it swallowed the em-dash prose
that explains the vscode exclusion — and that sentence contains backticked
`runtimes` and `null`, which is where the three phantom ids came from. The
documented list was correct; the test was reading its own explanation back as
data. Anchored to the id-list span.

Both directions still fail as intended: proven by injecting a bogus id and by
removing a registered one.

* test(#3024): anchor the --to all extractor and fail loudly on empty captures

The workflow has three TO_RUNTIMES= assignments and the regex matched the first
one — an empty array initializer at line 28 — so the extractor captured nothing
and the assertion diffed [] against 18 ids as if that were data.

That is the same failure twice, so the fix is the general one: every extractor
in this test now asserts it captured a plausible list before comparing, naming
which extractor found nothing and what it was looking for. An extractor that
silently yields [] is a confident wrong answer, and a parity guard that reports
it as a data mismatch teaches the reader to loosen the assertion.

Verified against the real workflow and against doctored copies with each target
construct removed, plus both teeth directions.

* fix(#3024): merge duplicate process-seam import after rebase

The rebase applied cleanly but left runNode declared twice: next had gained its
own import of the seam while this branch added one carrying OUTCOME. A clean
rebase is not a correct one — the file no longer parsed. Merged into a single
import providing both.

* fix(#3024): bind DEST_ROOT per destination instead of a dangling map

Step 2 stored each destination's root into DEST_SKILLS_ROOTS, which nothing ever
read, while Steps 3 and 5 used a scalar DEST_ROOT that nothing ever assigned. The
array was also never declare -A'd, so on bash 3.2 — macOS system bash, which this
repo supports — every destination collapsed onto index 0.

The absolute-path guard added earlier was the only thing standing between that and
rm -rf "/$SKILL"; it turned a silent disaster into a hard stop, but the feature
still could not complete. Each destination now binds its own DEST_ROOT where it is
used, and the unread map is gone rather than replaced.

Step 2 keeps eager validation, so a bad runtime id in a multi-destination --to
aborts before any destination is written rather than after some already have been.

Verified on bash 3.2 with a two-destination run binding distinct roots, and with a
bad id aborting before any destructive call.

* fix(#3024): restore grok support broken by the registry gate

The registry gate added earlier rejected grok, and that was my error. I confirmed
grok was absent from the capability registry and concluded the hardcoded branch
was dead — without checking what it resolved to. It resolves to ~/.agents/skills,
a real grok-specific path, exactly as the pre-fix workflow documented ('grok uses
the ~/.agents layout'), and there is a support discussion doc for it. So a
working, documented runtime silently lost --skills-root and sync-skills support
as a side effect of prototype-pollution hardening — and the parity test I added
locked that in as correct.

gemini is the one that really was dead: it fell through to CLAUDE's skills root,
so rejecting it is right and it stays rejected, as do bogus ids, __proto__,
empty, whitespace and traversal.

The validator's real question is 'does this id have a genuine runtime-specific
resolution', not 'is it in the registry map'. Registry membership was a proxy
that happened to miss grok. Legacy non-registry runtimes with dedicated
resolution branches are now a named, documented set; enumerating every hardcoded
branch in getGlobalConfigDir against the registry confirms grok is the only one.

The new tests assert grok resolves UNDER .agents and specifically not to claude's
root. Allow-listing an id proves nothing about whether it resolves correctly —
that assertion is what would have caught my mistake.

Also uses the shared PROBE_TIMEOUT_MS instead of a duplicate literal, and guards
Step 3's DEST_ROOT re-resolution, which contradicted the file's own stated
guarantee.

Verified on the remote runner.

* test(#3024): guard against LEGACY_NON_REGISTRY_RUNTIME_IDS drifting

The named legacy set is a second hand-maintained proxy for the same predicate
the registry check got wrong — 'does this id resolve runtime-specifically'.
Nothing stopped a third hardcoded branch being added to getGlobalConfigDir
without updating the Set, reproducing the exact class of bug that broke grok.

Production stays explicit and greppable; the test derives the truth instead. It
resolves a sentinel id to learn the generic fallback, classifies every candidate
against it, and fails in both directions — an id resolving runtime-specifically
that is in neither the registry nor the Set, or a Set entry that no longer earns
its exemption. The failure message names the remedy.

Confirms grok resolves runtime-specifically and gemini does not, which is the
distinction the original registry check could not see.

Also reverts the shared-timeout swap: SKILLS_ROOT_PROBE_TIMEOUT_MS is
pre-existing on next and arrived by rebase, so changing it here was scope creep
into another issue's territory.

Verified on the remote runner.

* chore(#3024): backfill changeset PR number

---------

Co-authored-by: sim <sim@local>
2026-08-07 18:53:25 -04:00
Tom Boucher
27aa40f65e fix(#3023): stage pi's shared hook bundle outside pi's reserved hooks/ directory (#3175)
* test(#3023): failing-first guard — pi must not stage hooks in its reserved dir

pi reserves <configDir>/hooks as its deprecated extension location and warns
on every startup when it exists. Assert a pi install stages the shared hook
bundle under gsd-hooks/ instead, manifests it there, and never creates hooks/.

Also adds pi to the local-scope dir table in install-shared.cjs: pi was in
RUNTIME_META but not LOCAL_DIR_NAME, so scope:'local' resolved
path.join(root, undefined) and no local pi install could be exercised.

Fails before the fix. Verified via the remote runner.

* fix(#3023): stage pi's shared hook bundle outside pi's reserved hooks/ dir

pi reserves <configDir>/hooks as its now-deprecated extension location and
warns on every startup when that directory merely exists — checkDeprecatedExtensionDirs()
guards the warning with a bare existsSync(), unlike its tools/ sibling. GSD staged
its shared hook bundle exactly there, and pi's advised remediation (move it to
extensions/) would break the adapter's paths and expose GSD's .js helpers to pi's
extension auto-discovery.

The bundle directory name is now runtime-descriptor-driven: hostBehaviors
.sharedHooksDirName, defaulting to 'hooks' so all 18 other runtimes are
byte-identical. pi sets 'gsd-hooks'. The name is validated as a single path
segment — separators, dot-only segments, trailing dots, absolute paths, NUL,
and Windows reserved device names all fall back to the default, because the
value is joined onto a user's config root and written to.

Renamed in place rather than relocated: hook scripts resolve siblings via
__dirname/.., so a depth change would silently break them.

- install / uninstall / manifest sites all read the resolved name
- pi/gsd.cjs probes gsd-hooks then hooks, so dev checkouts and half-upgraded
  trees still resolve; the never-throws contract is preserved
- new migration 009 retires the legacy pi hooks/ dir on upgrade, using a new
  non-recursive remove-empty-dir engine primitive (rmdirSync only,
  symlink-refusing, containment-guarded); ADR-0008 amended accordingly
- fixes two latent name-dependencies the rename exposed: the stale-hook scan
  and the injection scanner's self-exclusion both hardcoded 'hooks'

Verified on the remote runner.

Closes #3023

* fix(#3023): close review findings and align emitted provenance with the rename

Adversarial review found two defects, and the remote runner found four
failure clusters. All fixed here.

Review BLOCKER — detect-custom-files was blind to the renamed bundle.
GSD_PREFIX_MANAGED_DIRS in gsd-tools.cjs hardcoded 'hooks', so for pi the
whole gsd-hooks/ tree was invisible to the custom-file scan and user-added
files there were never backed up before the next update's clean-install wipe.
The dir set now resolves via the .gsd-runtime marker plus the shipped
capability registry (never bin/install.js, which is not shipped into installed
trees), and falls back to scanning every known candidate when the runtime
cannot be determined — over-scanning is safe, under-scanning is the data loss.

Review MAJOR — the pi adapter bound to an empty bundle. resolveSharedHooksDir
accepted any directory, so an interrupted install left gsd-hooks/ winning over
a fully-staged legacy hooks/ and every hook silently no-opped. A candidate now
qualifies only if it is non-empty.

Remote-runner clusters:
- emitted-provenance had no rule for the gsd-hooks/ family; added two pi-scoped
  rules pointing at the same sources the existing hooks/ rules use. The table is
  total, so an unattributed family is a hard failure by design.
- pi tests in install-minimal-hooks and the install integration suite asserted
  the old layout; updated to derive the dir name from the descriptor rather than
  hardcoding either name.
- 19 unrelated-looking failures on node22 only were a leaked fs mock: t.after()
  runs in registration order, cleanup was registered before mock.restoreAll(),
  and node22's JS rimraf calls the public fs.rmdirSync while node24's native
  path does not — so the EACCES stub leaked process-wide on one lane. Restore
  now runs first.

Verified on the remote runner.

* fix(#3023): honor PI_CODING_AGENT_DIR, ack the rename ripple, fix expandTilde

pi resolves its agent dir as PI_CODING_AGENT_DIR ?? ~/<CONFIG_DIR_NAME>/agent
(packages/coding-agent/src/config.ts). GSD's pi descriptor declared an empty
configHome.env, so a user with that variable set had GSD installed where pi
never looks. Added the env name; the dot-home-nested resolver already handled
the override, so no resolver logic changed.

Also fixes expandTilde in the shared runtime-homes resolver, found while adding
that: it hardcoded os.homedir() and ignored the opts.home every caller threads,
so EVERY runtime's tilde-valued env override (claude, antigravity, windsurf, pi)
silently resolved against the real home. That is a correctness bug and a
test-escape hazard — a sandboxed test asserting on a tilde override reached the
developer's actual home directory. Now threaded through every branch; behavior
with no injected home is unchanged.

Adds the emitted-drift ack fragment for the 58 pi paths whose emitted location
moved with the rename. The provenance rules satisfy the totality gate; the
differential gate needs the ack because the hook sources are byte-unchanged —
only the installer's target directory moved. The two hook files this branch
genuinely edits stay attributed and are not double-acked.

Note on piConfig.configDir: it is read from pi's OWN installed package.json
(getPackageDir walks up from pi's __dirname), alongside piConfig.name — a
white-label setting for a redistributed pi fork, not a per-project user setting.
Documented accordingly rather than treated as an unsupported override.

Verified on the remote runner.

* fix(#3023): reject blank env overrides, pin adapter/descriptor parity

Three review findings, all fixed.

A whitespace-only config-dir override was accepted verbatim: the guard was
`if (val)`, falsy only for the empty string, so PI_CODING_AGENT_DIR='   '
resolved to a literal three-space directory name instead of falling back to the
descriptor default. Fixed across every env-consuming branch — dot-home,
dot-home-nested, all three xdg steps, and generic-agents-root — not just pi's.
Non-blank values are still never trimmed, so '~/My Agent Dir' keeps working.

pi/gsd.cjs's probe list and the descriptor were two independent sources of truth
for the bundle directory name; a future rename would have desynced them silently
and left every pi hook quiet with no error. The probe list stays deliberate — it
must resolve in a dev checkout and a half-upgraded tree, where the registry's
answer would be wrong — so this adds the parity assertion the repo's
generative-fix-divergence rule calls for: the descriptor value must be the FIRST
candidate, and the default must remain present.

Changeset body rewritten to cover the two later user-facing fixes it had not
caught up with.

Verified on the remote runner.

* chore(#3023): backfill changeset PR number

* fix(#3023): anchor injection-scan patterns and fix a macOS detection hole

CI's security job flagged CONTEXT.md:124 — pre-existing prose reading 'not the
same fact as a genuinely empty or absent one'. The match was the 'act as a'
INSIDE 'f-act as a': the pattern had no left word boundary, so any word ending
in act tripped it (fact, impact, contract, artifact, interact, redact,
abstract). My four-line CONTEXT.md edit dragged the latent false positive into
this PR because the scan is diff-scoped by file but reads whole files. Anchored
with (^|[^[:alnum:]]) rather than rewording maintainer-owned prose, which would
have left the class alive for the next PR touching any file saying 'fact as a'.

Auditing the rest of the list for the same class surfaced a real detection hole:
the eval/exec/Function patterns matched a quote via \x27, a GNU-grep-only hex
escape. BSD/macOS grep reads it as four literal characters, so single-quoted
eval('...')/exec('...') payloads were NEVER detected there while passing on
GNU-grep CI. Replaced with a literal apostrophe class.

Boundaries were added only where a real word-suffix collision exists; exec,
jailbreak, developer mode and the role-manipulation family were audited and
deliberately left unanchored. 22 new cases cover both directions — the false
positives now scan clean, and every real payload still fires, including the
quote/punctuation/start-of-line boundary forms.

Also builds this branch's injection test fixture at runtime instead of carrying
the literal phrase, so the payload keeps its teeth without tripping the scan.

Verified on the remote runner.

---------

Co-authored-by: sim <sim@local>
2026-08-07 13:41:21 -04:00
sim
654b2cc10c refactor(#3149): bind planningPaths once in cmdStateLoad
The debug_dir change introduced a second planningPaths(cwd) call in the
same function. Bind the struct once and read both .planning and .debug
from it; emitted output is byte-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 09:33:47 -04:00
sim
2bead6ca1d feat(#3149): add dedicated init.debug entry point for /gsd:debug
/gsd:debug was one of the last workflows with no cmdInit* of its own: its
Step 0 made three separate round-trips (state.load, resolve-model
gsd-debugger, config-get workflow.tdd_mode) to assemble one context. Because
no debug-scoped fact was computed at any entry point, ADR-1671 admission gate
(2) could never be satisfied for debug — an applicability atom naming such a
fact would evaluate FALSE forever and silently exclude its section.

Adds cmdInitDebug (init.debug), registers it in the init router and the
command-alias table, and collapses debug.md Step 0 to one call. Every field
resolves through the same primitive the call it replaces used: loadConfig for
commit_docs, withProjectRoot for response_language (#2402), planningPaths for
debug_dir, resolveModelInternal for debugger_model, and the existing
Boolean(workflow.tdd_mode) idiom for tdd_mode.

PlanningPaths gains a debug field so state.load and init.debug share ONE
debug-directory expression rather than two kept in sync by hand. state.load
keeps emitting debug_dir: it is a shipped query surface with its own test
anchor, so narrowing it would break unseen consumers for no gain.

No WHEN_VOCABULARY atom and no gsd:section marker: gate (1), a consuming
section of at least 400 bytes, belongs to the change that adds the section.

Closes #3149

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 09:30:27 -04:00
Tom Boucher
4b66bf4560 fix(#3086): apply #2667 .cmd-shim gate to deps.spawn + surface errorCode in review lanes (#3142)
* fix(#3086): apply #2667 .cmd-shim gate to deps.spawn + surface errorCode in review lanes

deps.spawn used shell:false with a bare binary name — on Windows, npm-installed
CLIs (gemini, codex, etc.) are .cmd shims that CreateProcess cannot start,
producing ENOENT + empty stderr. The review path then wrote an empty err file
and emitted a generic 'failed or returned empty output' stub.

Two fixes:
1. deps.spawn: detect .cmd/.bat on win32 and mediate through cmd.exe /d /s /c
   (same gate as runWithTimeout #2667, same explicit argv array).
2. runSpawnLane: surface errorCode (ENOENT, ETIMEDOUT) in the err file so the
   stub explains WHY the lane produced nothing.

* chore(#3086): backfill changeset PR number 3142

---------

Co-authored-by: sim <sim@local>
2026-08-07 07:43:14 -04:00
Tom Boucher
7ab4556395 fix(#3079): query commit no longer resurrects deleted phase branches via silent switch (#3141)
* fix(#3079: query commit no longer resurrects deleted phase branches via silent switch

git checkout -b both created AND switched HEAD, so when branching_strategy:
'phase' deletes its branch on merge, a later query commit silently
recreated it and moved HEAD there. The commit landed on the wrong branch.

Fix: replace checkout -b with git rev-parse --verify + git branch (create-
only, no switch). The commit always lands on the current branch. Callers
that want to be on the phase branch use execute-phase's handle_branching.

Updated 3 existing tests that asserted the old switch behavior.

* chore(#3079): backfill changeset PR number 3141

---------

Co-authored-by: sim <sim@local>
2026-08-07 07:02:43 -04:00
Tom Boucher
80ec0791eb fix(#3052): preserve frontmatter last_activity_desc on same-date body prose conflict (#3140)
* fix(#3052): preserve frontmatter last_activity_desc on same-date body prose conflict

preferNewerLastActivity only preserved last_activity_desc when the derived
date was OLDER than the existing frontmatter date. When the dates matched
(same-date), the derived body prose (potentially stale) overwrote the
authoritative frontmatter desc.

Fix: when derDate === exDate, also preserve the frontmatter desc.

* chore(#3052): backfill changeset PR number 3140

---------

Co-authored-by: sim <sim@local>
2026-08-07 06:16:22 -04:00
Tom Boucher
3c1e358a2d fix(#3099): emit LAST_ACTIVITY_UNPARSEABLE diagnostic for unusable last_activity (#3139)
* fix(#3099): emit LAST_ACTIVITY_UNPARSEABLE diagnostic when last_activity is present but unparseable

parseActivityTimestamp returned null for both absent AND present-but-unusable
last_activity values, silently suppressing the idle-stranded recommendation.
Per ADR-1411's amendment (corrupt is not absent), the fallback stays but a
diagnostic is now emitted via the warnUnusableInput seam.

- Added LAST_ACTIVITY_UNPARSEABLE to UNUSABLE_REASON enum + prose
- Wired warnUnusableInput into detectSignals when lastActivityRaw is truthy
  but parseActivityTimestamp returned null
- Updated UNUSABLE_REASON lock test
- Added 5 regression tests (unusable→diagnostic, absent→silent, well-formed→silent, dedup)

* chore(#3099): add changeset fragment

* chore(#3099): backfill changeset PR number 3139

---------

Co-authored-by: sim <sim@local>
2026-08-07 05:32:49 -04:00
Tom Boucher
a731a45cd6 fix(#3116): strip trailing CR per line in parseFrontmatterStrict for CRLF WINDOWS.md (#3137)
* test(#3116): failing-first — parseLedger throws on CRLF WINDOWS.md

On repos with core.autocrlf=true (Windows default), .planning/WINDOWS.md
is checked out CRLF. The \n--- close-fence scan leaves the last frontmatter
line's CR attached, and the key:value regex's . doesn't match CR, so the
parser throws WINDOWS_LEDGER_MALFORMED on the last key.

* fix(#3116): strip trailing CR per line in parseFrontmatterStrict

The \n--- close-fence scan lands on the LF of the last frontmatter line's
CRLF, so yamlBody ends with a bare \r. split(/\r?\n/) strips CR from
interior lines but the last line's \r survives. The key:value regex fails
because . doesn't match CR.

Fix: strip \r per line (rawLine.replace(/\r$/, '')) rather than normalizing
raw — the writer round-trips raw byte-exact.

* chore(#3116): add changeset fragment

* chore(#3116): backfill changeset PR number 3137

---------

Co-authored-by: sim <sim@local>
2026-08-07 03:14:40 -04:00
Tom Boucher
0e6fa2e2cf enhance(#3118): close the dead injectables and the shell projection follow-on — Wave 4 (#3124)
* test(#3118): failing-first coverage for the dead injectables and the shell projection

Adds the counter-tests Wave 4 closes against, before any fix:

- antigravityWatermark had zero test references. The four existing tests
  that look like watermark coverage hand the fallback a literal mark and
  never call the producer, so nothing pinned whether a real run's mark is
  correct. Covers all six branches plus the non-object cache classes.
- Pins the fail-open: a transcript read that throws reports lines:0,
  indistinguishable from a genuinely empty transcript, and the consumer
  then replays a previous run's review as this run's.
- Pins the export-line escaping across the repair, persist and win32 bash
  lanes, including the parity assertion that they must not diverge.
- sliceCurrentPositionSection: empty-vs-absent, fenced heading, second
  occurrence, H3, CRLF.
- Proves deps.progressProvider is inert by supplying a throwing stub to
  all ten transition intents.

Verification through the remote runner only.

Refs #3118

* fix(#3118): distinguish an unreadable transcript from an empty one

antigravityWatermark's final read can throw on a transcript that
indisputably exists. It returned lines:0, which is the same value a
genuinely empty transcript produces, so the caller could not tell the
two apart.

antigravityTranscriptFallback derives its skip from that count. A mark
of {convId:'c1', lines:0} for a conversation that pre-dates the run
makes it skip nothing and return the last PLANNER_RESPONSE in a
transcript written before this run started — a previous review
presented as this one's, which is exactly what the function's own
'never stale' docstring promises cannot happen.

The unreadable case now sets unreadable:true and the fallback declines
for a same-conv-id unreadable mark. An absent or empty transcript is
untouched: those genuinely have zero prior lines.

* fix(#3118): escape the export line for the file it lands in, not the echo

Three lanes emit export PATH="<dir>:$PATH". repair escaped it with
escapePosixDoubleQuoted; persist and the win32 Git Bash lane escaped it
with escapeSingleQuotedShellLiteral instead.

The single-quoting is correct for the echo, so nothing runs when the
user pastes the command. But the bytes appended to ~/.bashrc are the
export line itself, and inside double quotes in an rc file a $(...) or
a backtick in the directory name is command substitution that runs on
every new shell. Those characters are legal in a path on both POSIX and
Windows, so the path was reachable.

projectPathExportLine is now the single source of that line and escapes
for its final rc-file context; each lane still applies its own transport
escaping on top. fish keeps the single-quote escaper — its value really
does stay single-quoted.

The cmd.exe lane interpolated into a cmd double-quoted string with no
cmd-level escaping, so a quote closed the region and &cmd& ran. A quote
is reserved on Windows and cannot appear in a real path, so there is no
correct command to suggest: the win32 lanes now fail closed for one.

Metacharacter-free paths render byte-identically on every lane.

* fix(#3118): drop a stray carriage return and a deps field nobody reads

locateCurrentPosition subtracted a fixed one byte to exclude the newline
before the next heading, which assumes LF. On a CRLF document the slice
kept an unpaired trailing carriage return. It now walks back over the
newline and over a preceding carriage return if there is one.

StateTransitionDeps also required a progressProvider that 33 sites
supplied and no site ever called. A required field nothing reads widens
the module's interface without changing its implementation, which is the
shape epic #3051 cites as its reason for refusing blanket injection.
Removed along with the ProgressRecord alias that existed only as its
return type; state-document.cts's unrelated interface of the same name
is untouched.

* fix(#3118): stop an empty span duplicating bytes, and name the empty results

Three findings from the isolated review pass.

locateCurrentPosition could return end < start when the section was
empty and the next heading followed with no blank line between. Every
mutator splices with slice(0,start) + body + slice(end), so an inverted
span duplicated the region between them — a blank line silently
inserted into STATE.md on every transition, two bytes on CRLF. The span
is now clamped, and an empty section is a zero-length span, which is
what it always meant.

The win32 fail-closed path left the installer printing 'Add it with one
of:' with nothing under it. An empty shellActions folded two different
facts together, so projectPathActionProjection now carries a frozen
PATH_ACTION_REASON and the installer branches on it. Two empty results
with different causes staying distinguishable is the subject of the
epic this belongs to.

fish_add_path parses a leading dash as an option, so a directory named
-v printed 'No paths to add' instead of being added. Verified against
fish 4.8.1: the end-of-options separator fixes it.

Replaces the console-prose test the second fix first arrived with — a
regex over captured stdout is what CONTRIBUTING prohibits, and the
typed reason is the surface it asks for instead.

* fix(#3118): escape TOML control characters, and stop a test name overstating

Five findings from the two review axes.

escapeTomlDoubleQuotedString escaped only backslash and quote. TOML
basic strings also require U+0000-U+0008, U+000A-U+001F and U+007F to be
escaped, so a value carrying a raw newline or NUL wrote a config.toml no
parser accepts — rejecting the whole file, not just that value. Four of
its call sites write real config. Tab stays raw; the grammar exempts it.

The byte-identity test claimed every lane was unchanged for an ordinary
path, which is false: fish now takes the end-of-options separator on
every path, not only hostile ones. Renamed, and the one intended delta
now has its own named test instead of hiding inside a claim that read
as broader than it was.

Also: exact-equality assertions in place of substring checks that could
pass on a subtly wrong escape, newline and null-byte cases for all five
quoting primitives, and a temp dir registered with t.after so it is
removed when an assertion fails.

* docs(#3118): add the changeset fragments

* fix(#3118): degrade instead of throwing on a null conversation cache

A cache file whose whole content is the literal null — what a truncated
or zeroed write leaves behind — made both antigravityWatermark and
antigravityTranscriptFallback throw. JSON.parse('null') succeeds, so the
try/catch wrapping the parse never fired, and resolveConvId then called
hasOwnProperty on null.

Both functions advertise the opposite; the existing test next to them is
named 'a missing cache or transcript degrades to empty, never throws'.
Parsing successfully is not the same fact as the payload being usable,
and a guard that only wraps the parse cannot tell them apart.

resolveConvId is now total for any non-object input, so one guard covers
both callers. Caught by the null case in this wave's own cache matrix.

* test(#3118): correct a stale fish expectation and a parity comparison

The pre-existing 'POSIX persist mode escapes single quotes' test pinned
fish_add_path without the end-of-options separator this wave adds, so it
asserted behavior that is no longer correct. A repo-wide scan found one
such hardcoded expectation; every other site derives its expectation
from the projection.

The new parity test compared the token from a POSIX path against the
win32 lane, which posix-normalizes its input first — two different
inputs, so the tokens differed for a reason that had nothing to do with
the parity it claims to check. It now derives the win32 expectation from
the same input the lane receives.

* docs(#3118): reword a comment the injection scanner reads as an instruction

The scanner pattern act\s+as\s+(?:a|an|the)\s+ carries no word
boundary, so 'the same fact as the payload' matched on the tail of
'fact'. Reworded per the documented remedy for this collision.

The missing boundary is a scanner defect rather than a prose problem —
any contributor writing 'fact as the' trips it — but the pattern is
gate plumbing, which the sibling epic owns, so it is surfaced rather
than changed here.

* chore(#3118): backfill changeset pr number to 3124

* chore(#3118): backfill changeset pr number to 3124

* fix(#2784): make the negation scan single-pass and index it correctly

Three defects in the negation suppression added by #3127, all in one
block, none of which had a test.

The pair scan was verbs.some(nouns.some(...)) with a slice and a split
per pair, so it grew cubically with clause length: 1.1ms before that PR
and 8462ms after, on 800 verb+noun pairs in one clause. api-coverage's
property test generates documents large enough to reach the runner's
600s file cap, which is why it hangs as 'fail 0, cancelled 1' rather
than failing an assertion. Every (verb, noun) window is a subset of the
single widest one, so one scan of that window answers the same question
in a linear pass. Verified equivalent against the old predicate over
20,000 generated clauses.

Both checks also subtracted clause.start from offsets that collectTerm-
Matches already returns clause-local. The first clause on a line has
start 0 so it worked there and nowhere else: later clauses went
negative, and slice reads a negative index from the end, so suppression
silently examined unrelated text.

The comment claimed 'without any API integration' was suppressed. It is
not — the qualifier sits outside the two-word lookback and the noun
precedes the verb. Widening the window would trade a false positive
that costs one declaration line for a false negative that slips a real
integration past a blocking gate, so the behavior stands and the
comment now says so. Pinned by a test.

The qualifier sets were also rebuilt for every line of every document.
2026-08-06 23:57:05 -04:00
Tom Boucher
94bf32a2bb fix(#2786): skip sentinel phase ids in phase-complete stage 2 heading scan (#3130)
* fix(#2786): skip sentinel phase ids in phase-complete stage 2 heading scan

Stage 2 of the next-phase cascade accepted any higher-numbered roadmap
heading without checking the 999.x backlog sentinel convention that stage 1
already checks. A Phase 999.1: Backlog Item heading was treated as the next
real phase, advancing STATE.md into the backlog and making the milestone
perpetually 'Ready to plan' instead of 'All phases complete'.

Added isSentinelPhaseId(pm[1]) guard (mirrors stage 1's /^999(?:\.|$)/ check)
so both sentinel ranges (0.x drafts, 999.x backlog) are skipped.

* chore(#2786): backfill changeset PR number 3130

---------

Co-authored-by: sim <sim@local>
2026-08-06 18:42:39 -04:00
Tom Boucher
1c9f6a08e3 fix(#2784): suppress detectApiIntegration on negated prose (#3127)
* fix(#2784): suppress detectApiIntegration on negated prose clauses

The compound verb+noun rule had no negation awareness. A clause like "This
phase integrates no external API" matched verb=integrates + noun=API and
fired a false positive, halting the blocking verify:pre gate and forcing a
coverage matrix for a non-existent API.

Added clause-local negation suppression: if the clause contains an
unambiguous negation qualifier (no, not, without, zero, neither, nor,
none, and common contractions), the pair is suppressed. True positives
("integrate the Stripe API") are unaffected. The human override (COVERAGE.md
"no integration" declaration) remains valid.

* chore(#2784): backfill changeset PR number 3125

* chore(#2784): fix changeset PR number to 3127

* chore(#2784): trigger CI re-run

---------

Co-authored-by: sim <sim@local>
2026-08-06 17:39:18 -04:00
Tom Boucher
b181c2f8c3 fix(#3039): clamp max/xhigh effort to high for Claude-runtime skills (#3119)
* fix(#3039): clamp max/xhigh effort to high for Claude-runtime skills

effort: max in plan-phase, execute-phase, and autonomous SKILL.md frontmatter
was passed through as output_config.effort, which the Anthropic API rejects
when extended thinking is disabled (400: effort 'max' is not supported when
thinking is disabled on this model). The frontmatter is static at install
time and the installer cannot know whether thinking will be on or off at
invocation.

normalizeClaudeSkillEffort now clamps both 'max' and 'xhigh' to 'high' —
the maximum value that works in both thinking states on all supported models.
Applied in both src/runtime-artifact-conversion.cts and bin/install.js.

* chore(#3039): backfill changeset PR number 3119

* fix(#3039): regenerate skills with clamped effort: high

---------

Co-authored-by: sim <sim@local>
2026-08-06 10:20:22 -04:00
Tom Boucher
077028584f fix(#3036): accept non-numeric-leading phase ids in roadmap.analyze (#3117)
* fix(#3036): accept non-numeric-leading phase ids in roadmap.analyze

roadmap.analyze's phase-heading discovery and checklist discovery regexes
required a digit-first id (\d+...). A project using letter-prefixed ids
(e.g. B7, P0.3-2) got phase_count: 0, current_phase: null, next_phase:
null — even though get-phase and execute-phase resolved the same ids fine.

Widened all three regex sites (phasePattern, checklistPattern, nextHeader
section boundary) to accept an optional leading letter prefix
([A-Za-z]?\d+...). Existing numeric-leading ids are unchanged.

* chore(#3036): backfill changeset PR number 3117

---------

Co-authored-by: sim <sim@local>
2026-08-06 08:07:24 -04:00
Tom Boucher
fb3ee56651 fix(#3033): resolve zero-plan split-parent phase as complete when roadmap checkbox is checked (#3114)
* fix(#3033): resolve zero-plan split-parent phase as complete when roadmap checkbox is checked

A phase split into sub-phases (parent kept as shared context, zero plans
by design) was permanently stuck as 'researched' because the roadmap-
checkbox override at line 2266 required completion.phase_complete (derived
from plan/summary counts), which is always false for zero-plan phases.
The parent was permanently eligible for current-phase selection and
re-planning recommendations.

The override now fires when roadmapComplete AND planCount === 0 (the
split-parent shape), treating it as complete regardless of the plan-count
derivation. A zero-plan phase whose checkbox is still unchecked stays
in-progress (researched). Ordinary phases with plans are unchanged.

* chore(#3033): backfill changeset PR number 3114

---------

Co-authored-by: sim <sim@local>
2026-08-06 06:44:28 -04:00
Tom Boucher
10da377794 fix(#3021): recognize worktree-wf_* branch namespace in all guards (#3109)
* fix(#3021): recognize worktree-wf_* branch namespace in all guards

The Claude-orchestration Workflow backend (#1143) creates per-plan
worktrees on branches named worktree-wf_<runid>-<n>. Four independent
copies of the agent branch allow-list regex (^(worktree-)?agent-...) never
learned this namespace:
- hooks/gsd-worktree-path-guard.js:176 — FAILED OPEN (process.exit(0)),
  silently disabling path containment for exactly the concurrent dispatch
  mode where cross-worktree writes are most likely
- src/worktree-safety.cts:21 — silently dropped cleanup-wave manifest
  entries
- agents/gsd-executor.md:503 — FATAL halt on branch check
- gsd-core/references/worktree-branch-check.md:33 — same FATAL halt

Extended all four to ^((worktree-)?agent-|worktree-wf_)[A-Za-z0-9._/-]+$.
The path guard now correctly blocks cross-worktree writes for Workflow-
backend branches instead of no-op'ing.

* chore(#3021): backfill changeset PR number 3109

---------

Co-authored-by: sim <sim@local>
2026-08-06 04:26:59 -04:00
Tom Boucher
bbdf34a332 Merge pull request #3106 from open-gsd/test/3057-wave3-negative-space
fix(#3057): reach the branches nothing could reach, and stop reaping on a PID we never probed — Wave 3
2026-08-06 03:10:37 -04:00
Tom Boucher
d6cce9e2c3 fix(#3020): verify graphify tool identity before reporting compatibility (#3107)
* fix(#3020): verify graphify tool identity before reporting compatibility

checkGraphifyVersion ran 'graphify --version' on PATH and trusted any
plausible version string — a foreign binary named 'graphify' that happened
to print a version would silently report compatible:true with no warning.
Downstream graphify work would then proceed against the wrong tool and fail
later in ways that looked like GSD defects.

After getting a version from the binary, verify the graphifyy Python package
via importlib.metadata. If the package cannot be confirmed, emit a warning
naming the mismatch regardless of version-range compatibility. The
compatible flag is now correctly false for unverified tools (it was
previously read by nobody — the warning is what surfaces).

* chore(#3020): backfill changeset PR number 3107

---------

Co-authored-by: sim <sim@local>
2026-08-06 02:56:07 -04:00
Tom Boucher
8fb6681a1e fix(#3017): scope state write disk scan to stored milestone (#3105)
* fix(#3017): scope state write's disk scan to stored milestone

buildStateFrontmatter called getMilestonePhaseFilter(cwd) WITHOUT the
stored milestone version, so it auto-derived from ROADMAP.md — and when
getMilestoneInfo mis-bound (the stored milestone had no matching non-✅
heading), it picked a confidently-wrong milestone and clobbered the stored
value + rewrote progress with whole-project counts on every state.* write.

Pass the stored milestone from STATE.md frontmatter through to
buildStateFrontmatter and use it as the explicit versionOverride for
getMilestonePhaseFilter. When the stored milestone is available, the filter
scopes to it instead of auto-deriving.

* chore(#3017): backfill changeset PR number 3105

---------

Co-authored-by: sim <sim@local>
2026-08-06 01:35:06 -04:00
sim
1e844b942c fix(#3103): only "no such process" means the owner is dead
Review found the previous commit fixed the wrong cliff. process.kill accepts a
pid up to 2147483647 and throws a TypeError above it, so 2147483648 — an
ordinary finite number — sailed past the finite check, threw, and was
classified dead. Measured here: 2147483646 and 2147483647 raise ESRCH,
2147483648 and above raise ERR_INVALID_ARG_TYPE. The digit-length boundary the
last commit pinned was a different, earlier gate, and its tests implied it was
the meaningful one.

The liveness helper now treats only ESRCH as dead. EPERM, a type error from an
out-of-range pid, an error with no code at all — every outcome it does not
recognise returns alive, because the value feeds a forced worktree removal and
an unrecognised failure must never read as permission to delete. Against a
build with the old catch, a lock holding 2147483648 reaps the worktree; with
this one it is skipped and the directory survives.

The finite check stays. It no longer carries the safety, but it still names
garbage input accurately rather than reporting it as a live owner, and its
comment now says that so it is not removed as redundant.

The freshness verdict is split. An unreadable lock mtime and a genuinely recent
lock both reported lock_too_fresh, which tells an operator to wait when waiting
cannot help — the same conflation this module already separated for parse
failures. The unreadable case is now lock_age_unknown. Only this module and its
tests read these strings; no workflow or command consumes them.

The dependency spread in the prune path is kept as it is, with a test that
kills it. A caller-supplied porcelain parser displacing the default is the
intended seam, not an accident: the parser is a declared member of the
dependency type and the planner already prefers an injected one, so the
hard-coded key restates the same default rather than guarding against
override. Previously nothing exercised that line at all.

Refs #3057

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 01:26:15 -04:00
sim
573d39ea60 fix(#3103): refuse to reap on a PID the parse could not represent
A lock file whose PID is a digit string longer than 308 characters parses to
Infinity, not NaN. The default liveness helper then calls process.kill with it,
which throws a TypeError rather than an errno error, and that helper's catch
only recognises EPERM — so it returns false, meaning "the owner is dead", and
the worktree becomes eligible to be removed.

The reaper already fails closed for this exact situation. It wraps the liveness
call in a catch that sets alive and does not reap, with a comment saying
liveness could not be determined. That protection never fires here, because the
inner catch swallowed the error first and answered confidently instead of
admitting it did not know. A guard that cannot verify safety reporting success
is the defect this whole epic is named for, and it was sitting inside the one
function in the tree that deletes things.

The guard deleted earlier on this branch tested for NaN. That test really was
dead — a digits-only capture cannot parseInt to NaN — but the reachable failure
is non-finite, so removing it without correcting the predicate left the hole
open. The check is now for a finite value, and a malformed PID reports the same
lock_owner_unknown skip as an unparseable one, since both mean the same thing:
the owner is unknown, so nothing is removed.

Measured, not assumed: 308 nines still parse finite, 309 are Infinity. All
three of that boundary are covered, along with a 400-digit case that asserts
the worktree is still on disk afterwards — the consequence, not just the
verdict. Against a build with the guard removed, that case reports
pid_dead_and_merged and the worktree is gone.

The liveness helper's own catch still maps every non-EPERM error to "dead". The
fix belongs where the value stops being trustworthy rather than at the far end
of it, but that helper is worth revisiting on its own terms.

Refs #3057

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 01:08:15 -04:00
Tom Boucher
4926c2e904 fix(#3004): update Codex adapter collaboration-tool vocabulary (#3104)
* fix(#3004): update Codex adapter collaboration-tool vocabulary

The generated Codex skill adapter documented stale tool vocabulary:
- wait(ids) → collaboration.wait_agent(timeout_ms=...) (the real tool), with
  explicit disambiguation from the unrelated exec-cell functions.wait
- close_agent(id) unconditional → gated on tool visibility (same schema-
  detection pattern already used for spawn_agent's agent_type field)
- Missing required task_name field and fork_turns parameter → added
  alongside the existing fork_context guidance (coexist, not replace)

Updated the regression test to assert the new vocabulary (wait_agent not
wait(ids), functions.wait disambiguation, task_name, fork_turns, tool_search
gate on close_agent).

* chore(#3004): backfill changeset PR number 3104

---------

Co-authored-by: sim <sim@local>
2026-08-06 00:53:12 -04:00
sim
c2d5b528e5 refactor(#3103): give the reap and worktree-info paths the seam their callers already have
Twenty-two branches in the orphan-reaping path are unreachable from the test
suite, and the reason is not that they are hard to reach — it is that nothing
can reach them. The reaper already accepts an injectable dependency bag, but
every test drives real git and injects only the clock and the liveness probe,
so each fail-closed return inside it has never executed under test.

The two entry points above it took no dependencies at all, so a test could not
drive them even if it wanted to. Both now accept the same bag and thread it
down, with stdout and stderr writers defaulting to the process streams. The
worktree-info probe in the base-branch resolver called its git seam directly
while a sibling function in the same file already modelled the injectable
form; it now follows that sibling rather than inventing a second convention.

Every parameter defaults to today's real implementation, so no existing caller
changes behavior. This is a testability seam, not a redesign.

Two guards are removed as genuinely dead, each excluded by a check a few lines
above it. A NaN test on a value captured by a digits-only pattern cannot fire,
because parseInt of digits is never NaN. An emptiness test on a capture group
that matched one-or-more non-space characters cannot fire either. Each site
keeps a one-line note naming the guard that excludes it, so neither gets
restored by a future reader.

A third guard was proposed for deletion on the same grounds and is NOT removed,
because the claim was wrong. The local-branch fallback returns null when git
prints output that is non-empty but names neither branch — the emptiness check
above it only catches the empty string, so a single newline reaches the
fallback with both flags false. Deleting it would have changed which branch the
resolver reports. It stays, and it gets a test.

Refs #3057

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 00:32:28 -04:00
Tom Boucher
aa7697fe97 fix(#2997): include phase_id_convention in resolved config (#3098)
* fix(#2997): include phase_id_convention in resolved config

_baseConfig in config-loader.cts is an explicit allowlist of keys copied
from parsed into the resolved config. phase_id_convention was in
VALID_CONFIG_KEYS (manifest line 83) and survived the unknown-key filter,
but was never copied into _baseConfig — silently dropped on a clean read.
The milestone-prefix validation check could only be activated via the
ROADMAP frontmatter fallback, not the documented project-config surface.

Added phase_id_convention: get('phase_id_convention') ?? null to _baseConfig.
3 tests: survives resolution, null round-trips, absent resolves to null.

* chore(#2997): backfill changeset PR number 3098

---------

Co-authored-by: sim <sim@local>
2026-08-05 21:26:38 -04:00
Tom Boucher
2979f2a994 fix(#2978): add structural validation to roadmap validate (#3092)
* test(#2978): roadmap validate must perform structural validation

Failing-first: roadmap validate returns {"warnings":[]} (exit 0) for every
input — empty file, garbage, missing file, truncated frontmatter — because it
performs no structural validation and its one opt-in check (W021 milestone-
prefix) is off by default. Six cases: empty, garbage, missing, truncated
frontmatter, well-formed (no false positive), BOM-prefixed (not corruption).

* fix(#2978): add structural validation to roadmap validate

roadmap validate returned {"warnings":[]} (exit 0) for every input — empty
file, garbage, missing file, truncated frontmatter — because it performed no
structural validation and its one opt-in check (W021 milestone-prefix) is
off by default. A verb named validate that cannot produce a negative result
provides false assurance.

Add four structural checks, each producing a coded warning {code, message}:
- V001: file missing/unreadable (was silent success)
- V002: empty/whitespace-only
- V003: malformed frontmatter (unterminated --- fence; BOM-tolerant per #3057)
- V004: no recognizable phase entries (no ### Phase N: heading)
Keep the existing W021 milestone-prefix check as-is. Exit non-zero via
ExitError(1) when warnings are non-empty, per the documented contract
('exits non-zero on any error or warning'). Well-formed roadmaps (incl.
BOM-prefixed, CRLF) still validate cleanly with warnings: [] and exit 0.

* test(#2978): update W021 tests for non-zero exit on warnings

Two existing W021 tests asserted roadmap validate exits 0 even with warnings
('roadmap validate should exit 0 even with warnings') — that was the bug.
#2978 made validate exit non-zero on any warning (per its documented
contract). Updated both mismatch-case tests to expect success===false and
parse the JSON output from the failure path (stdout is written before the
ExitError throw).

* chore(#2978): add changeset fragment

* chore(#2978): backfill changeset PR number 3092

---------

Co-authored-by: sim <sim@local>
2026-08-05 18:22:14 -04:00
Tom Boucher
b0f1722662 fix(#2969): ratchet completed_plans up for gap-closure plans under deriveProgressKeys (#3091)
* test(#2969): completed_plans must ratchet up for gap-closure plans

Failing-first regression: when deriveProgressKeys=true (cmdStatePlannedPhase's
opt-in), applyStatePreservation restores completed_plans to its pre-growth
curated value, so gap-closure plans that complete never increment it —
STATE.md shows completed_plans < total_plans forever even though every PLAN
has a SUMMARY. Three cases: the ratchet-up (derived 54 > curated 50), the
ratchet-down protection (derived 47 < curated 50 keeps curated), and the
body-only write protection (no deriveProgressKeys → wholesale restore).

The existing #2440 test covers the case where derived < curated (ratchet
holds); this adds the missing case where derived > curated (ratchet must
release upward).

* fix(#2969): ratchet completed_plans/plases up under deriveProgressKeys

applyStatePreservation's deriveProgressKeys path (cmdStatePlannedPhase's
opt-in) let total_plans/total_phases take the derived value but restored
completed_plans/completed_phases to their pre-growth curated value — so
gap-closure plans that completed after the plan count grew never incremented
them, leaving STATE.md at completed_plans < total_plans forever (every PLAN
had a SUMMARY).

Extend the deriveProgressKeys exclusion to also let completed_plans and
completed_phases take the derived value, but ratcheted UP only (never derive
downward past curated) — preserving the #3242 curated-progress protection
for cases unrelated to plan-count growth (e.g. a deleted SUMMARY). percent
takes the derived value (the resync already recomputed it from disk counts).

Scoped to deriveProgressKeys (plan-phase only); body-only writes
(state.update/patch without the flag) keep the full #3242 wholesale restore.

* fix(#2969): also take derived percent under deriveProgressKeys

Isolated-review blocker: percent fell into the else branch and was
overwritten with the stale curated value, contradicting the inline comment
and leaving STATE.md incoherent (e.g. completed_plans:54/total_plans:54 at
percent:93). Skip percent in the ratchet loop so the derived (resync-
recomputed) value survives.

* chore(#2969): add changeset fragment

* chore(#2969): backfill changeset PR number 3091

---------

Co-authored-by: sim <sim@local>
2026-08-05 16:52:37 -04:00
Tom Boucher
53ea8e0664 fix(#3057): make a guard's failure distinguishable from its benign result — Wave 1 (#3088)
* fix(#3057): refuse the write when the duplicate scan cannot complete

writeManifest documents itself as a fail-closed duplicate guard: if any
existing manifest shares plan_id with a different, non-terminal job_id it must
refuse, because dispatching again would duplicate the external job.

It could not honour that. The scan reads every sibling manifest looking for the
duplicate, and an unreadable or unparseable sibling was `continue`d past. If
the corrupt file was the one holding the live duplicate, the scan found nothing
and a duplicate external job dispatched.

The asymmetry is what gives it away: a malformed TARGET refused with
malformed_existing because clobbering is unacceptable, while a malformed
SIBLING was skipped — yet siblings are the only thing the duplicate check
reads.

Adds a scan_incomplete verdict that refuses and names the offending file, so an
operator can quarantine or repair it. Fail-closed alone would let one stale
corrupt manifest wedge every dispatch for that planning dir permanently; naming
the file is what makes refusing survivable. malformed_existing is untouched, so
the target/sibling distinction stays visible. The docstring is updated — it
previously stated a rule the function did not keep.

memFs() gains an optional failReads map so these branches are reachable at all;
they had zero coverage because the fake could not express a per-file read
fault. The signature is additive and every existing caller is unchanged.

The regression is proved by a pair, not a single test. A control writes a
readable sibling holding a genuine non-terminal duplicate and asserts
duplicate_plan_id, establishing the scenario is real; the regression then makes
that same path unreadable and asserts scan_incomplete. A first draft of this
test used a corrupt-JSON fixture containing no plan_id at all while its comment
claimed otherwise — it duplicated the unparseable-sibling case and proved
nothing, which is the defect class this phase exists to remove.

Refs #3051

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

* fix(#3057): make a guard's failure distinguishable from its benign result

Wave 1 of the negative-space backfill: the branches where a guard that could
not verify something reported the same value it reports when everything is
fine. That indistinguishability is the defect; every fix here makes the two
states tellable apart, and every test proves it with a pair — one for the
failure, one for the benign case. A single test cannot establish that two
states are distinguishable, which is the whole property being fixed.

state.cts phaseInventoryProvider returned null for both a real disk-scan
failure and a genuinely empty phases dir, so `state rebuild` could report
success while phase-table reconciliation never ran. It now returns a
discriminated result and the CLI surfaces phase_inventory_scan_failed plus a
reason. The reason field turned out never to have been wired into the emitted
JSON at all — it existed only as an internal variable — so a test could only
assert on the operator-facing note. It is a real field now.

state.cts treated an unreadable lock body the same as an empty one, applying
the 1-second stealable floor. A lock we cannot read is not a lock we know is
stale; an unreadable body is now held to the deadman ceiling like a live
holder.

verification.cts findStaleVerificationSummary returned null on any fs, scan or
clock failure — meaning "not stale". It now returns a discriminated
StaleCheckResult and the caller records that the check was indeterminate.

git-base-branch resolveBaseBranch returned 'main' both when no candidate branch
existed and when every git tier timed out. A diagnostics variant now reports
whether the answer was verified, and the CLI writes an unverified-fallback note
to stderr. The stdout contract five workflows parse is untouched.

worktree-safety snapshotWorktreeInventory left exists:true when statSync threw,
so a guard that could not check reported the worktree present; exists is now
tri-state and a stat failure surfaces as an 'unverified' finding.
planWorktreePrune reported 'no_worktrees' for a parse failure, which is not the
same as an empty list — and it drives a prune. It now reports 'parse_failed'.

Fixing the inventory change exposed a second fail-open in verify.cts: the
validate-health consumer silently dropped findings whose kind it did not
recognise, so the new kind would have vanished. That is closed too — worth
noting that the survey enumerated producers of degraded verdicts, not consumers
that discard them.

worktree-base-ref and state-transition gain the distinguishing signal without
changing what they do: headAbsenceVerified, and a phase-inventory scan meta.
Whether those guards should ACT differently is a product question this change
does not answer, and both are flagged rather than quietly settled.

rescueSummaryArtifacts is left alone: rescuing on an uncertain cat-file is
deliberate per #2556. It now has tests proving it, and a recorded negative
finding — git cat-file -e returns 128 for both "absent from HEAD" and a fatal
error, so "uncertain" and "certain-and-fine" are not separable at the git
level.

Refs #3051

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

* test(#3057): assert typed values, not rendered text

Ten assertions in the rebuild CLI suite matched substrings of produced output —
STATE.md body fields, a markdown table row, an audit-log heading, and JSON keys
read as text. CONTRIBUTING prohibits that: if the code under test produces
text, the test asserts on its structured surface instead.

No production surface had to be built. Every one already existed and was
already compiled into bin/lib: stateExtractField for body fields,
parseMarkdownTable for the phase table, collectSection for the audit-log
section, and result.data.log — already a typed RebuildLogEntry[]. The tests
were matching rendered text sitting next to the structured data.

One of those assertions was passing for the wrong reason. `stdout.includes
('rebuilt')` matched the JSON KEY name, not a value: the dry-run path emits
`mutated` and the real path emits `rebuilt`, so it would have passed whether
the value was true or false. It now asserts the value.

external-job's refusal already had to name the offending file — that naming is
why the fail-closed variant is survivable rather than a permanent wedge — but
the tests proved it by substring of a prose message. The failure result now
carries offendingPath as its own field and the tests assert it by value. The
human message is unchanged; operators read it.

Array membership is left alone. `phaseIds.includes('99')` and
`result.updated.includes('Completed Phases')` are membership checks on real
arrays, not text matching, and converting them would weaken nothing and clarify
nothing.

Refs #3051

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

* test(#3057): execute acquireStateLock instead of grepping its source

The non-EEXIST lock test asserted on the TEXT of the built .cjs and never
called acquireStateLock. It carried an allow-test-rule: architectural-invariant
exemption to permit that. A source grep proves a literal is present in a file,
not that the behaviour works — it is weaker than a liveness test, which at
least runs the code, and it was the only coverage the fatal-errno path had.

Replaced with tests that inject the errno through fs and assert what actually
happens: a fatal EACCES propagates out of acquireStateLock with zero backoff
sleeps, while EAGAIN/EINTR/EINVAL/EIO/ENOENT/ESTALE/EPERM/EBUSY retry once and
succeed. The exemption is removed and its allowlist entry with it.

One old assertion is deliberately not carried over: it checked the retryable
errnos were expressed as a Set rather than an inline literal. That is a shape
check with no runtime signature; the behavioural tests fail if the code reverts
to the old inline check, which is the regression it was really guarding.

The #3057 lock-body tests move into that same file rather than a new one, which
is what lint-test-file-count asks for and puts every acquireStateLock test in
one place.

Refs #3051

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

* fix(#3057): surface an indeterminate staleness check to its callers

An isolated review caught an inconsistency inside this wave. Two of the three
"add the distinguishing signal" fixes wire through to something a user sees:
git base-branch writes an unverified-fallback diagnostic to stderr, and an
unverifiable worktree surfaces as a W020 finding. The third set
staleCheckIndeterminate on readVerificationStatus's result and nothing read it.

A signal nobody consumes leaves the fail-open exactly as silent as before: the
staleness check could fail and the operator saw precisely what they would see
if the answer were genuinely "not stale". That is the defect this issue exists
to remove, so it is not defensible as scaffolding when its two siblings in the
same change already wire through.

All five callers now surface it, each through the channel it already had rather
than a mechanism imposed uniformly: phase complete adds it to its existing
warnings array and, on the blocked path, as an additive note on the error text;
init and roadmap carry it as a field on output they already emit; the UAT
report carries it without ever gating passed/blockers; workstream inventory
takes an injectable writeDiagnostic mirroring the git base-branch idiom,
because its return shape had nowhere to hang a per-phase field without
rippling the builder's types.

The routing decision is unchanged everywhere. What changes is only that a
caller and an operator can now tell a failed check from a completed one.

That diagnostic carries structured meta rather than being asserted by regex —
the default still writes only the human message to stderr, but tests assert
phaseDir and reason by value. Two earlier assertions in this branch were
converted the same way; this was the last raw-text assertion left.

Also records a scope correction: the completePhaseCore guards now compare
stateReplaceField's result to the body instead of testing truthiness, so a
field whose substitution produced identical text no longer reports as updated.
That is a real behaviour fix, not the signal-only change this file was
described as carrying, and its tests cover both the changed and unchanged
cases.

Refs #3051

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

* test(#3057): bound two heavy subprocesses for a loaded bench, not an idle one

The remote matrix surfaced three failures unrelated to this branch's changes.
All were bad tests, and a re-run would have hidden every one of them.

The reviewer-flags parse block bounded bash -> node -> a full gsd-tools cold
start at 5 seconds. On a bench running thirty thousand tests in parallel that
is not a hang, it is a busy machine. Raised to 30s, matching the convention
sibling suites already use for script invocations, with a comment saying what
the budget covers so nobody tightens it back. Two further copies of the same
5-second spawn in the same file had the identical defect and are raised too —
they were not in the failure report, but they will be next time.

The fragment-propagation test bounded npm run regen:derived — a full build plus
eight generators, the heaviest subprocess in the suite — at five minutes, and
node22 was killed near the end. The captured output proves it: every generator
had written its files and gen:install-tree had emitted all fifteen runtimes
before the kill. Raised to fifteen minutes.

That failure read as `null !== 0`, which says nothing. status null means killed,
not a non-zero exit, and the two want different responses: one is a timeout to
size correctly, the other is a real build break. The assertion now distinguishes
them and names the signal.

Neither test's assertions were weakened and no retry was added. A retry here
would suppress exactly the signal the timeout exists to produce.

Refs #3051

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

* test(#3057): capture fd 1 through the mock tracker, not a raw reassignment

The phase suite reported zero test results on both lanes while running for five
and a half minutes and exiting 1. No assertion text, no stderr, four events for
the whole file: enqueue, start, dequeue, complete. That shape is not a failing
assertion — it is the runner being unable to read the child at all, because it
parses its event stream from the child's stdout.

The cause was the capture helper reassigning fs.writeSync directly. Proven
rather than assumed: a standalone probe patched fs.writeSync and called
process.stdout.write, and the interception fired only when fd 1 resolved to a
FILE, not when it was a pipe. The remote runner captures the event stream to a
file, so a helper that was invisible against a pipe swallowed the reporter's own
output on the bench. That is also why the two sibling suites wired the same way
in this change pass cleanly — they use the mock tracker, the seam io.test.cjs
established for this exact function.

The helper now uses t.mock.method with an explicit restore after each call, so
teardown belongs to node:test rather than a second hand-rolled implementation,
and the interception cannot outlive the one synchronous call it wraps even if
that call throws. Ten call sites thread the test context through; three test
callbacks gained the parameter they lacked.

The three B3 tests are untouched — same assertions, same fault injection. Only
how the context reaches the helper changed.

Refs #3051

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

* test(#3057): capture phase-complete output from a subprocess, not fd 1

Two attempts to make in-process fd-1 interception safe both failed on the
bench. The suite reported zero test results on either lane while exiting 1 —
four events for the whole file — because the runner parses its event stream
from the child's stdout, and process.stdout.write routes through fs.writeSync
whenever fd 1 resolves to a file, which is how the runner captures. Patching
that seam anywhere in a file can therefore destroy the file's own reporting,
and tightening the window only moved the runtime from 326s to 125s without
recovering a single event.

So the interception is gone rather than tuned. The helper now spawns gsd-tools
as a real subprocess and reads stdout the way the OS already gives it to us,
which is what the rest of the suite does. It asserts the command succeeded
before parsing, so a genuine failure can no longer present as a JSON parse
error.

The two fault-injecting tests could not survive that move as written: a
subprocess cannot see a mock installed in the parent. Instead of reinstating
the interception they now produce the fault on disk — the summary artifact is
created as a dangling symlink, so the staleness check's real statSync throws
inside the child. That is a more honest fixture than a mock in any case, since
it is a condition a user's tree can actually be in. Skipped on Windows, matching
the existing symlink precedent in the write-guard suite.

Three further call sites turned out to depend on parent-process writeFileSync
mocks the subprocess could not see. Those call the CJS function directly, which
is what they always wanted — they never needed stdout at all.

Refs #3051

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

* fix(#3057): one name for one signal, one encoding for one distinction

Standards review found four things this branch introduced, all of them
inconsistencies with itself rather than with the repo.

One upstream bit reached its consumers under three names —
verification_stale_check_indeterminate in two modules, the same value with
"stale" dropped in a third, and stderr only in the fourth. Standardised on the
long name wherever it is a field. The workstream inventory keeps its stderr
channel, since its return shape has nowhere to hang a per-phase field without
rippling the builder's types, but it now says the same word for the same thing.

worktree-safety encoded one three-way distinction two ways in a single file: a
named union for a finding's kind, and boolean|null for an inventory entry's
existence. The second is now a named union too.

Two assertions matched human prose because the blocked and non-blocked
completion paths carried no typed field for the signal. Both now assert typed
values. The first round of this fix added the field but left the regex beside
it, which is the banned pattern sitting next to its own replacement; the second
removed it and added an assertion on the reason enum so nothing was lost.

The remaining two were reasoned away before being fixed, and both reasons were
bad. "No typed surface exists" is the condition CONTRIBUTING says to fix by
adding one — it took three lines. "The file already does this dozens of times"
is not licence to add instance number thirty-one; a convention that violates a
documented rule is debt, not precedent.

Vocabulary differing across DIFFERENT modules is left alone: CONTEXT.md rejects
a single shared result envelope, so per-module shapes are precedented, and a
baseline smell does not outrank a documented standard.

A census of every line this branch adds to a test file now finds no regex or
substring assertion on produced prose: 87 strictEqual, 25 ok (all non-empty or
shape guards), 12 equal, 3 throws (all typed err.code predicates), 3
deepStrictEqual, 2 notStrictEqual.

Refs #3051

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

* chore(#3057): backfill changeset pr number to 3088

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 16:00:52 -04:00
Tom Boucher
7203011400 feat(#3072): ship the deferred MCP served catalog (resources + prompts) (#3083)
* test(#3072): add failing-first coverage for the mcp served catalog

55 input-class rows from the phase test matrix, across four suites: the
catalog module over injected readFile/readDir seams, the protocol surface
through handleMessage, the install-vs-catalog parity gate, and fast-check
properties for uri round-trip, traversal refusal, and pagination partition.

src/mcp-catalog.cts lands as a skeleton whose functions throw, so the suites
fail on BEHAVIOR rather than on a missing module. The REASON enum is real so
tests assert typed codes instead of message prose.

Hostile coverage for the one client-controlled path surface (resources/read):
dot-dot and backslash traversal, percent- and double-encoded traversal,
absolute posix and windows paths, file:// scheme, null byte, symlink escape,
unindexed sibling, non-string and empty uri, wrong root segment.

IO faults are injected by monkeypatching the seam, never chmod 0o000 - root
bypasses mode bits, so a permission-based test silently passes with zero
coverage in root CI.

Refs #3072

* feat(#3072): serve the mcp catalog as resources and prompts

gsd-mcp-server now serves GSD's own content alongside its three tools: the
workflow, reference and command tree as MCP resources (resources/list, cursor
paginated, and resources/read over gsd://<segment>/<relpath> uris) and the 71
commands/gsd/*.md as MCP prompts keyed by bare command name. initialize
advertises resources and prompts, and deliberately does not advertise
subscribe or listChanged - the catalog is fixed for a server process lifetime,
so declaring a notification we never send would be a lie a host acts on.

Composition scope is SHARED, not re-declared. shouldCompose lives in
src/mcp-catalog.cts and bin/install.js now imports it instead of carrying its
own regex, so the served catalog and the installed file floor cannot drift on
what gets composed. Proven behavior-preserving across all 2871 tracked paths
plus windows-backslash, absolute and near-miss-prefix cases: zero mismatches.
tests/mcp-catalog-parity.test.cjs asserts served text equals the installer
composition-stage text over the real tree, with anti-vacuity guards requiring
both a marker-bearing workflow and a non-composed file in the comparison set.

Two measurements corrected the literal issue text. Composition is scoped to
gsd-core/workflows/ only, because a reference or command that documents marker
syntax with an unfenced example would otherwise be parsed as carrying a real
marker and have that line lossily dropped. And parity is asserted at the
composition stage rather than against an emitted runtime tree, since install
applies per-runtime path rewrites afterwards and the catalog is host-agnostic,
so byte equality with any one runtime would be false by construction.

resources/read is the one client-controlled path surface and is guarded in two
independent layers: the uri must be an exact key in the prebuilt index, which
defeats every traversal string by construction, and the mapped path is then
re-checked with validatePath so a symlink planted inside a root after indexing
is still refused.

Also fixes a real drift defect found while here: SERVER_VERSION was hardcoded
1.7.0 while the package is at 1.9.1. It now resolves lazily from VERSION or
package.json, reusing the precedent in runtime-artifact-conversion.

Closes #3072

* test(#3072): make the catalog parity gate drive the real installer

Review found the parity gate vacuous: it never imported or spawned
bin/install.js, and recomputed the installer side with the SAME shouldCompose
and composeWorkflow the catalog calls internally. It therefore proved only
that src/mcp-catalog.cts is self-consistent. The old row 52 compared
shouldCompose against a regex literal frozen in the test file rather than
against the installer at all. An inline divergent regex re-added to
bin/install.js - the exact regression ADR-1671 asks this gate to catch - would
have left the suite green.

The gate now spawns a real bin/install.js and compares the composition
DECISION, observed as gsd:section marker survival, against what the catalog
serves for the same files. Marker presence is the right observable because the
installer applies per-runtime path rewrites after composing while the catalog
applies none, so raw byte equality between the two surfaces is false by
construction and must not be asserted.

Sensitivity was proven, not assumed: overlaying the shouldCompose export that
bin/install.js imports so it always returns false makes a real spawned install
leave autonomous.md's markers in place while the catalog still strips them,
and the row 48 assertion diverges.

Anti-vacuity guards are kept and extended - the comparison set must be
non-empty, must contain a workflow that actually carries markers, must contain
a file the predicate declines to compose, and the install must have emitted a
non-zero file count. The marker-documenting reference case has no instance in
the real tree, so it uses an overlay fixture built with the same technique
workflow-fragments-emission.install.test.cjs already uses.

Renamed to .install.test.cjs so it lands in the install suite it now belongs to.

Refs #3072

* test(#3072): retarget the unknown-method assertion off a now-implemented method

tests/gsd-mcp-server.test.cjs used 'resources/read' as its example of an
UNKNOWN JSON-RPC method. The served catalog implements that method, so it now
returns -32602 (no uri supplied) rather than -32601. The remote runner caught
it deterministically on both linux lanes: -32602 !== -32601.

The test's intent is still correct and worth keeping, so it is corrected
rather than deleted or weakened. It now uses 'resources/subscribe', which the
server deliberately does not implement and deliberately does not advertise in
initialize's capabilities, because it never sends the corresponding
notification. That turns the assertion into a real contract - the advertised
capability surface and the implemented method surface agree - instead of an
arbitrary method name a future feature could invalidate the same way.

Swept the rest of the suite for other assertions pinning the newly implemented
methods; this was the only one.

Refs #3072

* chore(#3072): backfill changeset PR number 3083

* test(#3072): make the catalog fake fs separator-agnostic for windows

CI caught this on windows-latest (22 and 24): every catalog fixture indexed
ZERO entries, surfaced by the anti-vacuity guards as 'fixture catalog must
actually index resources for this property to mean anything'.

Mechanism: makeFakeFs keyed its dirMap/fileMap on POSIX-joined paths
(${root}/${rel}), while production buildCatalog looks paths up with
path.join, which is backslash-separated on Windows. Every lookup missed,
tryReadDir returned null, and the catalog came back empty.

Production is NOT at fault and is unchanged. The same CI run proves it: on
windows-latest the real-filesystem tests all passed, including 'installer
composition decision matches the served catalog for every file in the real
installed tree' and the row-51 non-vacuity proof against a real spawned
installer. A real Windows fs accepts both separators; the FAKE did not, so the
fake was the unfaithful one and is what changed.

Lookup keys are now normalized unconditionally with .replace(/\\/g,'/') in
readDir and readFile - never path.sep-conditional, never platform-gated. The
row-42/43 injected-fault wrappers got the same treatment, since they compared
raw production paths against POSIX-literal fixtures.

No assertion was weakened, and the anti-vacuity guards that caught this are
untouched - they are the reason this surfaced as a loud failure instead of a
suite that silently asserted nothing on Windows.

Refs #3072

---------

Co-authored-by: sim <sim@local>
2026-08-05 13:30:55 -04:00
Tom Boucher
2bc53baa03 fix(#2947): preserve preamble phase details when milestone section has none (#3084)
* test(#2947): milestone anchor must prefer heading with Phase details

Row 1 of the test matrix: the failing-first regression test. When the
phase-listing heading (## Phases) is NOT version-bearing but a later
version-bearing progress heading (### v9.0 phase progress) exists,
extractCurrentMilestone latches onto the progress heading and silently drops
the phases (phase_count: 0, exit 0). Five cases: the regression, the
version-bearing control (must keep working), the no-phase-details fallback,
the closed-vs-open preference, and an end-to-end roadmap.analyze check.

Reproduced locally against built lib + confirmed by maintainer triage (trek-e).
The one-word control (## Phases -> ## v9.0 Phases) restores phase_count: 2.

* fix(#2947): preserve preamble phase details when the milestone section has none

Root cause was one layer deeper than the issue title: the anchor selection
(selected = first non-closed version-bearing heading) is fine — the real
drop happens in the preamble strip. When the phase list lives under a
non-version-bearing ## Phases heading (the shipped greenfield template's own
shape) and the selected version-bearing heading is a LATER progress/notes
sub-heading with no ### Phase N: details of its own, the preamble strip
removed every phase-detail heading from the pre-milestone region (intended
to avoid duplication with a Phase Details section that does not exist here)
— silently dropping all phases (phase_count: 0, exit 0, empty stderr).

Fix: only strip preamble ### Phase N: headings when the selected milestone
section (currentSection) actually contains its own phase details. When it
does not, the preamble phases ARE this milestone's phases and must be
preserved. Falls back to today's behavior (strip) whenever the selected
section has phase details, so multi-milestone roadmaps with a dedicated
Phase Details section (#730) are unaffected.

Surgical: one conditional on the existing strip, no signature change, no
change to computeSectionEnd or the #730 Phase Details append. Blast radius
CRITICAL (84 upstream symbols) — the change is gated on currentSection's
content so every existing roadmap that currently resolves phases correctly
keeps doing so byte-identically.

* chore(#2947): add changeset fragment

* chore(#2947): backfill changeset PR number 3084

---------

Co-authored-by: sim <sim@local>
2026-08-05 13:10:52 -04:00
Tom Boucher
5719efbc6b fix(#2766): scan archived phases and read table-shaped Gaps/deferred entries (#3082)
* fix(#2766): scan archived phases and read table-shaped Gaps/deferred entries

Three silent false negatives in cmdAuditUat and its readers, all failing in
the reassuring (false-negative) direction — a UAT audit whose entire job is
to catch leftover work reporting zero over real work:

1. Archived phases invisible (src/uat.cts cmdAuditUat): on milestone
   completion milestone.cts MOVES phase dirs into
   .planning/milestones/<version>-phases/ (archive-by-default since #1871),
   leaving .planning/phases/ empty or absent. Partial archive → false
   all-clear; full archive → hard error indistinguishable from a broken
   install. Fix: enumerate archived dirs via the canonical
   getArchivedPhaseDirs seam (phase-locator.cts); archived dirs deliberately
   bypass getMilestonePhaseFilter (which scopes to the CURRENT milestone —
   applying it to past-milestone dirs would discard every one and reinstate
   the bug).

2. Table-shaped deferred-items.md yielded zero items (splitGapsEntries
   keyed on bullet openers only; a GFM table row starts with |). Fix: union
   of bullet + numbered + table-row splits.

3. Table-shaped ## Gaps yielded zero items (same bullet-only splitter).
   Fix: same union walker.

The table walker is deliberately NOT routed through parseMarkdownTable
(ADR-2143 §3 — that reads only the first table and treats ragged/headerless
shapes as errors, the wrong contract for a hand-written backstop table
that must surface its rows). New additive archived_milestone field labels
provenance.

Fix authored by issue reporter gavin-ray and verified against the published
tarball; maintainer triage (trek-e) confirmed all three findings. Cherry-
picked onto fresh next after prior PR #2832 closed for staleness; re-verified
under gsd-test + reviews. 21 regression tests including the negative
direction (bullet-only unchanged, status: resolved still suppressed, empty
phases dir still succeeds).

* chore(#2766): backfill changeset PR number 3082

---------

Co-authored-by: sim <sim@local>
2026-08-05 11:19:17 -04:00
Tom Boucher
481ac7c71b fix(#2946): run milestone complete unstarted-phase guard independent of STATE (#3081)
* test(#2946): milestone complete unstarted-phase guard fails open on STATE desync

Row 1 of the test matrix: the regression test that fails first. Adds seven
cases to tests/milestone.test.cjs covering the desync, absent, no-file,
--force-override, mismatch-WARNING, fresh-project-noop, and sentinel-skip
behaviors. RED on next: the guard's entire scan is nested inside
`if (stateVersion && stateVersion === version)`, so any STATE.md milestone:
value that does not exactly equal the version argument skips the scan with
no warning — functionally an implicit --force on a one-way-door operation.

* fix(#2946): run milestone complete unstarted-phase guard independent of STATE

The entire ROADMAP phase-directory scan was nested inside
`if (stateVersion && stateVersion === version)`, so any STATE.md milestone:
value that did not exactly string-equal the version argument — a desynced
value, or no milestone: field at all — skipped the scan with no warning,
functionally an implicit --force. The operation the guard fronts is a one-way
door: ROADMAP.md and REQUIREMENTS.md are archived and phase directories are
MOVED into .planning/milestones/<version>-phases/.

The scan was already driven by the version argument through
getMilestonePhaseFilter / extractCurrentMilestone; the STATE match was a
redundant second gate that shadowed and broke it. Decouple: the scan now runs
whenever --force is absent, and a present-but-mismatched STATE milestone:
field emits a WARNING naming both values so the suspicious condition is
visible rather than silent. A fresh project with no Phase headings in the
scoped slice still yields an empty scan (no false positives) — the intent the
STATE-match short-circuit was reaching for, now achieved by the scan itself.

* docs(#2946): document milestone complete --force, --dry-run, and the unstarted-phase guard

The CLI-TOOLS reference signature omitted --force and --dry-run entirely,
and neither the unstarted-phase guard nor its override was documented
anywhere user-facing. Add a flags table and a factual guard description
to the Reference page (CLI-TOOLS.md), and a practical guard note to the
/gsd-complete-milestone How-to (COMMANDS.md) covering what to do when
the guard fires and the new STATE-mismatch WARNING (#2946).

American English per CONTRIBUTING.md language policy.

* fix(#2946): emit STATE-mismatch WARNING as JSON in --json-errors mode

Follow-up to the guard decoupling: a structured caller using --json-errors
parses stderr line-by-line as JSON, so the plain-text WARNING would break
such a parser. Honor getJsonErrorMode() and emit a structured JSON object
({ ok, level, message }) in that mode, plain text otherwise — mirroring
io.cts error()'s JSON shape. Addresses the isolated-review observation
(~45% but credible, since --json-errors is a documented CLI flag).

* fix(#2946): address review — drop JSON-mode WARNING scope creep, tighten test assertions

Standards + spec review findings (code-review two-axis + isolated adversarial):

1. The --json-errors JSON WARNING branch (commit dee719404) was scope creep
   the issue never asked for, AND emitted ok:true for a suspicious-condition
   warning (a category error — a stderr JSON parser keying on ok would treat
   the suspicious state as success), AND its comment falsely claimed to mirror
   io.cts error()'s {ok:false,reason,message} shape. Dropped: the WARNING is
   now plain-text stderr only, matching the existing [gsd-tools] WARNING
   convention (state.cts). The issue asked for 'at minimum warn', not a
   structured JSON surface.

2. The WARNING test asserted on /WARNING/ regex (raw-text matching on stderr
   prose). Tightened to assert on the stable operator-facing tokens — the
   WARNING: marker and both version literals the operator must see — not the
   surrounding formatter prose. Added a paired negative test confirming no
   WARNING is emitted for an absent milestone: field (a missing declaration
   is a normal fresh-project state, not suspicious drift).

* fix(#2946): sanitize STATE milestone value before stderr WARNING interpolation

Security review (minor): stateVersion is read from a user-controlled file
(STATE.md) and is not validated like the CLI version arg. Sanitize before
interpolating into the WARNING — strip ANSI/control chars
(/[\x00-\x1f\x7f]/g -> '?') and truncate to 80 chars — so a corrupted or
hostile STATE.md cannot echo terminal escapes or secret-looking strings
verbatim into a CI log or terminal aggregator (CONTRIBUTING.md security:
secret-looking values in stderr). version is already constrained to
[A-Za-z0-9._-] by ARCHIVE_VERSION_LABEL_RE upstream, so it needs no
sanitization.

* test(#2946): correct stale fixtures that relied on the guard being silently disabled

Four pre-existing tests broke under the #2946 fix because their fixtures
only passed thanks to the bug — the unstarted-phase guard was skipping on
STATE mismatch, so fixtures with missing or non-matching phase directories
slipped through. The tests exercise version-forwarding / version-scoping,
not the guard, so give them legitimate directories:

- milestone.test.cjs #3043: dirs were '103.old'/'104.old'/'108.new' (dot),
  which phaseTokenMatches rejects — renamed to hyphen form. The v3.6 stats
  scoping still yields 1 phase (getMilestonePhaseFilter scopes correctly);
  the guard now sees all three phases as having directories.
- milestone-archive.test.cjs 'returns version in response data': ROADMAP
  listed Phase 1 but no directory was created. Added 01-foundation so the
  scan is satisfied.

Per CONTRIBUTING.md, test-fixture corrections land as their own test:
commit, not bundled into fix: (release hotfix cherry-pick routes by prefix).

* chore(#2946): backfill changeset PR number 3081

---------

Co-authored-by: sim <sim@local>
2026-08-05 10:42:42 -04:00
Tom Boucher
e6fcf14d02 fix(#2977): tolerate a leading UTF-8 BOM before the frontmatter fence (#3076)
* test(#2977): prove extractFrontmatter returns {} on a leading BOM

Failing-first regression for #2977. extractFrontmatter's startsWith('---') byte-0
check fails on any leading byte, so a UTF-8 BOM (Windows PowerShell/Out-File, several
editors) makes every frontmatter field silently disappear. Rows 1-2 assert BOM-prefixed
frontmatter parses identically to no-BOM (incl. BOM+CRLF); Row 3 guards the no-frontmatter
negative space; Row 4 covers STATE/PLAN/SUMMARY/UAT artifact shapes; Row 5 is the control.

* fix(#2977): tolerate a leading UTF-8 BOM before the frontmatter fence

extractFrontmatter's byte-0 startsWith('---') fence check failed on any leading
byte, so a UTF-8 BOM (\uFEFF) — written by default by Windows PowerShell
`>`/Out-File (PS 5.1) and several editors — made every frontmatter field
silently disappear. STATE.md/ROADMAP.md/PLAN.md/UAT.md/SUMMARY.md all lost
phase/status/name with no error, no warning.

Strip a single leading BOM before the fence check. The rest of the function is
unchanged — the BOM is one codepoint, and removing it restores byte-0 alignment.
Negative space preserved: BOM + no-frontmatter and BOM + thematic-break Markdown
both still return {} (no false diagnostic). Scope: BOM only; the generalized
'arbitrary content before the fence' fork (tolerate vs diagnose) is a separate
product-intent decision, surfaced in the PR.

* chore(#2977): add changeset fragment

* chore(#2977): backfill changeset PR number 3076

---------

Co-authored-by: sim <sim@local>
2026-08-05 02:18:25 -04:00
Tom Boucher
7e1004d89e test(#3056): add the in-process fault-injection adapter, and normalize execGit's shape (#3077)
* refactor(#3071): normalize execGit's call and result shape, unify ExecGitFn

ExecGitFn was declared four times. Three were hand-copies of one signature and
two of those were wrong: they typed exitCode as number|null when _spawnResult
returns `result.status ?? 1` and can never yield null, weakened signal from
NodeJS.Signals to string, and widened error from Error to unknown. Only
verification.cts got it right, via `typeof execGit`.

The root cause was a missing export: SpawnResultOutput was declared without
`export`, so no other module could name the return type of execGit. Three
authors independently hand-copied it instead. Exported now.

Normalizing the type alone would have left the pressure that caused the
divergence, so the function is normalized on both sides. It now ACCEPTS every
call its consumers make — worktree-safety's declaration could not express an
env-carrying call at all — and RETURNS every result code they need: timedOut
moves into _spawnResult, so execGit, execNpm and execTool all carry it and the
one extension that justified a separate type disappears. All four sites are
now `typeof execGit` with nothing left to restate.

timedOut reuses the existing isSpawnTimeout predicate introduced by #3050
rather than re-deriving it. That predicate checks error.code === 'ETIMEDOUT'
only; the signal === 'SIGTERM' conjunct was deliberately dropped there because
Windows does not reliably report SIGTERM and requiring it risks a false
negative. There is no false-positive risk, and a test proves it: an
externally-delivered SIGTERM leaves error null, so it is still not reported as
a timeout.

No dead null-checks surfaced. Every exitCode comparison in the two affected
modules is === 0, !== 0 or === 128 — never a null guard — so the nullable
declaration had never been written against.

Closes #3071

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

* test(#3056): add the in-process fault-injection adapter

Adds tests/helpers/faulty-deps.cjs — makeFaultyGit() and withFaultyFs() — so a
module's error branch can be driven deterministically and its degraded verdict
asserted, instead of a counter-test that only proves the call did not throw.

makeFaultyGit returns a value structurally assignable to `typeof execGit`, so
one stub satisfies all four seams that the #3071 normalization collapsed into
that single shape. A parity test drives the same stub through a real injectable
entry point in each of worktree-safety, git-base-branch, worktree-base-ref and
verification; it fails the moment any of them re-grows its own shape.

Faults are scoped rather than global — by argv predicate and by call ordinal —
because a fault adapter that faults everything looks like it works and proves
nothing, and because verification.cts's two-call error handling needs to fault
the second call only. Invocations are recorded so a test can assert an exact
call count.

The timeout fault carries error.code === 'ETIMEDOUT', and a test asserts the
real isSpawnTimeout predicate matches it, so the fixture cannot drift from the
production definition of a timeout. A companion test asserts an externally
delivered SIGTERM with a null error is still NOT reported as a timeout — the
false-positive guard for #3050's dropped conjunct.

withFaultyFs restores in a finally so a throwing body still restores, patches
only the named methods, and nests without clobbering an outer saved original.
It never uses chmod: that no-ops under root, so the test would pass with zero
coverage in root Docker/CI.

The adapter is in-process via deps only. The Phase 1 process seam is documented
as deliberately not a fault-injection surface — it cannot distinguish an
injected timeout from a genuine bench OOM and would retry it.

Refs #3051

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

* chore(#3071): add changeset fragment for the execGit normalization

Refs #3051

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

* fix(#3071): route the last two timeout checks through the shared predicate

An isolated review found this branch had normalised the timeout verdict but
left two callers still hand-rolling the fragile version of it.

check-command-router's runBoundedShell computed `timedOut: r.signal ===
'SIGTERM'` while the correctly-derived `r.timedOut` sat on the same result
object. graphify's execGraphify branched on `result.signal === 'SIGTERM'`, with
a comment asserting the very premise isSpawnTimeout exists to reject.

Both fail in both directions. On Windows a genuine timeout is not reliably
reported as SIGTERM, so the guard silently fails to fire — the false negative
#3050 was raised for. And an externally-delivered SIGTERM is not a timeout at
all, so the check also fires when it should not; isSpawnTimeout avoids that
because `error` is null in that case and it keys on error.code.

Both now read the derived `timedOut`, and graphify's comment states the actual
rule instead of the fragile assumption.

Also replaces a vacuous test: "execGitDefault now accepts env" never called
execGitDefault (it is unexported), called execGit — whose signature already
accepted env before this branch — and asserted only that exitCode was a
number, which would pass whether or not the change under test existed. It now
proves env reaches the child by asserting `git var GIT_EDITOR` returns the
injected sentinel.

Refs #3051

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

* test(#3071): make the graphify timeout fixture faithful to a real timeout

The remote matrix failed on both Linux lanes: graphify's "returns exitCode 124
on timeout" got 1 instead of 124. The fixture was wrong, not the production
change.

It stubbed spawnSync as { status: null, signal: 'SIGTERM', error: undefined }.
That is not a timeout. A real spawnSync timeout also sets error.code
'ETIMEDOUT'; a SIGTERM with no error is an externally delivered signal — a
kill. The old `result.signal === 'SIGTERM'` check accepted it as a timeout,
which is the false positive the shared predicate exists to reject, so this test
was locking that bug in rather than guarding against it.

The fixture now carries a real ETIMEDOUT error and all three original
assertions pass unchanged. A counter-test is added alongside it: an externally
delivered SIGTERM with no error must NOT be reported as a timeout. That is the
assertion whose absence let the false positive live.

Swept every SIGTERM/SIGKILL stub under tests/ for the same unfaithful shape.
No other instance: the worktree-safety, worktree-base-ref and commit-staging
fixtures already set ETIMEDOUT, and the remaining hits are either deliberate
external-kill tests or feed code that never consults timedOut.

Two sites keep their own signal check deliberately and are NOT changed:
capability-source.cts:1301,1386 fail closed on ANY abnormal termination, which
is correct — reading timedOut there would stop it failing closed on a kill and
let it parse stdout from a killed process. Their reason strings, and
check-latest-version.cjs:115, label any signal as "timed out", which is
imprecise wording over a correct verdict, not a silent failure.

Refs #3051

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

* chore(#3071): backfill changeset pr number to 3077

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 02:14:41 -04:00
Tom Boucher
59b74c4e7d fix(#2945): roll back the REQUIREMENTS checkbox when the traceability row rejects the write (#3073)
* test(#2945): prove phase complete checkbox ignores traceability row rejection

Failing-first regression for #2945. cmdPhaseComplete flips the REQUIREMENTS.md
checkbox unconditionally and never rolls back when the traceability row exists
but rejects the Status write (Deferred/Blocked), so a deferred requirement reads
as shipped. Rows 1-2 assert the checkbox stays [ ] for Deferred/Blocked; Row 3
guards the forward-status flip; Row 4 covers the no-row boundary.

* fix(#2945): roll back the REQUIREMENTS checkbox when the traceability row rejects the write

cmdPhaseComplete's inline requirement-write loop flipped the REQUIREMENTS.md
checkbox unconditionally and kept the flip when the traceability row existed but
rejected the Status write (Out/Deferred/Blocked), so a deferred requirement read
as shipped — the #2788 defect-2 fix was written into cmdRequirementsMarkComplete
(milestone.cts) only, never the phase.cts inline copy.

Port the rollback: capture beforeCheckbox, track tableHit in the
updateTraceabilityCell callback, and when reqUpdate.ok && !tableHit (row existed
but rejected the write), restore beforeCheckbox. The two surfaces can no longer
silently diverge. Forward-status rows (Pending/In Progress/Gaps Found) still flip
+ advance; absent rows still flip (nothing to disagree with).

* chore(#2945): add changeset fragment

* chore(#2945): backfill changeset PR number 3073

---------

Co-authored-by: sim <sim@local>
2026-08-05 00:45:03 -04:00
Tom Boucher
c97f5debb9 fix(#2949): exclude sentinel phase ids from stage-3 next-phase candidacy (#3070)
* test(#2949): prove phase complete stage-3 admits 0.x backlog sentinels

Failing-first regression for #2949. cmdPhaseComplete's stage-3 lowest-outstanding
loop has no sentinel filter, so completing the last real phase with an unchecked
0.x backlog row present selects the sentinel as next_phase, corrupting STATE.md.
Row 1 asserts the 0.x sentinel is not selected; Row 2 guards the #2028 real-lower-
phase out-of-order behavior; Rows 3-4 cover STATE desync and the checked-sentinel
boundary.

* fix(#2949): exclude sentinel phase ids from stage-3 next-phase candidacy

cmdPhaseComplete's stage-3 lowest-outstanding-override loop (#2028) had no
sentinel filter, so completing the last real phase with an unchecked 0.x backlog
row present selected the sentinel as next_phase — comparePhaseNum("0.1","12")
=== -12 sorts it below every real phase — corrupting STATE.md and desyncing
current_phase from current_phase_name.

Add !isSentinelPhaseId(cbm[2]) to the stage-3 condition, reusing the existing
zero-caller predicate (SENTINEL_RANGES = [0, 999]) so both sentinel ranges are
excluded. A real lower-numbered outstanding phase is not a sentinel and is still
selected, preserving #2028's out-of-order-completion behavior.

Stage-3 only: PR #2815 (in-flight, #2786) covers stages 1-2; the two PRs touch
disjoint code.

* fix(#2949): compare next_phase numerically in Row 2 (handles padded/unpadded)

Row 2's assertion /09/.test(next_phase) failed because the CLI returns the
unpadded "9", not "09". Compare numerically (parseInt === 9) so the assertion
holds for either form.

* fix(#2949): mark Phase 11 complete in Row 1 so only the 0.x sentinel is unchecked

Row 1's fixture left Phase 11 unchecked, so completing Phase 12 correctly
selected Phase 11 as the real lower outstanding phase (is_last_phase=false) —
the assertion is_last_phase===true was wrong for that fixture, not the code.
Mark Phase 11 [x] so the ONLY unchecked lower row is the 0.x sentinel, which is
the actual #2949 scenario. Confirmed locally: with Phase 11 checked + the fix,
completing 12 yields is_last_phase=true, next_phase=null (sentinel excluded).

* chore(#2949): add changeset fragment

* chore(#2949): backfill changeset PR number 3070

---------

Co-authored-by: sim <sim@local>
2026-08-04 23:58:49 -04:00
Tom Boucher
8f75e27554 fix(#3045): fail closed when an executor dispatch drops its resolved isolation (#3069)
* feat(#3045): deny an executor dispatch that drops its isolation flag

Every isolation gate already resolved correctly. The resolved value then reached
the executor through a prose instruction telling the model to substitute it into
a call the model composes itself, and nothing verified the substitution. When it
was dropped, the executor edited and committed in the user's primary checkout
with no consent and no warning.

A prose backstop would be the same class of artifact as the defect, so this is a
shipped PreToolUse hook on the Agent tool. It fires at the instant of the call
rather than being read once at the top of a workflow, which is the only placement
the model cannot skip.

The guard is inert unless it can positively establish that this is a GSD project,
that the project resolves to harness isolation, and that the dispatch targets an
executor. A non-GSD repo has no invariant to enforce. Where it cannot read the
configuration at all, it denies rather than assuming, with its own reason -- a
guard that cannot verify must not answer safe. A malformed payload allows rather
than throwing.

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

* feat(#3045): extend the isolation guard to Cursor

Cursor is the second of only two runtimes that resolve harness isolation, so
shipping the guard for Claude alone left half the exposed surface unguarded while
the changeset implied it was covered.

The two runtimes fail differently. On Claude the harness flag is a per-dispatch
kwarg the model must copy into a call it composes, and the defect is that it can
be dropped. On Cursor the flag is --worktree, which applies to the whole session,
and the subagent-start payload carries no isolation field at all. There is no
flag to check, so the guard verifies the effective state instead: whether the
workspace is genuinely running outside the user's primary checkout. That is a
stronger check than the Claude one because it tests reality rather than intent,
and it is commented so nobody later rewrites it into a flag check.

Isolation is established two ways, either sufficient: the workspace resolves to a
linked git worktree, or it sits under the worktree root Cursor manages. The
second matters because a directory Cursor placed there is a legitimate isolated
session even before it becomes a distinct git worktree, where linkage alone would
report no repository.

Detecting linkage required a new primitive rather than the existing context
resolver. That resolver short-circuits on finding a local .planning directory
before it ever compares the git directory to the common one -- and an isolation
worktree normally has its own checked-out .planning. Reusing it would have read a
correctly isolated session as unisolated and denied it, which is the failure
direction that gets a guard switched off. The comparison is now its own
shortcut-free function that the resolver delegates to after its own shortcut, so
existing behavior is unchanged, and the case that would have broken is pinned.

The subagent type is checked before any configuration is read, so an unreadable
config cannot deny a dispatch this guard would never have enforced against.

The input-schema comment on the Cursor hook documented only the fields common to
every event and omitted the ones specific to this one. That omission cost a
halt during this work; it now documents both.

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

* fix(#3045): enforce the resolved dispatch decision, not the host capability

The guard keyed on the registry's dispatch.isolation, which says only that a
runtime is CAPABLE of harness worktrees. The decision that actually governs a
dispatch is the one the workflow resolves after gating, and that legitimately
comes out as sequential in three documented cases: a project setting
use_worktrees false, a per-plan submodule intersection, and the base-check
auto-degrade. The workflow tells the model to omit the flag in exactly those
cases, and the guard was denying every one of them.

The third case matters most. The preceding fix made the base-check degrade on
git timeouts and a missing git binary, where it had previously answered "safe".
That correction is right, and it means a transient hang now degrades to
sequential far more often than before -- so the two changes composed into a trap
where the workflow behaved exactly as designed and the guard blocked it.

The workflow already resolves isolation in shell, deterministically, which is
what makes it a trustworthy source in a way the model-authored call is not. It
now records that resolved value through a dedicated verb, and both guards read
it first. A fresh record is authoritative, so sequential dispatches pass
untouched. Absent or stale, the guards fall back to the capability check
combined with the project's use_worktrees setting, which still covers the case
that never reaches the workflow.

Also widened the matcher to accept Task alongside Agent, since a host that names
the tool Task would otherwise leave the guard silently inert while implying
coverage; stopped assuming Claude when no runtime is declared, which is the
shipped default and would have demanded a Claude-only argument elsewhere; and
made a non-git project inert rather than denied, since advising a worktree
session is not actionable without a repository.

The original diagnosis never modeled sequential mode as legitimate. That
omission is what let this through, and it is now recorded there.

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

* fix(#3045): record at resolution and bind the record to its dispatch

Two independent reviews converged on the same failure: the guard was fail-open in
a default install, so it did not catch the defect it exists to catch. A shipped
project carries no runtime key, which made "runtime not confidently known" the
common case rather than a corner one. A record asserting that isolation was
required but carrying no flag then fell through to a capability lookup that
answered "none", and the dispatch was allowed. The flag itself only arrived from
a second shell block -- the same block a model dropping the argument would also
skip. A test had pinned that behavior as intended.

The record is now written by the resolver, as an unavoidable consequence of
asking for the value, rather than by a step the model is told in prose to go and
run. A guard against a prose-carried value cannot itself depend on prose. Mode,
flag and identifiers are written together and atomically, so the flagless window
is gone, and a record asserting isolation with no resolvable flag now denies
instead of degrading. Runtime is also resolved from the installer's own recorded
default, which makes confident resolution the normal case.

The per-plan submodule gate degrades after the phase-level decision and never
re-recorded, so a plan that legitimately ran sequentially was denied against a
still-fresh phase record. It now records its own, scoped to the plan.

A record also authorized any dispatch for four hours. One phase degrading to
sequential could silently license an unisolated dispatch in the next. Records
now carry phase and plan, the guards require them to match, and the window is
minutes rather than hours -- the resolver rewrites it before every dispatch, so
a long window bought nothing and only widened the hole.

The flag validator rejected any value beginning with two dashes, which is exactly
the form Cursor and Windsurf declare, so their real value could never have been
stored. Writer and reader also derived the record path differently and diverged
inside a linked worktree without local planning state.

The predictable path remains a way to silence the control without leaving a trace
in the diff. It grants no access an agent with shell does not already have, so it
is documented as accepted rather than redesigned around.

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

* fix(#3045): correct the staleness boundary and unmask a vacuous parity test

The remote runner returned twenty failures. One was a real production defect the
boundary case existed to catch: a record whose age exactly equalled the staleness
window was treated as fresh, so it stayed authoritative for one tick past its own
expiry. Freshness is now strictly inside the window.

The parity test meant to stop the two guards' executor lists from drifting could
never have failed. Its project fixture was a bare directory rather than a
repository, so the non-git inert branch answered before the executor list was
ever consulted. It asserted agreement it never actually measured. The fixture is
now a real repository, like every sibling in the file.

A test also asserted that Windsurf declares the worktree flag. It does not --
Windsurf resolves to no isolation by design, having no named concurrent dispatch
to isolate. The test claimed a registry fact that was never true, and a comment
in the resolver repeated it. Both corrected, and the test now proves what it
should have all along: that the parser accepts any bare flag value, rather than
one runtime's supposed value.

The new guard was missing from the bundled-hook whitelist, which is the surface
that decides what actually ships, and the per-plan gate had gained calls to the
launcher without the preamble those calls require. The changeset carried
parenthetical product descriptions the purity rule forbids.

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

* chore(#3045): backfill changeset pr number

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

* test(#3045): make the guard tests hold on Windows

Two tests redirect HOME to control where the installer-persisted runtime default
is read from. Node resolves the home directory from USERPROFILE on Windows and
never consults HOME, so both silently read the real runner profile, found no
recorded runtime, and asserted against a project the hook had not recognised. The
production code was already correct in asking the platform rather than the
variable; only the tests were wrong to assume one variable answers everywhere.
The helpers now mirror the override onto both.

The symlink spoofing test also created a directory symlink unconditionally, which
needs elevated privileges on Windows. It survived on this runner, but it would
fail on any host without them, so the creation is now attempted and the test
skips explicitly when it cannot be done -- a bare return would have counted as a
pass and hidden the gap.

Skipping alone would have left the platform uncovered, so the behaviour it proves
is now also driven in-process through an injected realpath, following the seam
already used for the clock. That case no longer depends on privileges at all, and
the end-to-end test keeps its original assertions wherever symlinks work.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 23:42:16 -04:00
Tom Boucher
c899f5ada3 chore(#3065): build the deterministic load-bearing-fragment contract gate (#3068)
* test(#3065): build the load-bearing contract gate ADR-1671 promised

Epic #1671 Phase 7. A post-merge audit of every promise in ADR-1671 against the
merged tree found one mitigation asserted-but-absent and two stale records.

ADR-1671 names exactly one correctness risk — trimming a load-bearing fragment,
with the recorded history of a paraphrased META.RULE causing agent violations —
and #2931 amended its mitigation to a deterministic contract gate that proves no
load-bearing fragment was omitted or shrunk, treats a floored fragment as a
success, and asserts the isolate prefix survives byte-identical, with an explicit
anti-vacuity rule.

That gate did not exist. What existed was tests/context-composer.test.cjs:
synthetic unit tests of the composeWithinBudget primitive over invented
fragments, asserting nothing about real declared strategies. The ADR asserted a
mitigation that was never built, which is the promised-but-not-built shape the
epic's own coverage discipline exists to catch.

The gate derives its load-bearing set from declared verbatim strategies rather
than a hand-maintained list, so it cannot go stale as upstream changes. It sweeps
budgets from 4x total down to a quarter of total and asserts at every step that
no load-bearing id appears in omitted or shrunk, that isolatePrefix is
byte-identical, and that hardFailed is surfaced rather than silently passed.

Both anti-vacuity guards are EXECUTABLE, not comments. One proves the empty
load-bearing set guard actually throws. The other proves a sweep that never
applies pressure is rejected — because a gate that only ever runs unpressured is
exactly how the original mitigation went missing without anyone noticing.
Measured: underPressure true at 6 of 7 budgets, false only at 4x total.

Three ADR records corrected in the same change, all doc-vs-reality drift:

  - Decision item 2 describes a composer that trims by priority to fit a measured
    per-runtime cap. composeWorkflow in fact passes MAX_SAFE_INTEGER with every
    fragment verbatim (both verified in source), so no trimming happens there;
    the emitted-byte cap is a separate measure-and-fail gate and Windsurf's limit
    a bespoke truncation. The wording described an option as shipped behavior.
  - flag:--converge never reached a terminal state. #2992 withheld six atoms;
    five were resolved explicitly. This one was resolved in code by reusing
    state:plan-strategy-converge but recorded nowhere — the same gap #2995 closed
    for flag:--verify-only, and I closed five of six.
  - The open-questions list enumerated three questions while two Resolved-by
    blocks resolved an unlisted Question 4. It is now listed.

Refs #3065

* fix(#3065): make the gate assert over production, not a copy of it

The isolated review found a blocker, and it was fatal to the gate's purpose: it
hand-copied applyBudget's fragment array into the test, so flipping a strategy in
src/prompt-budget.cts — say roadmap from verbatim to drop — would leave the gate
computing from its own untouched copy and still passing. A guard built as an
instance of the very divergence class it exists to prevent
(DEFECT.GENERATIVE-FIX) is worse than no guard, because it reports green.

Fixed by eliminating the duplicate rather than adding a parity assertion, the
same resolution used for the FAMILIES table in #2996. applyBudget's inline
construction is extracted to an exported buildBudgetFragments(), which both
applyBudget and the gate now call; the 1024 plan floor is exported as
PLAN_FLOOR_CHARS instead of being re-declared in the test. The extraction is pure
— verified behavior-preserving at budget=2000: hardFailed false, omitted
['context'], projectMd shrunk, plan truncation ~27.8%, all headers present. There
is no longer a second copy to diverge from.

Also fixed a vacuous assertion the same review caught: isolatePrefix was pinned
across the sweep, but no production fragment sets isolate:true, so the value is
always '' and the check could never fail. The pinning assertion stays, with an
honest comment that nothing in production sets it today, and a second test now
constructs an isolate:true fragment set and proves the prefix is non-empty and
byte-identical across a roomy and a severely tight budget — which is what makes
the first assertion capable of detecting a real change.

Refs #3065

* chore(#3065): backfill changeset pr number to 3068

---------

Co-authored-by: sim <sim@local>
2026-08-04 22:41:01 -04:00
Tom Boucher
83a26ed1dc fix(#2939): honor the declared depth budget in shouldFlattenDispatch (#3063)
* test(#2939): prove shouldFlattenDispatch ignores the depth budget

Failing-first regression for #2939. shouldFlattenDispatch checks only
background+backgroundDispatch, never nested/subagentToolkit/maxDepth, so a
maxDepth:1 descriptor (no room for a bg orchestrator plus a leaf) is told it
may background. Row 1 (codex-like, maxDepth:1) asserts true (flatten) and
fails today; rows 2/3 guard the unchanged depth-sufficient cases.

* fix(#2939): honor the declared depth budget in shouldFlattenDispatch

shouldFlattenDispatch checked only background+backgroundDispatch, never
nested/subagentToolkit/maxDepth, so a maxDepth:1 descriptor (no room for a
backgrounded orchestrator plus a delegated leaf) was told it may background —
producing a depth-2 tree (Codex MultiAgent V2) the declared contract cannot
support.

canBackground now ALSO requires nested:true + subagentToolkit:"full" + a depth
budget > 1 (or unbounded -1), reusing the exact predicate shape from
bin/install.js _normalizeDispatchCallSpan and matching degradationFor's
treatment of maxDepth===1 as flat. Non-finite/missing maxDepth fails closed to
flatten. Correct the two existing pins that asserted the buggy output (bare
{bg,bgDispatch} now fail-closes on missing depth; the codex-like maxDepth:1 pin
flips to flatten) and add a maxDepth:2 negative-space row.

* fix(#2939): propagate depth-aware flatten to all pinned descriptors + tests

The isolated adversarial review found the depth-aware predicate reclassifies
codex/kimi/kimi-code (previously background-eligible under the two-field rule)
to flatten — the correct behavior, since each lacks what a backgrounded nesting
orchestrator needs:

  - codex: maxDepth:1 (no room for a depth-2 leaf)
  - kimi: nested:false (cannot host a nesting orchestrator)
  - kimi-code: subagentToolkit:'built-in-only' (cannot delegate to full subagents)

Only cursor (maxDepth:2) remains background-eligible. Update the three test
files that pinned the old contract (host-integration-descriptors EXPECTED_FLATTEN,
kimi-upgrades UPGRADE 2, trae-imperative-reference), and align the unbounded
convention to maxDepth < 0 (matching degradationFor/negotiateHostCapabilities)
with an accurate docstring noting the deliberate nested-check addition over
_normalizeDispatchCallSpan.

* fix(#2939): update dispatch-should-flatten CLI query pins for codex

The depth-aware rule (a0ad0f680) reclassifies codex (maxDepth:1) to flatten, but
command-routing-hub.test.cjs exercises the contract through the CLI query route
(runGsdTools query dispatch-should-flatten), not a direct shouldFlattenDispatch
call — so neither the reviewer's caller-search nor a grep for the symbol found
it; only the full gsd-test matrix did. Update the codex query assertions to
shouldFlatten=true (maxDepth:1 insufficient), preserving cursor (maxDepth:2 →
false) and the backgroundDispatch:true descriptor field.

* chore(#2939): add changeset fragment

pr:0 placeholder backfilled with the real PR number once the PR exists.

* fix(#2939): rephrase changeset for product-name-purity + opencode flatten pin

Two failures from the full gsd-test matrix on the prior sha:

1. product-name-purity: changeset fragments must not include parenthetical product
   descriptions (they render verbatim into CHANGELOG.md). 'Codex (and kimi/kimi-code)'
   tripped it — rephrase to lead with the behavior, naming runtimes inline without
   the parenthetical. lint:ci changeset-lint does not catch this; only the test does.

2. opencode-imperative-reference: the #2087-retraction pin flipped only the two
   background booleans and asserted shouldFlatten:false. Under #2939 that is no
   longer sufficient (opencode lacks nested + full toolkit + depth budget), so the
   retracted axes now correctly flatten — update the pin to true with rationale.

* chore(#2939): backfill changeset PR number 3063

---------

Co-authored-by: sim <sim@local>
2026-08-04 20:33:50 -04:00
Tom Boucher
c547e73a71 fix(#2927): merge installed overlay reviewer lanes into review-lane invocation (#3062)
* test(#2927): prove overlay reviewer lanes are invisible to review-lane

Failing-first regression for #2927. routeReviewLane builds its lane map from
the static REVIEWER_LANES array only, so an installed overlay reviewer lane
(role:"reviewer" capability) is roster-visible and disclosed at install but
never selectable, plannable, or invocable. The test exercises a pure
mergeReviewerLanes(firstParty, registry) helper that does not exist yet, so
every row fails at the require().

* fix(#2927): merge installed overlay reviewer lanes into review-lane invocation

routeReviewLane built its lane map exclusively from the frozen first-party
REVIEWER_LANES array, so an installed, consented third-party reviewer lane
(role:"reviewer" capability) was roster-visible and disclosed at install but
never selectable, plannable, or invocable — sections/flags/plan/invoke all
shared the one static map.

Add a pure, total mergeReviewerLanes(firstParty, registry) helper
(src/review-lane-descriptor.cts) implementing ADR-2782 D8: first-party ∪
installed overlay reviewer bodies, first-party winning on slug collision. The
overlay body is field-identical to ReviewerLane per ADR-2782 D1 ("no
translation layer"), so the helper MERGES rather than PROJECTS. Malformed
overlays (missing/non-object body, empty or grammar-invalid slug) are skipped,
never thrown — one bad third-party manifest cannot take the first-party lanes
down. routeReviewLane consults loadRegistry({includeInstalled:true}) and
degrades to the static set on any load failure.

* test(#2927): add CLI-seam coverage for the wiring defect + normalize slug

Two findings from the isolated adversarial review:

1. The test matrix's rows 9-10 (acceptance criteria #1-#3: overlay appears
   in sections/flags and plan resolves ok) were documented as covered but
   had no backing tests. The eight pure-helper tests would stay green if the
   one-line routeReviewLane wiring were reverted — the actual defect this PR
   closes had no regression guard. Add real end-to-end CLI tests that install
   a global-scope role:"reviewer" overlay and assert review-lane
   sections/flags/plan see it through loadRegistry -> mergeReviewerLanes.

2. mergeReviewerLanes trimmed the slug for the map key but stored the body
   with its untrimmed slug, diverging from deriveReviewerSlugs (which trims
   before adding to the roster). Normalize the stored lane's slug to the
   trimmed value so the two surfaces agree on the canonical key.

* test(#2927): correct CLI-seam fixtures for reviewer manifest shape

Two corrections from local CLI smoke-testing before the verification run:

1. role:"reviewer" manifests must omit feature-only fields (skills/agents/
   steps/contributions/gates/hooks/runtimeCompat) — the validator rejects them.
   Match the shipped capabilities/lm-studio shape.

2. The plan subcommand renders an ARRAY of {slug,ok,section,transport,...}
   (it strips the nested invocation plan object), so assert on the array
   element, not a top-level object. Also drop the malformed-flag-filter
   assertion: the capability validator enforces flag grammar at install time,
   so a lane with a malformed flag cannot be installed and never reaches the
   flags shape filter (which is defense-in-depth, not independently reachable).

* fix(#2927): drop unnecessary type assertion flagged by lint:ci

The `body as object` cast inside the spread is redundant — body is already
narrowed to object by the preceding typeof check. eslint no-unnecessary-type-
assertion flagged it; lint:ci is a merge gate.

* chore(#2927): add changeset fragment

pr:0 placeholder backfilled with the real PR number once the PR exists.

* fix(#2927): access runGsdTools result via .output in CLI-seam tests

runGsdTools returns {success, output, exitCode, error}, not a string. The CLI
tests (rows 9-10) passed the result object directly to JSON.parse/.split, which
string-coerced to "[object Object]" and threw under gsd-test (3 failures). My
local smoke test ran the CLI directly (string stdout), so it missed this — the
helper wraps execFileSync and returns a result object. Access .output and assert
.success explicitly, matching the established capability-cli.test.cjs convention.

* chore(#2927): backfill changeset PR number 3062

---------

Co-authored-by: sim <sim@local>
2026-08-04 18:59:19 -04:00
Tom Boucher
ed360cd99f chore(#2995): extend fragment emission to agents/ and reclaim size-cap headroom (#3058)
* feat(#2995): extend fragment emission to agents/ across every read point

Epic #1671 Phase 6.4. `composeWorkflow` stripped `<!-- gsd:section -->` markers
only for `gsd-core/workflows/`, so a marked agent shipped its markers verbatim
into every runtime — and agent text is loaded into a subagent's context on every
dispatch.

The issue proposed widening the `copyWithPathReplacement` guard. That is a no-op
for agents: agents never traverse that function. Agent content is read for
emission at five independent points, and the obvious chokepoint
`stageAgentsForProfile` short-circuits on the DEFAULT `full` profile
(`skills === '*'` returns the real unstaged directory), so a hook placed there is
dead code on most installs.

Composition now happens at two call sites instead of five parallel surfaces:
`stageAgentsForRuntimeWithConverter` (with `agentsKind` and `kimiAgentsKind`
routed through it via an identity converter) and the inline agent loop in
bin/install.js. Both compose BEFORE any path rewrite, so a `.claude/` ->
`.windsurf/` regex can never reach inside a marker attribute — the ordering
#2930 established for workflows.

`installCodexConfig` was the fifth read point: Codex embeds each agent's prompt
into a per-agent `.toml` via its own readFileSync. Call-graph analysis missed it;
the exhaustive per-runtime emission sweep found it. That is why the new guard is
behavioral rather than structural — a sixth read point fails the sweep without
anyone remembering to extend a list.

tests/agent-fragments-emission.install.test.cjs spawns a real installer for every
runtime at every agent-bearing scope, derived from RUNTIME_META and the
capability registry at run time so a new runtime cannot be silently
under-covered. It asserts markers are absent AND the `when="always"` body is
retained, so marker-absence cannot be satisfied by dropping content. An
identity-composer negative control proves the assertion can fail.

Verified: 0 install failures, 0 marker leaks, body retained on 27 runtime/scope
paths; red before the wiring on claude(global+local), zcode(global+local),
kimi, codex and opencode.

Refs #2995

* chore(#2995): give the tightest agents headroom and correct the design lock

Epic #1671 Phase 6.4, second half.

`agents/gsd-verifier.md` had 12 bytes of headroom under its 49,152-byte LARGE
cap and `agents/gsd-debugger.md` had 147 under its 57,344-byte XL cap. Both now
extract reference material to `gsd-core/references/` behind an @-reference — the
documented DEFECT.AGENT-FILE-SIZE-CAP-BREACH remedy:

  gsd-verifier  49,140 -> 46,371 B   headroom    12 -> 2,781
  gsd-debugger  57,197 -> 48,851 B   headroom   147 -> 8,493

Byte accounting proves no content was lost: the combined agent+reference delta
is exactly the new files' headers plus the agents' slim replacement blocks. Each
agent keeps its routing table and a one-line summary per entry, so it degrades
gracefully on a runtime that does not inline @-references.

`agents/gsd-planner.md` is untouched and still passes both char guards
(49,130 < 49,152); it needed no change, so it took none.

The other nine LARGE/XL agents carry NO gsd:section markers, and that is
deliberate, not deferred. `when=` selection is read from
gsd-core/workflows/section-manifest.json, which gen-section-manifest.cjs derives
from gsd-core/workflows/*.md only — shape `{workflows: ...}`, no per-agent key,
no per-agent init entry point. An agent atom therefore fails admission gate (2)
("a fact the init seam demonstrably computes at a real entry point") and would
evaluate false forever while looking like working gating. Marking agents would
manufacture exactly the silent-inertness rot the frozen vocabulary exists to
prevent.

ADR-1671 gains three amendments, two of which close gaps /adr-phase-coverage
found against what actually merged:

  - The 19 -> 29 vocabulary widening shipped in #2994 with no coordinated ADR
    amendment, which that bullet's own rule forbids. Recorded now.
  - `flag:--verify-only` was one of six atoms #2992 withheld and deferred to
    "the LARGE/XL rollout phase". Five shipped; this one is permanently
    rejected, and that disposition lived only in a merged PR body.
  - Phase 6.4's own finding: emission extends to agents/, gating does not.

CONTEXT.md's glossary was stale on both seams — Workflow Fragments Module still
listed the original 4-atom vocabulary and described when= as "not yet acted on",
and Section Manifest Module still described InvocationFacts as
{waveFlag, phaseNumber, hasPriorPhases}. Both now match the shipped contract.

Inventory manifest regenerated AFTER build:lib per the documented ordering
landmine; 19 install-tree fixtures pick up the two new references.

Refs #2995

* chore(#2995): correct the compose-site count and mark the raw stager

Self-review found two comment defects in the prior commit. The agentsKind
comment claimed composition lands at TWO call sites; it is three, since
installCodexConfig's per-agent .toml writer was added after that comment was
written. And stageAgentsForProfile is now production-dead — both callers route
through the composing stager — while staying exported and unit-tested, which
makes it a trap: it does a raw copyFileSync and short-circuits to the unstaged
source directory under the default profile, so a future caller would silently
reintroduce the marker-shipping path. Its JSDoc now says so.

* test(#2995): guard the marker-documenting-doc class for agents

Widening the composer's scope to agents/ makes reachable the exact class #2930
narrowed scope to avoid: a file that DOCUMENTS the marker syntax with an
unfenced example is indistinguishable from a real marker, so the composer drops
that line from the emitted artifact.

Three rows. A fenced example must compose byte-identically. No shipped agent may
carry a marker outside a fence — asserted by parsing every real agent and
requiring zero explicit sections, which is what makes the fence protection
load-bearing rather than decorative. And a non-vacuity row asserts an UNFENCED
marker IS parsed as a real marker, so if that ever stops being true the second
row is guarding nothing.

Also applies two review findings: stageAgentsForProfile's new JSDoc claimed it
had no production caller, which is false — bin/install.js's _stageAgents still
calls it, and its consumers compose before writing. Corrected to state the
invariant instead. And a let/const nit in the emission sweep.

* fix(#2995): keep verifier status vocabulary in the agent, fix a wrong fixture

The first remote run came back red with three failures. Both root causes were
mine.

1. tests/agent-frontmatter.test.cjs requires agents/gsd-verifier.md to literally
   contain HOLLOW and DISCONNECTED. The Step 4b extraction moved that status
   vocabulary into gsd-core/references/verifier-wiring-patterns.md, so the agent
   no longer had it.

   Byte accounting said no content was lost, and byte-wise that was true — but a
   contract required those tokens to live IN THE AGENT. That is ADR-1671:66's
   flexReserve floor stated concretely: a load-bearing fragment must not be
   trimmed out of its host, and "the bytes still exist somewhere" is not the
   test. The two status tables are restored to the agent and deliberately
   mirrored in the reference with a note saying so, so the procedure there still
   reads standalone. gsd-verifier lands at 47,069 B — headroom 12 -> 2,083,
   rather than the 2,781 the first attempt claimed.

2. Row 12b of the new marker-documentation guard asserted that an unfenced
   marker example parses as a real marker, and threw instead:
   "unmatched /gsd:section close marker". The grammar is WHOLE-LINE only. The
   fixture had put the OPEN marker inline mid-sentence, so it was correctly not
   recognised as an open while the close, on its own line, was.

   That is a real refinement of the hazard this guard exists for: only a marker
   on its OWN line is mis-parsed — which is exactly how a documentation example
   is normally written. Row 12b now uses a whole-line marker, and a new row 12c
   pins the inline case as explicitly NOT a marker.

No test was weakened to accommodate the change; the change was corrected to
satisfy the tests.

Refs #2995

* chore(#2995): backfill changeset pr number to 3058

---------

Co-authored-by: sim <sim@local>
2026-08-04 18:10:31 -04:00
Tom Boucher
4eb8e3648c fix(#3050): consolidate the spawn-timeout predicate and propagate the unresolved-root reason (#3060)
* chore(#3050): changeset and review artifacts for the follow-up

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

* chore(#3050): backfill changeset pr number

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 17:21:24 -04:00
Tom Boucher
24066e536e fix(#3050): fail closed when a worktree guard cannot verify safety (#3054)
* fix(#3050): fail closed when a worktree guard cannot verify safety

Three places answered "safe" when they had not actually checked.

The base-divergence gate held the clearest evidence against itself: within one
function, an unresolvable fork ref correctly degrades, while an unresolvable
HEAD twenty-five lines earlier returned "proceed". Because a timeout collapsed
into the same branch as "not a git repository", a locked index or a stalled
mount produced a green gate that had never resolved the fork base -- and that
value decides parallel versus sequential dispatch.

Timeouts are now distinguished from a genuine absence of a repository. A
timeout degrades with its own reason and message; not-a-git-repo keeps today's
non-degrading behavior, because there is no worktree concern there. The same
conflation in worktree-context resolution is surfaced rather than silently
falling back to the current directory.

Worktree creation's root confinement was opt-in: omitting the root skipped the
check entirely, leaving only the leading-dash and parent-segment guards. The
sole caller always passed it, so nothing was exploitable -- it is now mandatory
so a future caller cannot inherit an unconfined path by forgetting.

The timeout predicate was checked against what Node actually emits on a
spawnSync timeout, not only against the fixtures, so it cannot be a guard that
fires solely in tests.

Coverage is deliberately behavioral. The existing worktree suites -- 134 tests
across two files -- require no production module and call no production
function; they assert against prose and would pass with the implementation
deleted. That is how three fail-open guards survived in a heavily-tested
module, so the new tests drive the real resolvers through an injected git seam,
with five of them pinning the paths that must NOT change.

One existing test asserted the opt-in confinement behavior and was rewritten
rather than left green against the corrected code.

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

* test(#3050): stop a CLI exit code leaking into the test process

The runner reported the new file as failed while the file's own summary said
nine tests passed and none failed. That signature is a non-zero process exit
after a green run, not a failing assertion.

Cause: the confinement test calls the worktree-create command function directly,
and that function sets process.exitCode on its failure path as a CLI would. In
process, that exit code became the test file's own exit status.

The sibling suite already guards this with a save/restore wrapper and a comment
naming the hazard; the new file simply did not follow the convention. It does
now.

Root cause is in the test, not the production code -- setting an exit code is
correct behavior for a command entry point, and the existing convention exists
precisely because tests call these functions in process.

Verified by exit-code and active-handle probes rather than by re-running: exit
was 1, is now 0, with zero lingering handles and all nine tests still passing.

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

* chore(#3050): backfill changeset pr number

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 16:18:21 -04:00