`cmdVerifyKeyLinks` compiled `must_haves.key_links[].pattern` from plan frontmatter with `new RegExp()` and tested it against whole file contents, so a nested-quantifier pattern such as `(a+)+$` hung `verify-phase` indefinitely (CWE-1333). JavaScript has no regex-execution timeout.
Untrusted patterns now run on RE2 (re2js), whose match time is linear in input length — the class is closed by the engine, not by a heuristic screen. The screen lost in the ADR-0174 consolidation was deliberately NOT restored: it never worked, since `(a|a)*$`, `((a+))+$`, `(a+){2,}$` and `(a{1,3})+$` all evade it. A refused pattern's matcher returns false for every input, so it cannot report a match no matter what the caller does.
The engine is vendored at gsd-core/bin/lib/vendor/re2js.cjs because gsd-core/bin/** is copied into installed trees with no node_modules; runtime dependencies are unchanged. New ESLint rule local/no-external-require-in-bin enforces that invariant, which had been documented in a comment since the #3024/#2071 bug class and enforced nowhere.
Backreferences and look-around are unsupported by RE2 by construction — disclosed in a Changed changeset.
Closes#3477
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
RegExp.escape is ES2026 (first shipped in Node 24); pattern.cts called it
unconditionally, and the build consumes the module
(scripts/gen-loop-host-contract.cjs), so npm run build failed on Node 22 and
the gsd-test linux-node22 lane could not reach run_tests.
Fix: prefer the built-in when present, else an in-file metachar escape —
still the sole owner of escaping (#3212 invariant; lint scope unchanged).
Builtin captured at module load so runtime mutation cannot flip the path.
Regression: tests/pattern.test.cjs section 4 — child-process probes neuter
RegExp.escape before/after require and assert match-behavior equivalence.
Co-authored-by: sim <sim@local>
* test(#3468): add write-path drift guard, ratcheted at its measured baseline
Guard-first, per ADR-3180 Amendment 3's standing rule that a phase builds
and runs its guard BEFORE its scope is fixed, and states its copy count as
'N found by the guard', never 'N per the epic'.
Measured, not assumed:
Axis 1 (policy dispatch, ADR-3408 section 8.1) — 7 violations, RED by
design. 5 field-name-keyed getFieldClassification('literal') branches
plus 2 declared FieldPreservation members with no executor at all
(derive, clear). This is the fail-first evidence for the refactor.
Axis 2 (write seam, section 8.3) — 4 bypasses, ratcheted. Epic #3408
scoped this at two writers; the whole-repo scan found four, and one the
epic named (patchCore) is not among them because it bypasses via
stateReplaceField rather than the seam calls. Fourth consecutive time an
epic's copy count proved a lower bound.
Two detectors were written and removed again before this commit, both
recorded in the file header rather than silently dropped:
- A prompt-layer detector that reported 5 backticked prose mentions as
drift. That is ADR-3180 Amendment 3's recorded false-positive class,
and CONTRIBUTING.md already settles it: a backticked command reference
is a mention. Now gated on inline-code spans.
- A stateReplaceField co-occurrence detector for section 8.3(b). Measured
at 29 false positives to 1 true positive — it matched the function's own
definition and ~20 calls on frontmatter-free body slices. Banking 29
non-defects to catch one is the 'ratchet as a parking lot' gaming route
Decision 5 names, so it is a DECLARED KNOWN GAP owned by Phase 2
(#3469), which both fixes it and makes its detection tractable.
* test(#3468): failing-first coverage for policy dispatch and the loud failure
Matrix sections A, B and C from 50-test-matrix.md.
Expected RED against this tree, confirmed by static trace rather than
assumed:
B1, B2, B3 — an unwired declared preserve-when-unchanged row must throw
with code STATE_PRESERVATION_UNWIRED_ROW and a structured .field. Today
src/state-transition.cts:314 silently continues.
A4 — a whitespace-only snapshot is restored today, because the guard is
.length > 0. Required behavior is skip.
Everything else is characterization, locking in behavior the refactor must
preserve. C1 is table-driven over every FIELD_CLASSIFICATION key; C2 pins
current_phase_name's exact outputs as literals, because its row is being
reclassified preserve-always to preserve-when-unchanged as a
behavior-preserving change and nothing else would catch a drift. C3 is a
seeded fast-check property (seed 3468, 200 runs, replay data on failure).
A22 is deliberately NOT a behavioral test. Whether 'derive' has an explicit
executor is not observable through applyStatePreservation's public API — it
is a structural property, and the drift guard's unimplemented_policy axis is
what enforces it. That split is ADR-3408 Decision 5's own pairing: the lint
is the structural metric, the test is the outcome metric, and neither is
reported alone.
* refactor(#3468): dispatch preservation on the declared policy, not the field
Implements ADR-3408 sections 8.1, 8.2 and 8.6.
applyStatePreservation is now one loop over FIELD_CLASSIFICATION dispatching
on the row's preservation value, with four small executors — one per
FieldPreservation member. No branch is selected by field name. Zero
literal-argument getFieldClassification calls remain.
Behavior-preserving for 16 of 20 input classes. The four that change:
- An unwired declared preserve-when-unchanged row now THROWS
(code STATE_PRESERVATION_UNWIRED_ROW, structured .field) instead of
silently continuing. This fires only on an internal invariant violation
with both ends in our own source; a drifted, malformed or unparseable
user STATE.md must never reach it, which is section 8.2's bright line
and what test B8 proves through the real CLI.
- derive gained an explicit no-op executor. That is what makes the throw
decidable: 'policy says do nothing' is now distinguishable from 'nobody
wired this'.
- current_phase_name's row is corrected from preserve-always to
preserve-when-unchanged. The row was wrong, not the code — it has always
been delta-gated on the body Phase line, so preserve-always had two
divergent implementations. Behavior is unchanged and test C2 pins it.
- A whitespace-only snapshot is no longer restored; the check is trimmed.
clear is deleted from the FieldPreservation union — no row used it and no
executor existed. Speculative Generality: a policy invented for a need that
never arrived. Verified zero dependents.
The caller folds six dedicated pre/post parameters into one bodyDeltas map
keyed by field, so all seven preserve-when-unchanged rows travel one channel
instead of two. Two shapes for one kind of data is why the executor needed
per-field branches at all.
Also fixed, found while reviewing the refactor rather than deferred:
- applyPreserveIfPlaceholder opened with a field-name literal test, which
section 8.1 forbids outright. The executor is idempotent, so the test
bought nothing. The drift guard could not see it, so Axis 1 is widened
to catch field-variable comparisons against literals — the guard
reported zero while a violation sat in the file it polices, which is
Goodhart's gaming-by-indirection.
- loadBaseline conflated an unreadable baseline with an absent one. A
guard whose own diagnostic collapses two states into one identical
result reproduces the exact failure shape this epic exists to remove.
* docs(#3468): record Phase 1 validation as ADR-3408 Amendment 1
Amendment 1 records what Phase 1 found, per ADR-3408 section 8's rule that a
behavior it does not state is not decided:
- preserve-always had TWO divergent implementations; current_phase_name's
row was wrong and is reclassified, behavior unchanged.
- section 8.6 resolved: clear is deleted, zero dependents.
- the closed guard vocabulary is real and has exactly one true member,
because stopped_at's scoping turned out to be caller-side extraction.
- copy count found by the guard: 4 write-seam bypasses where the epic
scoped 2, and patchCore — one of the two it named — is not among them.
- two detectors built and removed again, with their measured false-positive
rates, so nobody re-attempts them.
- a DECLARED KNOWN GAP for section 8.3(b), owned by Phase 2.
- Decision 5's anti-gaming list earned itself twice in one phase.
Also adds the changeset fragment.
* test(#3468): fix review findings — try/finally, stale clear allowlist, ratchet owners
Standards axis, both hard violations:
- tests/state-write-path-drift-guard.test.cjs wrapped stdout/argv/exitCode
restoration in try/finally inside the test body. CONTRIBUTING.md:356
forbids it outright, and the correct t.after() pattern was already in
use two lines up in the same test.
- tests/state-transition.test.cjs still listed 'clear' as an allowed
FieldPreservation value in the row-enumeration test AND the
getFieldClassification property test, after this PR deleted it. A stale
allowlist weakens the property's negative space — it would accept a
resurrected clear row as valid.
Contract tension, resolved rather than left:
ADR-3408 section 8.3 requires each ratchet entry carry the issue owning
its removal. All four shipped with owner: null. The guard was right not to
INVENT one, but the owners are known from the phase plan, so recording
them is not inventing: phase.cts -> #3469, state.cts and milestone.cts ->
#3471, health-diagnostic.cts -> sanctioned-permanent.
Rather than a JSDoc caveat, --baseline now MERGES prior owner values on
the (file, source) key, so a mechanical regeneration can no longer
silently discard curated provenance. Verified by regenerating twice.
* fix(#3468): sanitize attacker-controlled fields on every guard output path
Isolated security review, MEDIUM, confidence 8/10.
findSeamBypasses and findPromptSeamUses built findings with an UNSANITIZED
`file`, while the co-located `source` on the same object was correctly
wrapped in sanitizeForReport. On a fork PR a filename is exactly as
attacker-controlled as a source fragment — a repo can legally track a
filename carrying C1 control bytes or bidi overrides.
The raw value reached two paths: --json stdout, and the COMMITTED baseline
JSON via buildBaselineEntries. JSON.stringify neutralizes C0 controls but
does NOT escape C1 (0x7f-0x9f) nor the bidi/zero-width range
sanitizeForReport exists to strip — which is the precise threat the guard's
own header names. Only the human formatter was safe.
Sanitization now happens at CONSTRUCTION, so every consumer inherits it
rather than each output path having to remember. The same defect was present
on `field` and `policy` and is fixed alongside. Double-sanitization in the
formatter is left in place, verified idempotent: escaped output is ASCII and
cannot re-match the control/bidi classes.
Also: the guard was not referenced anywhere in package.json, so nothing ran
it. A drift guard nobody runs is not a guard, and ADR-3408 Decision 5 assumes
it runs. Wired into lint:ci beside its sibling drift guards; it was already
green on this tree, so the chain stays green.
* chore(#3468): re-curate ratchet after an upstream rewording of a tracked bypass
The rebase onto origin/next turned the guard red on its first real day, which
is the ratchet working rather than a defect.
c90ae479f fix(#3350) reworded cmdPhaseComplete's syncStateFrontmatter call
onto one line and changed its third argument. Because entries are keyed on
(file, trimmed source text) rather than a line number, that single upstream
edit registered as BOTH a stale acknowledgment and an unrecorded site — the
two-sided signal the design intends, forcing a human to look rather than
letting a tracked bypass drift out of view.
The owner-preserving merge behaved exactly as designed: three owners survived
because their keys were unchanged, and phase.cts's dropped to null because its
source text is genuinely a different key. Re-curated to #3469, the phase that
owns its removal.
Note for Phase 2: c90ae479f is #3350's fix landing independently on next —
one of the two instances Phase 2 was scoped to drive fail-first. Surfaced to
the epic rather than absorbed silently.
* test(#3468): derive B1's fixture from the table so it cannot go stale
Checkpoint 2 came back with 2 failures of 33803, both B1:
actual 'current_phase_name'
expected 'current_plan'
The implementation was right and the test was stale. B1 hand-built a
bodyDeltas literal intending current_plan to be the ONLY unwired row, but it
also omitted status, stopped_at and current_phase_name — all three of which
became preserve-when-unchanged rows in THIS PR. Table order puts
current_phase_name first, so the throw correctly named it.
B1 now builds from neutralBodyDeltas() and deletes exactly one key, which is
what its own comment always claimed it did. A future table change can no
longer silently make it assert the wrong field.
Audited every other bodyDeltas literal in the file: four exist, all correct —
two enumerate all seven rows explicitly, two pass {} where the emptiness is
the point of the test. Roughly thirty other sites already derive from the
helper.
Also renames the local unchchangedChanged to lastActivityDescChangedDeltas.
A typo'd identifier that happens to work is still a Mysterious Name; noted
during research and fixed now that this change touches the file.
* chore(#3468): re-curate ratchet and fold the seam channel into the shared helper
The rebase onto be9329b10 fix(#3374) was a true semantic conflict, resolved
rather than handed back, because the resolution was determinable:
That PR extracted the post-sync preservation pass into a shared
applyPostSyncPreservation helper — which is ADR-3408 section 8.3, i.e. a
piece of Phase 2's own deliverable, landing upstream. Its structure is kept
wholesale; this branch's contribution is applied INSIDE it.
That combination had to be checked rather than assumed. Upstream's helper
wires only FOUR bodyDeltas keys and still passes status / stopped_at /
current_phase_name through six dedicated parameters. This branch reclassifies
current_phase_name to preserve-when-unchanged, deletes those six parameters
from StatePreservationInput, and makes an unwired declared row THROW. Taking
upstream's file as-is would therefore have thrown on EVERY STATE.md write.
The helper now wires all seven rows through the single channel. Verified
7-to-7 against FIELD_CLASSIFICATION, with a clean tsc — which is the real
proof the dedicated parameters are gone, since they no longer exist on the
input type.
The ratchet also caught the same phase.cts call being reworded a second time,
reporting it as both a stale acknowledgment and an unrecorded site. Re-curated
to #3469. Recording the tradeoff plainly: keying on (file, source text) means
an upstream reword of a tracked line needs re-curation, where keying on line
numbers would churn on every unrelated edit. ADR-3180 Decision 4(e) chose
source text deliberately, and the owner-preserving merge added earlier covers
the common case where the text is unchanged.
* chore(#3468): backfill pr number in changeset fragment
---------
Co-authored-by: sim <sim@local>
Phase 1 of #3464. Removes the `// allow-test-rule:` marker from 22 test files
where it is provably vestigial, and tightens the ratchet ceiling in
scripts/lint-allow-test-rule-refs.ceiling.json from 305 to 285.
Eligibility is decided by two independent AST discriminators, both
conservative (any doubt => keep):
(a) Read-target type. Every readFileSync/readFile call in the file resolves
statically to a prose/config extension (.md/.json/.yml/.yaml/.toml/.txt),
or the file performs no reads at all. Any read of a source extension
(.cjs/.js/.mjs/.ts/.cts/.mts/.jsx/.tsx), any dynamic/unresolvable path,
and any other extension all disqualify the file.
(b) Marker context. Every `allow-test-rule:` occurrence is a genuine comment
node, never string- or template-literal payload. A marker that lives
inside a RuleTester `code:` fixture is test DATA, not a suppression
directive; stripping it corrupts the test. tests/eslint-rules.test.cjs is
the one such fixture host and is deliberately untouched.
An earlier attempt at this phase classified markers by "strip it and see if
local/no-source-grep still passes" and was reverted in full before commit.
That oracle is unsound: the rule only fires on a literal .cjs/.js/.ts path
containing a quoted bin/lib/gsd-core/src segment, tracked one hop from the
binding, so files that genuinely source-grep real JavaScript pass it
silently -- tests/no-unbounded-spawn-allowlist.test.cjs (reads test sources
through a listTestFiles() walk) and tests/claude-imperative-reference.test.cjs
(matches bin/install.js through an intermediate variable) both cleared it
while being real source-greps. The rule's implementation is narrower than its
intent, so it cannot adjudicate whether an exemption is load-bearing.
Scope is limited to comment deletions: the diff over the test tree is 100%
line removals with zero insertions, and no executable line is altered.
On the ceiling value. The measured count at this HEAD is 283, so 285 leaves 2
slack -- deliberate, and well inside the documented grace band of 3. Pinning
the ceiling to the exact count makes this change effectively unmergeable: any
concurrent PR that lands one marker-bearing test file re-reds it. That race
fired twice while preparing this branch (once mid-rebase taking the count
304->305 on next, once between rebase and the verification run taking it
282->283), and it is the same race that broke next in #3461. A ceiling of
actual+2 preserves a merge window while still ratcheting 305 -> 285.
Known limit, disclosed rather than papered over: this clears 22 of 303
markers and does not reach #3464's trend-to-zero goal. Most of the remaining
markers sit on dynamic-path reads, commonly a hoisted `const p =
path.join(tmpDir, 'STATE.md')` whose target is prose but is unresolvable to
this classifier. A stricter one-hop const resolution would flip an estimated
95 more; that is deliberately left to a follow-up so it can be reviewed on
its own evidence.
Marker discovery reads bytes rather than shelling out to grep:
tests/security-prompt-injection.security.test.cjs carries a literal NUL byte
(an intentional injection fixture) that makes grep treat it as binary and skip
it, which is why the true marked-file count is 303 and not the 302 a shell
scan reports.
Closes#3465
Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(#3374): phase.complete stops harvesting stale body stopped_at
Variant A: cmdPhaseComplete's adapter calls syncStateFrontmatter directly
(deliberately - STATE.md commits atomically with ROADMAP/REQUIREMENTS),
which also bypassed the #948/#1230 preservation pass every RMW write gets.
A stale body 'Stopped at:' line then silently clobbered a fresher
frontmatter stopped_at on every phase completion, with warnings: [].
Three layers close it without reversing #3517's refresh expectation:
- completePhaseCore now refreshes the body continuity line it implies
('Phase N complete, ready to plan Phase N+1'; ADR-2207 phrasing on the
last phase), session-scoped via the new stateReplaceFieldInSession seam
so a decoy bold Stopped-at line in an unrelated section cannot absorb
the refresh. Replace-only - a layout with no session line keeps its
shape and its frontmatter value survives via the preservation delta.
- the RMW post-sync preservation chunk (snapshots + table-driven
applyStatePreservation + #2736 re-assert, full bodyDeltas wired) is
extracted into the shared applyPostSyncPreservation helper; the
phase.complete adapter and writeStateMd (milestone complete / state
sync - the gap the closed PR #3442 review flagged) now run it too.
- cmdStateRecordSession pushed 'Stopped At' onto updated[] on any label
MATCH, including a value already on disk - reporting a write that never
changed a byte. It now reports only on real change, and the match is
tracked separately so an identical value does not arm the #944 DWIM
section rewrite (which would reset an executor-authored resume file to
None).
* docs(#3374): backfill changeset pr field to 3491
* fix(#3374): drop the writeStateMd preservation pass - state sync's #905 contract is body-wins
CI on this PR caught what the closed PR #3442 review's MAJOR remediation
option (a) would have broken: state sync's #905 contract ('body annotation
beats existing frontmatter when both are present') is the opposite by
design - sync exists to re-derive frontmatter from the body. A blanket
applyStatePreservation pass on writeStateMd re-locked stale frontmatter
(current_phase 3 over the body's 5) on every sync.
Take the review's sanctioned option (b) instead: the scope claim is
accurate (phase.complete only) and the milestone complete / state sync
exposure is tracked as follow-up issue #3492.
---------
Co-authored-by: sim <sim@local>
* enhance(#2115): tighten AI-integration gate keywords per re-triage pin
Apply the maintainer-pinned scope from the 2026-07-31 re-triage on #2115
exactly: `eval` -> `llm eval`, `ai system` dropped, every other token and
the surrounding sentence byte-identical. Supersedes the stale-closed PR
#3131, whose broader whole-word-matching rework went beyond the pin.
* chore(#2115): set changeset fragment pr to 3431
* fix(#3395): own the phase line in planned-phase and persist --name
* fix(#3395): backfill changeset pr 3490
---------
Co-authored-by: sim <sim@local>
* fix(#3351): reconcile state.patch report with persisted state.md
* chore(#3351): add changeset fragment
* chore(#3351): backfill pr number in changeset fragment
---------
Co-authored-by: sim <sim@local>
* fix(#3355): pick phase-dir dedup survivor from content, not mtime
* chore(#3355): add changeset fragment
* chore(#3355): backfill PR number in changeset fragment
---------
Co-authored-by: sim <sim@local>
* fix(#3456): render /gsd command output via ctx.ui.notify in pi
* chore(#3456): add changeset
* chore(#3456): point changeset at PR 3485
---------
Co-authored-by: sim <sim@local>
* fix(#3384): strip mcp__* tool grants from zcode-installed subagents
ZCode's dispatcher treats every mcp__<server>__* entry in an agent's
tools: frontmatter as a required MCP server and hard-fails the subagent
spawn (CONFIGURATION_ERROR) when it is not connected, whereas Claude Code
treats the same grants as an optional allowlist. ZCode shared Claude's
verbatim agents copy (converter: null), so all 8 MCP-granted agents
failed to spawn out of the box with zero MCP servers configured.
Add convertClaudeAgentToZcodeAgent — a line-surgical converter that
filters mcp__* entries out of the frontmatter tools: grant list (both
inline comma and YAML block-list shapes) and preserves every other byte.
Declare it on both of zcode's capability.json agents entries and cut
zcode over to the descriptor-driven agents path
(_DESCRIPTOR_AGENTS_RUNTIMES) so the legacy inline loop stops
deleting+re-copying the converted agents raw. Claude Code, Kimi, and
Gemini install behavior is unchanged.
* chore(#3384): link changeset fragment to pr 3483
---------
Co-authored-by: sim <sim@local>
* fix(#3370): state checkpoint gate semantics in executor dispatch prompts
* fix(#3370): set changeset pr to 3478
* fix(#3370): keep gate rule in routing fragment under phase-6 ceiling
---------
Co-authored-by: sim <sim@local>
* fix(#3354): preserve stored total_phases when milestone is unbounded
* chore(#3354): backfill PR number in changeset fragment
---------
Co-authored-by: sim <sim@local>
* fix(#3448): thread next_action through debug auto-resume respawn
Both /gsd-debug auto-resume call sites (Section 1c continue-path return
handling and Section 4's non-terminal branch) respawned the session manager
with identical session_params, making every resume prompt-indistinguishable
from a cold start: the checkpoint's recorded next_action and the disposition
that any earlier checkpoint was already answered never reached the respawned
agent. Two auto-resumes then made no progress and the (correct) no-progress
guard stalled the loop.
The respawn now carries resume: true, resume_status, and resume_next_action
sourced from the checkpoint file; the session manager documents the params
and its Step 2 gsd-debugger template forwards them via a DATA_START/DATA_END
<resume_directive> (Step 3d's shape), instructing the debugger to proceed
directly on the recorded next action without re-raising answered checkpoints.
The anti-loop guard (next_action-only heuristic, 3-resume hard cap) is
untouched.
* chore(#3448): set changeset pr to 3476
---------
Co-authored-by: sim <sim@local>
* test(#1884): reproduce phantom lock timeout from swallowed mkdir failure
withPlanningLock swallows a platformEnsureDir failure at src/planning-workspace.cts:210,
so an EACCES/ENOSPC/EROFS creating .planning/ is misreported as a 10s "held by a live
process" lock-contention timeout instead of the real filesystem error. These tests pin
the corrected contract and currently fail against the unfixed source (RED). Also proves
the pre-existing Docker overlay-fs lock-write retry race is unaffected by this change.
* fix(#1884): surface mkdir failures instead of a phantom lock timeout
withPlanningLock swallowed platformEnsureDir failures (EACCES/ENOSPC/EROFS/EMFILE)
creating .planning/, so the subsequent lock write failed with ENOENT (parent
missing), which is retryable (PLANNING_LOCK_RETRY_ERRNOS, added for a Docker
overlay-fs race). The loop then spun the full 10s budget and threw a phantom
"held by a live process" contention error pointing at a nonexistent holder.
The mkdir failure now propagates immediately with its real errno and message.
The Docker overlay-fs ENOENT lock-write race (directory present) is unaffected,
as is every path where .planning/ already exists or is creatable.
Per docs/adr/1411-resolution-provenance.md's 2026-07-26 amendment, this is the
one site in epic #1879 that legitimately throws (ADR-227's genuinely-fatal
carve-out) -- its defect was throwing the wrong error after swallowing the
real one, not that it threw at all.
* chore(#1884): backfill changeset PR number
---------
Co-authored-by: sim <sim@local>
* docs(#3467): adr-3408 state.md write-path behavior contract
* docs(#3467): correct write-seam caller list and adr heading depth
Review findings from the Standards and Spec axes, fixed in place:
- ADR behavior contract demoted from H2 to `### 8` with `#### 8.x`
subsections, matching ADR-3180's `### 7` / `#### 7.1` precedent the
front matter claims to follow.
- CONTEXT.md placed the STATE.md factory-reset primitive at
verify.cts:1925. It moved to health-diagnostic.cts:337 when
cmdValidateHealth migrated onto the rule table (#3309); verify.cts
now has no writeStateMd call. The design intent was correct — only
the address was stale.
- A repo-wide scan found three direct writeStateMd callers, not two:
cmdStateSync, cmdMilestoneComplete, and the REGENERATE_STATE remedy.
The last is documented as a sanctioned permanent exception — it is
a factory reset, so preservation would restore the values it was
invoked to discard.
- phase.cts comment citation corrected to :2953-2957.
- Amendment 4 attribution corrected: the recorded owner-file exemption
failure is roadmap-parser.cts; the state.cts transfer is this ADR's
own extrapolation.
---------
Co-authored-by: sim <sim@local>
* fix(#3329): reconcile stale managed .sh hook commands on install/update
applySettingsJsonHooks registers the four .sh managed hooks only-if-absent,
so entries registered before the #580/#3393 shellHookOmitsBashRunner fix kept
their bash-runner-prefixed commands forever — /gsd-update re-invokes the
installer but never re-derived existing entries. Add
reconcileManagedShellHookCommands (wired into applySettingsJsonHooks): on
win32+claude it rewrites existing managed .sh entries to the command this
install would generate today, scoped to exact managed basenames so
user-authored hooks are untouched, and inert wherever the bash runner is
still the correct shape.
Also bumps the allow-test-rule ceiling 301→302: PR #3455 added
tests/milestone-lock.test.cjs (the 302nd marked file) without the ratchet
bump, leaving lint-tests red on next.
* chore(#3329): add changeset fragment
* chore(#3329): backfill changeset pr number 3460
---------
Co-authored-by: sim <sim@local>
* feat(#3415): ship local/no-unbounded-quantifier, burn down ReDoS class
Phase 4 of epic #3212 (ADR-3212 §5/§7, the final phase). New rule flags
an unbounded */+/{n,} quantifier over a broad character class
([\s\S], dotAll ., or a 1-2-unit negated class like [^\n]/[^)\n] — the
exact #2128-fixed shape) applied to a regex whose match target is
data-flow-traced to readFileSync content.
eslint-rules/lib/readfilesync-trace.cjs extracts the data-flow tracer
shared with no-crlf-fragile-split (Phase 2) rather than a second copy
— no-crlf-fragile-split refactored onto it with zero behavior change,
parity-tested.
Real triage, not 798 mechanical edits: the ADR's census (2026-08-08)
screened every unbounded quantifier in the tree unscoped. Correctly
scoped to readFileSync-derived content (matching Phase 2's own G2/G3
scoping), the rule found 162 real hits across two detection waves — the
second wave (93) surfaced only after a genuine off-by-one bug in this
rule's own first draft was caught while writing its RuleTester tests
and fixed (the bug silently missed every directly-quantified [\s\S]*
with no gap before the quantifier — exactly the class this rule exists
to catch). 3 hits landed in production src/ (commands.cts, milestone.cts,
roadmap.cts) and were each empirically timed against adversarial input
(matching #2128's own measured-not-assumed precedent) — all confirmed
linear-time/benign, left unbounded with a measured-evidence comment
rather than mechanically bounded. The remaining 159 are test-file
fixture parsing (test-author-controlled, fixed-size content, not
adversarial input) — each suppressed with a specific, non-generic
reason. Zero functional behavior changed anywhere in this diff.
tests/no-pending-3212-markers.test.cjs locks the epic's own closing
invariant (ADR §7: "assert zero pending #3212 markers remain") — ground
truth confirmed trivially true today (no phase left any such marker
behind), now regression-locked going forward.
Design: .gsd/phase/chore-3415-prohibition-with-teeth/40-design.md
Test matrix: .gsd/phase/chore-3415-prohibition-with-teeth/50-test-matrix.md
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(#3415): correct rule category mislabel, add CI test-scope entry
An orthogonal Standards-axis review found eslint-rules/no-unbounded-quantifier.cjs
mistakenly carried meta.docs.category: 'Portability', copied from a sibling
rule without realizing what that implied: docs/contributing/cross-platform-
portability-rules.md governs an ADR-1703 rule family under a hard "zero
escape hatches" contract (tests/portability-rule-disable-ban.test.cjs's
PROTECTED_RULES bans eslint-disable for those rules entirely). This rule is
not part of that family — it's ADR-3212 (ReDoS/CWE-1333), a different epic —
and its eslint-disable-next-line suppressions (159 of them, added earlier
this same phase after empirical benign-verification) are an intentional,
correct design, not a bypass. Corrected to category: 'Best Practices',
matching the actual precedent (no-adhoc-regex-escape.cjs, Phase 1 of the
same epic, which is also correctly outside PROTECTED_RULES), and the rule's
own docstring now states this explicitly so a future reader doesn't have to
re-derive it.
Also registers a new scripts/ci-test-scope.cjs bucket so editing this rule
or the shared eslint-rules/lib/readfilesync-trace.cjs helper re-runs their
own test suites under targeted CI selection — was previously unregistered
and invisible to that fast-path (this PR's own gsd-test checkpoint runs the
full suite regardless, so this only affects future narrowly-scoped PRs).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(#3415): bound no-unbounded-quantifier's own scanner (CWE-1333, ironic)
Security review found the rule meant to catch algorithmic-complexity bugs
had one of its own: hasUnboundedBroadQuantifier's negated-class inner
scan walked from each `[^` occurrence to the next `]` (or EOF) with no
bound, while the outer loop only ever advanced by one character — O(n²)
total work on a pattern with many unclosed `[^` runs. Runs unconditionally
inside checkPattern on any `new RegExp('literal string')` argument in any
linted file, before the (cheap) readFileSync data-flow gate — so a single
crafted string literal, no valid regex syntax required, could make
`npm run lint` / CI hang.
Empirically confirmed both the bug and the fix: pre-fix, n=4000/8000/
16000/32000 chars took 30.8/115.6/463.8/1874.3ms (~4x work per 2x n,
quadratic); extrapolated, the 300000-char repro from the finding would
run ~165s. Post-fix (bail the inner scan once units exceeds the rule's
own 1-2-unit scope, rather than continuing to hunt for a closing `]`),
the same 300000-char input runs in 8.7ms via the real rule module,
independently reconfirmed at 18ms via a fresh Linter.verify() call.
New regression row in tests/no-unbounded-quantifier.rule.test.cjs
asserts the RuleTester run on a 50000-char adversarial pattern
completes and returns a defined result — no wall-clock assertion
(CLAUDE.md Clock Seams / local/no-elapsed-assertion).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(#3415): triage 3 new sites, re-raise ceiling after upstream batch
next merged 12 more PRs during this PR's review. Two consequences:
- tests/edit-phase.test.cjs (fix#3262, unrelated) added 3 new
content.match(/<tag>([\s\S]*?)<\/tag>/) reads of this repo's own
workflow .md content — the same Class A pattern as the ~159 sites
already triaged elsewhere in this PR. Suppressed with the same
established reason.
- lint-allow-test-rule-refs' ratchet ceiling needed re-raising again
(301 -> 303) for the same reason as the two prior bumps: organic
growth from unrelated, already-reviewed PRs landing concurrently,
not a defect in this branch's own diff.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---------
Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* fix(#3311): milestone lock makes parallel-phase state conflicts visible
Two sessions running different phases in one working tree silently
clobbered STATE.md's single un-scoped ## Current Position slot: byte-level
serialization already existed (STATE.md.lock since #464), but nothing ever
surfaced that two sessions claimed two different phases, and
state.advance-plan — which takes no phase argument — kept advancing whatever
plan the just-clobbered position named.
Adds the maintainer-chosen milestone lock (issue #3311 comment): an advisory
.planning/milestone.lock claim keyed by phase + session id (session identity
via getWorkstreamSessionKey: env-first, then controlling TTY). begin-phase
claims (inside the STATE.md lock), advance-plan detects a claim/position
mismatch and heartbeats a matching claim, phase.complete warns via warnings[]
and releases the claim when the claimed phase completes. Conflicts warn
(stderr + typed milestone_conflict JSON field) instead of blocking, per the
decision's blocking/warning latitude; TTL 4h with heartbeat liveness expires
abandoned claims. milestone.lock is registered in the canonical artifact
registry so validate.health W019 recognizes it.
* chore(#3311): point changeset fragment at pr 3455
---------
Co-authored-by: sim <sim@local>
* test(#1762): assert reassuring verification-routing wording (RED)
Regression test for #1762 — asserts readVerificationStatus's missing/
unknown next_action text reassures the user that execute-phase resumes
at the verification gates without redoing work, instead of reading as
a blind re-run instruction. Fails against current wording; the fix
lands in the next commit.
* fix(#1762): reassure verification-missing/unknown routing text is safe to run
readVerificationStatus's 'missing' and 'unknown' next_action text read as
"redo the implementation", when execute-phase's own discover_and_group_plans
step (#2868) already resumes at the verification gates and skips
execute_waves/checkpoint_handling entirely when every plan already has a
SUMMARY.md. Reword both to say so explicitly, and soften the 'unknown'
message to acknowledge a non-standard status may be an intentional marker
rather than presuming re-verification is always the fix.
Also updates progress.md's Route V.missing / V.unknown to consume the
dynamic $VERIFICATION_NEXT_ACTION (matching V.gaps/V.human) instead of a
hand-duplicated string, so the reassurance lands there too without a second
copy to keep in sync.
No change to next_command routing, status values, or the isPhaseComplete
completeness predicate (still gated on status === 'passed' per the #2957
DISK-STRICT decision) — advisory text only.
* fix(#1762): drop duplicated reassurance clause in progress.md routing text
Review finding: the SUMMARY.md reassurance was stated once via the
interpolated $VERIFICATION_NEXT_ACTION and again as a parenthetical on
the command line, in both Route V.missing and Route V.unknown. Trimmed
the parenthetical so $VERIFICATION_NEXT_ACTION stays the single source
of truth.
* docs(#1762): add changeset fragment
Type Changed with a docs-exempt marker — advisory routing text only,
no docs page documents this specific message today.
* test(#1762): acknowledge progress.md emitted-content growth
Route V.missing/V.unknown now render a small block referencing
${VERIFICATION_NEXT_ACTION} instead of a bare command line, growing
progress.md by 376 bytes. Deliberate per the differential attribution
check (ADR-2719).
* chore: drop spent progress.md ack from #3218's emitted-drift fragment
The #3218 growth it described is already on next (inert, per the
lint's own note that spent acks 'can no longer clear anything').
Its presence collided with the new #1762 fragment naming the same
bare filename, which lint-emitted-drift-ack rejects outright ('two
ack sources may never name the same path'). #3218's other two
acks (plan-phase.md, plan-review-convergence.md) are untouched.
* docs(#1762): backfill changeset PR number
---------
Co-authored-by: sim <sim@local>
* fix(#3189): drop non-id prose from phase_req_ids before gap check
* chore(#3189): pin changeset fragment to pr 3438
* fix(#3189): use hyphenated IDs in e2e fixtures (parseRequirements requires hyphen)
* fix(#3189): assert e2e item set via table, not rows (check query has no rows)
* fix(#3189): check prose fragments against table rows, not the table heading
---------
Co-authored-by: sim <sim@local>
* fix(#3191): anchor remaining diff-base greps, portably
The #2989 fix anchored only the Tier-3 grep, and did so with \b — not a
POSIX ERE token, so on macOS regex(3) it silently matches nothing and
Tier 3 always fails closed. spawn_reviewer's agent-context DIFF_BASE and
the fallow structural pre-pass's --changed-since base each still ran the
original unanchored --grep="${PADDED_PHASE}", whose oldest substring
match is routinely a version-string/date commit from months before the
phase existed — feeding the reviewer agent a bogus diff_base exactly
when files: is empty, and widening fallow's changed-files scope.
All three derivations now use the same anchored, POSIX-portable
'[Pp]hase N([^[:alnum:]_]|$)' with --extended-regexp; spawn_reviewer
also gains Tier-3's parent-exists guard so the two computations are the
same algorithm. Behavioral regression tests execute the shipped bash
extracted from the workflow files against a git fixture on every
platform, so the macOS \b hole is covered, not just the Linux CI view.
* chore(#3191): backfill changeset PR number 3437
* fix(#3191): scope fallow test snippet past the gsd-tools resolver
The CI runners have no installed gsd-tools, so executing the resolver
line that precedes FALLOW_SCOPE_ARGS in the extracted fence exits 1
before the derivation under test ever runs. Slice the snippet to start
at FALLOW_SCOPE_ARGS=() — the resolver is orthogonal to the base
derivation the regression test binds.
---------
Co-authored-by: sim <sim@local>
* fix(#3194): verify source-grounded lane evidence from review output
* chore(#3194): fill changeset pr number
---------
Co-authored-by: sim <sim@local>
* fix(#3190): commit review.md in --auto loop; fix report env var
Three coupled defects in gsd-core/workflows/code-review-fix.md:
- The --auto re-review loop overwrote REVIEW.md each iteration but the
single docs commit staged only REVIEW-FIX.md, so the committed REVIEW.md
stayed at iteration 1 and contradicted the committed REVIEW-FIX.md. The
--auto commit now stages the converged REVIEW.md alongside REVIEW-FIX.md
(guarded on AUTO_MODE; non-auto single-pass runs unchanged).
- The two inline frontmatter validators (HAS_STATUS, FIX_FRONTMATTER)
exported REVIEW_PATH into a node -e body that reads process.env.
FIX_REPORT_PATH, so the status check was always empty and REVIEW-FIX.md
was never committed. Both now export FIX_REPORT_PATH.
- On successful convergence the spent .iterN.md backups are removed so the
phase directory is clean; they are retained on degradation for post-mortem.
Regression test: tests/code-review-fix-pipeline-regression.test.cjs.
* chore(#3190): set changeset pr to 3434
---------
Co-authored-by: sim <sim@local>
* fix(#3188): null absent planning-doc paths in init phase queries
The init phase-op / plan-phase / execute-phase queries emitted a non-null
absolute requirements_path / state_path / roadmap_path even when the named
file did not exist — built with a bare path.join and no existence check,
unlike the conditional sibling fields (patterns_path, context_path, ...) in
the same payload. Consumers (e.g. ultraplan-phase.md:104 'requirements_path
is not null') therefore read missing files.
Each of the three reading sites now returns null when the file is absent and
its absolute path when present. The project/milestone-bootstrap and doc-ingest
emitters that use these paths as write-targets for not-yet-created files are
intentionally unchanged.
* chore(#3188): backfill changeset pr: 3430
---------
Co-authored-by: sim <sim@local>
* fix(#3171): init execute-phase emits display name, not directory slug
When a phase directory already exists on disk, the disk-lookup path
(searchPhaseInDir) derived phase_name from the directory-name remainder --
itself an already-slugified value (phase.add writes ${num}-${slug} dirs) --
so phase_name and phase_slug came out byte-identical. The execute-phase
workflow forwards phase_name into 'state begin-phase --name', which wrote
that raw slug into STATE.md's current_phase_name on every phase start.
cmdInitExecutePhase now prefers the ROADMAP's curated display name
('### Phase N: <Name>') for phase_name, matching the no-disk fallback path
that already did this correctly. phase_slug is unchanged (it feeds
branch-name construction). The state.begin-phase authoritativeFm override
(#2821/#2736) is untouched; the correction is in the value fed into --name.
The milestone_name half of #3171 was subsumed by #3216 / PR #3226; this
fixes the remaining current_phase_name half.
* docs(changeset): backfill pr 3429 for #3171
---------
Co-authored-by: sim <sim@local>
* feat(#1689): per-plan agent_hint executor routing
Option A per-plan specialist routing: a plan with an `agent_hint:` frontmatter field is dispatched to that subagent instead of gsd-executor when it resolves on the active runtime; absent/unresolved/disabled falls back to gsd-executor (byte-identical). Default-on via workflow.agent_hint_routing.
- src/phase.cts: parse agent_hint into the plan-index JSON (plan_json.agent_hint)
- agent-install-check.cts: resolveAgentHint() reuses getAgentsDir + runtime filename variants; probes project + global agent dirs; fails closed; rejects path-traversing names
- gsd-tools.cjs: 'resolve-agent' query route (fail-closed to gsd-executor; --raw/--json)
- execute-phase.md: lean per-plan reference + {EXECUTOR_TYPE} placeholder (host stays under the ADR-857 Phase 6 byte ceiling)
- execute-phase/steps/per-plan-executor-routing.md: resolution logic (Agent()-based dispatch; advisory on orchestrator-worktree)
- config: workflow.agent_hint_routing (validKey, default-on via SCHEMA_DEFAULTS, boolean validator)
- docs (CONFIGURATION.md, plan-md.md), changeset, tests/agent-hint-routing-1689.test.cjs (17 tests)
* chore(#1689): backfill changeset PR number (#3417)
* chore(#1689): regenerate install-tree fixtures for new workflow fragment
* chore(#1689): ack deliberate execute-phase.md growth (agent_hint routing)
* test(#1689): SPAWN contract allows parameterized subagent_type placeholder
agent-frontmatter's spawn-type checks scanned subagent_type="..." as a
concrete agent name. execute-phase now uses subagent_type="{EXECUTOR_TYPE}"
(a runtime placeholder resolved via resolve-agent, default gsd-executor).
Skip {TOKEN} placeholders in both the known-type and <available_agent_types>
checks; execute-phase still lists the built-in roster incl. gsd-executor.
* fix(#1689): CI conformance for the routing fragment
- per-plan-executor-routing.md: add the canonical runtime-launcher preamble to
its gsd_run block (runtime-launcher-parity #373), matching sibling step fragments.
- agent-install-check.cts: drop a literal ~/.claude/agents path from the
resolveAgentHint JSDoc so it does not leak into the compiled engine .cjs
(cline install leak guard).
---------
Co-authored-by: sim <sim@local>
* feat(#3414): promote git-cmd.js token-walk into a shared scanner, fix#3169
Phase 3 of epic #3212 (ADR-3212 §4). New src/token-scanner.cts generalizes
hooks/lib/git-cmd.js's proven token-walk (#3129 — "has not re-opened"):
tokenizeShellLike (quote-aware shell tokenizer, byte-identical port) and
indentWidth (bullet-nesting depth).
git-cmd.js migrates onto tokenizeShellLike with zero behavior change
(parity-asserted against every existing #3129 fixture in
tests/worktree-safety.test.cjs's folded block); isGitSubcommand's phases
1-3 (env-prefix skip, executable check, global-option consume) extracted
into skipToSubcommand, shared with the new extractBranchArgument (git
checkout -b / git branch <name>) — a new capability exercising the seam
on the domain the ADR names, not a migration of existing duplicated logic
(none existed).
Fixes#3169: src/decisions.cts's parseDecisionLines couldn't distinguish
a cross-reference bullet nested under an open decision from a fresh
malformed declaration attempt. An earlier bold-run-content-classification
design was tried and disproven against the repo's own existing FIX-B
fixtures (D-02, "no colon no dash") before being adopted — both have
identical shape under any content-only rule. Nesting depth (via
indentWidth) is the actual distinguishing signal: a bullet indented
deeper than the currently-open decision's own bullet is elaboration,
folded into its text like a continuation line, never tested against the
parse-miss guard. A bullet at the same-or-shallower indent is unchanged.
Scope-narrowing disclosed, not silent: of the ADR's four named bugs
(#3197, #3169, #2570, #2528), three no longer need this phase's work.
were independently fixed and closed since the ADR was authored — #2570's
fix is already a correctly-bounded regex per the ADR's own decidability
test (no scanner needed); #2528's fix is a deliberate, twice-reviewed
non-scanner design (its own code comment records a scanner-based attempt
that regressed a symmetric case and was reverted) that this phase does
not disturb. Only #3169 required new work.
get_impact: isGitSubcommand CRITICAL/196 affected symbols,
parseDecisionLines CRITICAL/164 affected symbols (ADR §6 due diligence).
Six-gate ripple: .gitignore, eslint.config.mjs, docs/INVENTORY.md,
docs/INVENTORY-MANIFEST.json (regenerated), CONTEXT.md glossary.
Design: .gsd/phase/chore-3414-tokenizer-first-seam/40-design.md
Test matrix: .gsd/phase/chore-3414-tokenizer-first-seam/50-test-matrix.md
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(#3414): add required fast-check property tests per code review
TESTING-STANDARDS.md:169 requires at least one fast-check property test
for any module that implements parsing — src/token-scanner.cts had none,
an orthogonal Standards-axis review finding. Adds two seeded property
tests (mirroring Phase 1/2's fast-check-setup.cjs convention):
indentWidth counts exactly a generated leading-space run; tokenizeShellLike
round-trips a generated array of whitespace/quote-free words joined with
single spaces.
The design doc's own "no property test needed" rationale was wrong — it
argued no algebraic law applied, but the standard is unconditional for
parsing modules regardless of whether one "feels" applicable. Corrected
in .gsd/phase/chore-3414-tokenizer-first-seam/50-test-matrix.md.
Also fixes two Spec-axis wording drifts the same review found between
the design doc and the shipped code (doc-only, no behavior change):
extractBranchArgument's documented signature dropped an unused
subVariants parameter that was never implemented, and the #3169
fail-first fixture description corrected from "15-decision plan via
cmdDecisionCoverageVerify" to the actual compact 3-decision analog via
the real blocking gate, check.decision-coverage-plan.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* docs(#3414): add changeset for #3169 fix
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* docs(#3414): backfill changeset pr number to 3424
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---------
Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Claude Code applies SKILL.md effort: as output_config.effort; any change from
the session baseline invalidates the prompt cache at BOTH scope boundaries
(entry + exit, the latter often machine-fired via subagent-completion
notification). The reporter's owned measurement confirms it: /gsd-progress
(effort:low) in a medium session → cache_creation 63,404 (entry) + 18,589
(exit), while a no-effort skill shows none. ~76% of invocations paid in full.
Fix (trek-e AC#2/AC#4): convertClaudeCommandToClaudeSkill no longer emits
effort: into Claude-runtime skill frontmatter (src/runtime-artifact-conversion.cts
+ duplicated bin/install.js). normalizeClaudeSkillEffort removed (dead). The six
declaring skills (plan-phase/execute-phase/autonomous/next/progress/stats) no
longer carry effort. Source command files keep effort (input, used elsewhere);
the separate agent-effort surface (#3160) is untouched.
Tests: install-runtime-artifacts #769 block flipped to assert effort is ABSENT
from installed SKILL.md + converter output (the AC#4 behavioral coverage).
Co-authored-by: sim <sim@local>
* fix(#1526): delegate auto-chain post-completion to transition workflow
execute-phase's auto-chain completion called phase.complete then a light inline
set (partial PROJECT.md update + offer-next) and never invoked the transition
workflow, silently skipping graduation scan, session-continuity, project-reference,
accumulated-context, and current-position updates — so a phase completed via
auto-chain left different project state than a normal transition.
Fix (delegate, user decision 2026-08-13): replace execute-phase's update_project_md
+ offer_next with a delegation step that @-includes transition.md in post-completion
mode. Add a post_completion_mode step to transition.md that skips verify_completion
+ update_roadmap_and_state (phase.complete already ran; avoids double-write) and
begins at evolve_project. Standalone transition (mode 1) is unchanged.
Regression: tests/auto-chain-transition-delegation.test.cjs (source-text-is-the-
product) asserts the delegation, the skip-set, the removed inline step, and the mode.
Ack fragment 1526 covers execute-phase.md + transition.md growth (spent 2930 fragment
removed — same-path owner conflict, like #3025/#3024).
* docs(#1526): backfill changeset PR number (#3419)
---------
Co-authored-by: sim <sim@local>