* 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>
* test(#3413): failing-first suite for the line-terminator seam
Phase 2 of epic #3212 (ADR-3212 §3/§6/§7). Tests only — src/text-lines.cts
does not exist yet, so tests/text-lines.test.cjs fails with MODULE_NOT_FOUND
at its require line, which is the intended RED.
The frontmatter.test.cjs additions drive #3360 (confirmed-bug) fail-first:
parseMustHavesBlock currently returns [] for every must_haves block on a
CRLF-authored plan file, because \r is its own LineTerminator in ECMAScript
and two /m-anchored \s* patterns can absorb it, inflating a captured indent
by one character and tripping the "not nested under must_haves" guard.
Verified locally against the current (unfixed) compiled module: both the
direct repro and the silent-exit "blank line before must_haves:" variant
return [] today. A parity property test (crlf vs lf must deep-equal for
every block name) matches a pattern this maintainer has required repeatedly
for prior CRLF fixes in this codebase (Cortex-recorded, verify_intent=held).
The no-crlf-fragile-split.rule.test.cjs additions lock the eslint rule's
future fix-hint text (pointing at splitLines()) and its self-reference
non-violation (the seam's own correct \r?\n split must never flag itself).
Design: .gsd/phase/chore-3413-text-lines-seam/40-design.md
Test matrix: .gsd/phase/chore-3413-text-lines-seam/50-test-matrix.md
* chore(#3413): src/text-lines.cts owns line-terminator handling
Phase 2 of epic #3212 (ADR-3212 §3/§6/§7). Adds splitLines/normalizeEol/
detectEol/joinLines and migrates frontmatter.cts onto it.
parseMustHavesBlock (#3360, confirmed-bug) returned [] for every
must_haves block on a CRLF plan file. Root cause: \r is its own
LineTerminator in ECMAScript, so under /m two \s*-anchored indentation
lookups could match at the position INSIDE a \r\n pair and absorb the
terminator, inflating the captured indent by one character and tripping
the "not nested under must_haves" guard. Two silent exits, one with a
diagnostic and one without (a blank line before must_haves: hits the
silent path). Fixed by converting both lookups from a whole-string /m
match to split-then-scan — splitLines first, then a per-line, non-/m
match — the same structural pattern parseYamlRegion (30 lines away in
the same file) already used safely. Nothing downstream of the two
lookups changed; blockLines is now sliced from the already-split array
instead of re-splitting a substring, but its contents are unchanged for
LF input, and the per-line dash/kv parsing loop is untouched.
A parity property test (CRLF and LF plans parse to identical must_haves
for every block name) matches a pattern this maintainer has required
repeatedly for prior CRLF fixes in this file's neighborhood (Cortex:
7 recorded decisions, verify_intent -> held).
frontmatter.cts's other .split(/\r?\n/) call sites (parseYamlRegion,
isFrontmatterShaped, sliceTopLevelFrontmatterSegments, spliceFrontmatter)
are rerouted onto splitLines — a literal 1:1 substitution, zero behavior
change, since splitLines IS that same regex plus a type guard.
The 4 scripts/normalizeLineEndings copies (gen-registry, gen-loop-host-
contract, gen-capability-registry, gen-context-index) are deleted and
rerouted onto normalizeEol, which strips a bare unpaired \r exactly like
the deleted copies did (not just \r\n pairs) -- verified against each
script's own --check mode against its real generated output.
local/no-crlf-fragile-split widens from tests/ to src/**/*.cts, with its
fix-hint message now naming splitLines() instead of the raw regex --
the prohibition finally has a primitive to point at. Detection logic
unchanged in this phase (deliberate scope limit, see design doc Known
limits: the rule doesn't yet recognize safeReadFile/platformReadSync as
a content source, and has no detector for the \s-adjacent-to-anchor
shape that is #3360's actual mechanism -- the CLASS is converged by the
direct fix + regression test regardless).
joinLines/detectEol are NOT wired into frontmatter.cts's own write path
(cmdFrontmatterSet/Merge -> platformWriteSync) -- verified that
platformWriteSync already, unconditionally converts CRLF->LF on every
.md write today as a pre-existing policy owned by a different module,
and ADR-3212's backward-compatibility clause rules out a file-format
change in any phase. Stated explicitly in Known limits rather than left
for a reader to discover.
Six-gate ripple: .gitignore, eslint.config.mjs (src/**/*.cts block),
docs/INVENTORY.md + INVENTORY-MANIFEST.json (regenerated), CONTEXT.md
glossary (Text Lines Module, mirroring Phase 1's Pattern Module entry).
Design: .gsd/phase/chore-3413-text-lines-seam/40-design.md
Test matrix: .gsd/phase/chore-3413-text-lines-seam/50-test-matrix.md
* fix(#3413): fix 13 pre-existing CRLF-fragile splits the widened rule found
Widening local/no-crlf-fragile-split from tests/ to src/**/*.cts (the
previous commit) immediately surfaced 13 real, pre-existing violations
across 10 files -- undetected until now because the rule never scanned
src/. This is the exact defect class ADR-3212 exists to close, playing
out again one phase after Phase 1 hit the same shape ("the new lint
rule -- once live -- found 27 more"). Per CLAUDE.md's no-defer rule,
fixed inline rather than deferred or suppressed; there is no
established suppression convention for this rule in src/ and inventing
one now would undermine the point of widening it.
audit.cts, broken-windows.cts, core-utils.cts, init.cts, milestone.cts,
phase.cts (x3), profile-output.cts, roadmap.cts (x2): bare-\n splits or
regex character classes widened to \r?\n / [^\r\n], each following the
same pattern already established migrating frontmatter.cts.
phase-estimation.cts: `\r?(?:\n|$)` restructured to `(?:\r?\n|\r?$)` --
already semantically CRLF-safe, but the rule's lexical scanner doesn't
recognize \r? guarding a group (only \r? immediately before a literal
\n). Verified the two forms are equivalent across all four EOL/EOF
cases before restructuring, not assumed.
roadmap-upgrade.cts needed two coupled sites, not the one flagged line:
computeMigrationPlan and applyMigration must agree on line
representation for the lines[edit.lineIndex] === edit.from equality
check to hold, and the write-back needed joinLines + detectEol -- a
plain lines.join('\n') was silently flattening a CRLF ROADMAP.md to LF
wholesale on every migration. This is the first real production
consumer of joinLines/detectEol in this epic (frontmatter.cts's own
write path doesn't use them -- see the previous commit's Known limits).
Fixing the 13 flagged sites surfaced 4 more adjacent same-shape sites
the rule doesn't track (.search() and new RegExp(dynamicString) aren't
in its tracked call/construction set). Investigated each empirically --
hand-tracing this exact bug class already produced one wrong conclusion
earlier in this phase (a detectEol design-doc arithmetic error), so
these were verified with real CRLF fixtures rather than reasoned about
on paper:
- audit.cts (scanTodos): REAL bug, fixed. `bodyMatch.trim().split
('\n')[0]` leaked a trailing \r into a user-visible todo summary on
CRLF input -- .trim() only strips the string's outer edges, not a
\r sitting mid-string before the first bare \n. Now splitLines(...)
[0].
- phase.cts (cmdPhaseInsert, bullet-style branch): REAL bug, fixed.
[^\n]* in targetBulletPattern swallowed a line's trailing \r on
CRLF input, shifting the computed insert position to land INSIDE
the \r\n pair; combined with a hardcoded '\n' bullet separator, a
CRLF ROADMAP.md ended up with a mixed CRLF/LF result after an
insert. Fixed with two coupled changes (either alone still
corrupts, verified both ways): [^\r\n]* in the pattern, and the new
bullet's leading terminator now comes from detectEol(rawContent).
- roadmap.cts (cmdRoadmapAnnotateDependencies phase-boundary scan):
investigated, genuinely safe, left untouched. The .search(/\n#{2,4}
.../) boundary-finder and the [^\n]*-based heading match were
empirically verified on a 3-phase CRLF fixture -- the only stray \r
ends up at the tail of an intermediate phaseSection string that is
only ever used for .test()-based idempotency checks, never for an
exact-match comparison or written back to disk. No corruption on
round-trip.
Every fix re-verified: npm run build:lib clean, npx eslint
'src/**/*.cts' --no-cache reports 0 problems (was 13), and each
fixed function's existing LF-input tests were spot-checked unchanged.
* fix(#3413): apply orthogonal review findings
Two isolated review engines (correctness + security) ran against the
full diff and found three majors, one real security issue, and several
disclosure-worthy minors. All fixed or explicitly disclosed with
evidence; nothing deferred.
MAJOR — detectEol's tie-break contradicted its own documented contract.
Code returned '\n' on a 1:1 crlf/bare-LF tie; every doc (design doc,
CONTEXT.md, the function's own comment) says ties resolve to '\r\n'.
The existing test masked this by reusing the same tie fixture the
buggy code happened to satisfy, rather than a genuine LF-majority
case. Root cause: an Edit attempted earlier in this phase to fix this
exact arithmetic error was blocked by the tier guard, and a later
dispatch was incorrectly told it had already landed. Fixed: condition
is now crlfCount >= bareLfCount; the test fixture corrected to a
genuine 2:1 majority, with a new explicit tie-case test.
MAJOR — phase.cts's cmdPhaseInsert built an EOL-aware bulletEntry via
detectEol(rawContent), justified by a comment claiming a hardcoded
'\n' corrupts a CRLF ROADMAP.md. False: this write goes through
platformWriteSync, whose normalizeContent/_normalizeMd unconditionally
converts CRLF->LF for any .md target — the templating was inert dead
code, erased before the file is ever written. Reverted to hardcoded
'\n', comment corrected to state the true reasoning. The separate
[^\n]* -> [^\r\n]* widening one function up (a real splice-position
fix, independent of final EOL) was kept.
MAJOR — roadmap-upgrade.cts's stated rationale for switching onto
splitLines/joinLines was wrong (both functions always agreed on line
representation, before and after — the claimed equality-check risk
never existed), and the change it justified introduced a real
regression: forcing every line onto one dominant terminator silently
rewrites untouched lines' EOL on a mixed-CRLF/LF ROADMAP.md. This
write path uses raw fs.writeFileSync, not platformWriteSync, so unlike
the phase.cts case above the regression is genuinely live.
Fixing this took two attempts. The first attempt (revert to
split('\n')/join('\n') plus a suppression comment) was correctly
blocked by an agent that discovered local/no-crlf-fragile-split is a
PROTECTED_RULES entry in tests/portability-rule-disable-ban.test.cjs —
a hard, out-of-band, ADR-1703-governed guardrail banning any
eslint-disable of this rule anywhere in src/**/*.cts. That agent also
detected and correctly disregarded an injected instruction that
appeared in tool output during a git operation, per this session's
untrusted-content policy. The actual fix: computeMigrationPlan
reverted to roadmapContent.split('\n') (confirmed lint-clean — the
rule's data-flow tracking only follows a variable's initializer, and
this one is declared empty then reassigned in a try block).
applyMigration's write-back now splices edits against the ORIGINAL
content string via indexOf('\n', pos) boundary-walking instead of a
full split/rejoin, so every untouched character — including every
line's own terminator — is copied byte-for-byte. A capture-group split
(/(\r\n|\n)/, preserving terminators inline) was tried first and
empirically confirmed to still trip the rule before this approach was
chosen instead.
MINOR (security) — roadmap.cts's cmdRoadmapAnnotateDependencies used
the STRING form of String#replace, so $&, $`, $', $1-$9 inside
must_haves.truths content (author-controlled) were interpreted as
replacement directives, splicing unrelated ROADMAP.md text into the
result. Fixed with the function-replacement form, which is never
pattern-interpreted. Verified before/after with the reviewer's exact
repro.
Also disclosed rather than silently left: test matrix row 31 (four
planned CRLF-materialized regression tests) was never implemented as
separate files — corrected to record the actual verification (a
manual --check run plus incidental existing coverage via each script's
normalizeLineEndings: normalizeEol alias). parseMustHavesBlock's LF
behavior was claimed byte-for-byte unchanged but the old
yaml.indexOf(blockMatch[0]) substring search could match an unrelated
earlier occurrence of the header text (e.g. inside a quoted value) —
the split-then-scan fix incidentally also closes this, a strict
improvement now recorded in the design doc rather than left implicit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(#3413): checkpoint 2 red — missing eslint ignore entry, RuleTester config error
Checkpoint 2 came back red with 5 failures on the reviewed sha, both
gaps genuinely undetectable by any local gate.
eslint.config.mjs was missing the 'gsd-core/bin/lib/text-lines.cjs'
ignores-list entry (ADR-457: generated .cjs artifacts are excluded from
direct type-aware linting). Phase 1's sibling entry (pattern.cjs) sits
two lines above it and was the exact precedent read while researching
the six-gate ripple for this module -- missed anyway. Caught by
tests/repo-invariants.test.cjs's bin/lib coverage-tracking test, which
only runs on the remote suite.
tests/no-crlf-fragile-split.rule.test.cjs's row-32 case specified both
`messageId` and `message` on the same RuleTester error assertion --
ESLint's RuleTester rejects that combination outright. This existed
since the test was first authored and was never caught locally: `npx
eslint` only lints the file's syntax, it does not execute RuleTester,
and local `node --test` is hard-blocked in this repo -- the assertion
had never actually RUN before this checkpoint. It was even present in
checkpoint 1's failure list, listed there as one of the "expected RED"
tests; I matched it against my expected-failures list by test NAME
only and never inspected the actual failure detail closely enough to
notice it was failing for the wrong reason (a RuleTester config error,
not the intended message-text mismatch). Fixed by keeping `message`
(the exact-text assertion the test exists to make) and dropping
`messageId`. Verified the crlfFragileSplit message string in
eslint-rules/no-crlf-fragile-split.cjs matches this assertion
character-for-character, and swept every other invalid case in the
file for the same double-specification bug (none found -- all
pre-existing cases use messageId alone).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(#3413): add Fixed changeset for the #3360 CRLF parsing fix
The sole user-visible effect of this phase. No breaking-change label
or Changed fragment needed — ADR-3212's Backward Compatibility section
names the Node floor (Phase 1, already shipped) as the epic's only
breaking change; Phase 2 has none.
* chore(#3413): backfill changeset pr number to 3420
---------
Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
expectedQuickId() in tests/init.test.cjs computed its expected quick_id
using local-time Date getters (getFullYear/getMonth/getDate/getHours/
getMinutes/getSeconds) in the test-runner process, while the CLI
subprocess under test is pinned to TZ=UTC. The two sides only agreed
when the test-runner's own ambient TZ happened to already be UTC.
Swap the six getters to their getUTC* equivalents so the helper is
timezone-invariant. No change to src/init.cts (the CLI side already
relies on its own UTC-pinned subprocess env and is correct) and no
change to the pinned clock instants or expected quick_id strings
asserted by the three affected tests.
Introduced by 80734a969 (#3332).
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
Adds one `type: "eos"` entry for a Reasonix host integration and regenerates
docs/registries/eos-registry.md.
Reasonix is a Go, single-static-binary, DeepSeek-native terminal coding agent.
The integration renders the GSD workflow set as native Reasonix slash-command
launchers; GSD workflows are not reimplemented.
Every axis is sourced from Reasonix's own docs per the never-infer rule.
`effortSurface` is omitted: Reasonix resolves effort from config and the
/effort command with no argv route, so neither allowed value fits and the
field is optional.
Supersedes #3346, which proposed an in-tree capabilities/reasonix entry and
was closed as filed against the wrong surface.
Co-authored-by: onionviolet <apexofficial21@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
* test(#3412): failing-first suite for the pattern-construction seam
Phase 1 of epic #3212 (ADR-3212 §1/§2/§7). Tests only — src/pattern.cts
and eslint-rules/no-adhoc-regex-escape.cjs do not exist yet, so both
suites fail with MODULE_NOT_FOUND, which is the intended RED.
Locks the measured behavior rather than the assumed behavior:
RegExp.escape hex-escapes the leading character of nearly every string
("abc" -> "\x61bc"), so the suite asserts match-equivalence against an
inlined historical oracle (the implementation being deleted) rather
than byte-equivalence of pattern text — 200 seeded fast-check runs plus
a fixed corpus, 0 mismatches. Also locks the latent character-class
range bug this phase fixes as a side effect: a hyphen-bearing value
interpolated into [...] currently forms a real range and matches an
unintended character; post-migration it must not.
* chore(#3412): src/pattern.cts owns runtime-value regex construction
Phase 1 of epic #3212 (ADR-3212 §1/§2/§6/§7). Adds the pattern seam
delegating to the built-in RegExp.escape, deletes every hand-rolled
copy, and raises the Node floor to the Active LTS line.
The census was low, three times over. ADR-3212 counted 10 copies; a
graph query found 12; the new lint rule — once live — found 27 more.
The difference is that the census counted named helper FUNCTIONS while
the rule counts the escape SHAPE, so inline .replace(<class>, '\$&')
copies were never in scope. ADR §1's actual requirement is that no
module outside the seam escapes a value for regex use, so all of them
are, and CLAUDE.md's no-defer rule makes them this change's work.
Fourth consecutive epic here whose copy count was low — the argument
for ADR-3180 Amendment 3's "state N found by the guard" rule.
Also corrected mid-implementation: the survey reported phase-id.cts's
escapeRegex had 0 external importers. It had 8 production importers,
making its removal a public-surface change to an ADR-2121-owned module
and requiring an update to that ADR's locked-surface test. Blast
radius revised Medium-High -> High.
RegExp.escape is match-equivalent but NOT text-equivalent: it
hex-escapes the leading char of nearly every string ("abc" ->
"\x61bc"). Equivalence is proven by a seeded fast-check property test
against the deleted implementation as oracle. It also fixes a latent
bug: a hyphen-bearing value interpolated into a character class
previously formed a real range and matched an unintended character.
Node floor 22 -> 24 (RegExp.escape is Node 24+), across engines,
.nvmrc, package-lock, 9 CI matrix entries, and 5 docs. The aggregate
`required-tests` context is unchanged and no job was added or removed,
so branch protection cannot be orphaned by the dropped lanes.
Enforced by eslint-rules/no-adhoc-regex-escape.cjs (shape-matched, with
structural provenance for reviewed pattern-fragment constants rather
than a name heuristic) plus a whole-tree companion guard covering the
directories ESLint's globs miss.
* fix(#3412): close the _SOURCE guard evasion, correct two false claims
Three findings from the orthogonal review pass, all fixed.
1. The ESLint rule's `_SOURCE` provenance fallback was pure identifier-
name matching with no binding check, so `new RegExp(userInput_SOURCE)`
— a function parameter — sailed past the guard. That is the same
rename-evasion class issue #3410 documents, reopened by the very
fallback meant to complement the structural check. Now bound to the
identifier's actual binding kind: import, require-derived const, or
module-scope const; parameters, `let`/`var`, and unresolvable
bindings fail closed. Four RuleTester cases cover the evasion and
prove the legitimate cross-module case still passes.
2. src/pattern.cts's own header carried the stale pre-correction counts
(12 copies / 17 call sites) while CONTEXT.md and the design doc
carried the corrected ones (~39 / ~44) — a self-contradiction inside
the PR whose entire purpose is deleting divergent copies. Rewritten,
preserving the durable lesson: a named-function census cannot see
inline copies; only a shape-matching guard can.
3. The claim that all deleted copies threw TypeError on non-string was
false. phase-id.cts's copy — the one with 8 external importers — did
String(value).replace(...) and never threw. The seam's locked
signature does not coerce, so this is a real, now-disclosed behavior
change rather than the pure preservation the tests asserted. Audited
all 32 invocations across the 8 importers and 6 in-file callers:
every one is safe by construction (upstream truthy guard or a
string-producing derivation), verified by runtime probe against the
compiled modules rather than by TS compilation, which cannot see a
runtime undefined. Corrected the false claim in both the test comment
and the design doc, and added it to Known limits.
* docs(#3412): add Changed changeset for the Node 24 floor
The only user-visible break in this phase. The escape-behavior change
is internal and match-equivalent, so it carries no user-facing note.
* fix(#3412): resolve the seam's require graph in script fixtures and packaging
Checkpoint 2 came back red with 90 failures on the node24 lane. Three
distinct defects, all introduced by routing scripts/ through the new
pattern seam, none reproducible by any local gate:
1. ~82 failures — tests/adr-index-gate.test.cjs and
tests/removed-but-needed-lint.test.cjs copy a scripts/*.cjs into an
mkdtemp fixture and spawn it there (necessary: those scripts resolve
their scan root from __dirname/.., so running the real script would
scan the real repo). Each harness hand-listed the dependencies to
copy alongside. Adding require('../gsd-core/bin/lib/pattern.cjs') to
gen-adr-index.cjs made both lists silently incomplete ->
MODULE_NOT_FOUND, plus 17 downstream 'did not emit parseable JSON'
failures from the same crash.
Fixed as a class, not an instance: new tests/helpers/copy-script-
fixture.cjs walks a script's transitive static relative-require graph
and copies it, so dependencies are derived and never re-declared. It
throws (naming the unbuilt artifact) instead of letting the child die
with a bare MODULE_NOT_FOUND. Verified for all four seam-consuming
scripts: gen-adr-index, lint-removed-but-needed, gen-loop-host-
contract, sync-runtime-launcher.
2. 2 failures — scripts/ ships wholesale but eslint-rules/ does not, so
the new scripts/lint-no-adhoc-regex-escape.cjs would be
MODULE_NOT_FOUND in a published install (#2858 guard). Excluded from
the tarball, matching the existing precedent for gen-emitted-
baseline.cjs, which is excluded for the identical reason, and locked
with a test modeled on that one. Confirmed against a real npm pack:
890 files, 0 from eslint-rules/, and gsd-core/bin/lib/pattern.cjs
present (so the other four scripts' requires are legitimate).
3. 6 failures — tests/phase-id.test.cjs asserted the literal escaped
source text ('0*29', 'PROJ-42'). RegExp.escape is match-equivalent to
the retired hand-rolled escaper but NOT text-equivalent: it hex-
escapes the leading character and all hyphens ('0*\x329',
'\x50ROJ\x2d42'). Verified NOT a behavior change — 576 match
decisions across all three real interpolation prefixes, zero
divergence. Those tests now compile each source into the same heading
regex src/roadmap.cts's searchPhaseInContent builds and assert what
matches and what does not, including the 'i'-flag canonicalization
the hex escape has to preserve. Re-pinning the new literals would
have rebuilt the same brittleness one layer down. Adds a test for the
property the escape exists for: a dot in '1.2' must not act as a
wildcard.
Also shares one definition of 'a require' between the packaging guard
and the fixture copier, so the two cannot disagree about what they scan.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(#3412): refuse to copy a fixture dependency outside the fixture root
copyScriptWithDeps resolved each relative require and joined the
repo-relative result onto fixtureRoot. A require resolving OUTSIDE the
repo yields a '../'-prefixed relative path, so path.join climbed out of
the fixture and wrote into the surrounding temp dir (verified:
repoRoot=/repo + depAbs=/etc/passwd wrote /tmp/etc/passwd).
No script in the tree does this today, so this closes an available
escape rather than an active one. Refuses via the existing unresolved-
require path so the failure names the offending specifier. Covered by a
negative proof that the guard fires and that nothing lands outside the
fixture.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(#3412): parse requires instead of pattern-matching them; restore the foreign-prefix contract
Applies all findings from the second orthogonal review round, re-run
because real code changed after round 1.
HIGH (security) — extractRequires stripped BLOCK comments before LINE
comments, so a '//' comment containing '/*' opened a phantom block
comment, and a '//' inside a string literal truncated the line. Both
hid real requires: 'const u="http://x"; require("./real.cjs")'
returned [], and four real requires in gsd-core/bin/gsd-tools.cjs were
invisible. Replaced with a real AST parse via espree.
This is ADR-3212's own Decision 4 — tokenizer-first for stateful
grammars — applied to the case it describes; comment/string/regex
nesting is exactly such a grammar, which is why the regex version was
wrong. The function was moved byte-identical out of the #2858 packaging
guard, so the bug PRE-DATES this branch and has been a live blind spot
there: a shipped script could have required an unshipped path
undetected. Fixing it makes that guard strictly stronger than on next.
espree is promoted from a transitive eslint dependency to an explicit
devDependency rather than relying on hoisting. The script parse attempt
sets ecmaFeatures.globalReturn because Node wraps CommonJS bodies in a
function, making a top-level return legal — scripts/check-coverage-gate
.cjs relies on it, and without the flag the guard throws on a file it
is supposed to scan. Verified 0 unparseable across all 324 .cjs/.js
under scripts/, bin/, and gsd-core/bin/, and 0 new violations against a
real npm pack, so the exact extractor does not newly fail the guard.
MEDIUM (security) — the repo-containment check guarded dependencies but
not the entry path. One escapesContainment predicate now guards both.
LOW (security) — containment was lexical while fs follows symlinks, and
a directory symlink could mint a fresh dedupe key per level. realpath
now resolves both repoRoot and each dependency before the decision, and
the realpath-derived path is the dedupe key. Destination layout still
uses the original repo-relative path, so copied trees are unchanged.
MAJOR (standards) — the round-1 behavioral rewrite of phase-id tests
lost the foreign-prefix contract: every assertion was satisfied by an
impl returning [A-Z]+\x2d42, i.e. ANY project code — the exact #3599
bug class the exact-source prevents. The literal assertions it replaced
were catching this. Now asserts the compiled regex REJECTS a different
prefix with the same number.
MAJOR (standards) — the test hand-duplicated production's heading regex
with no parity guard (CLAUDE.md's 'Generative Fix Divergence'). Removed
the parallel surface instead of policing it: src/roadmap.cts exports
buildPhaseHeadingRegex, searchPhaseInContent calls it, the test imports
it. Byte-identical .source and .flags verified for both escaped forms.
MINOR — '..foo' no longer false-flagged as an escape; the inverted
spurious-vs-missing doc claim corrected; the dead allow-test-rule
header removed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* chore(#3412): backfill changeset pr number to 3416
* fix(#3412): make the escape guard's own regex linear, reword an injection-scan collision
Two CI failures on PR #3416, both in code this branch added.
CodeQL js/redos (high) — REPLACE_CALL_RE's outer alternation let a
bracket run be consumed EITHER by the character-class branch OR one
character at a time by the trailing catch-all, so a failing match
explored both parses of every pair. Measured on the real regex:
n=26 -> 204ms, n=28 -> 791ms, n=30 -> 3475ms, a clean 2^n. This script
scans repo source, so a file with a long bracket run after '.replace(/'
would hang CI outright — a guard against undisciplined pattern
construction was itself the worst pattern in the diff.
Fixed the way ADR-3212 already prescribes: the catch-all branch now
excludes '[' and ']' so a bracket can only be consumed by the class
branch (this is what makes it linear), and every quantifier is bounded
(the locked bounded-quantifiers decision) as a second line of defense.
Now 0ms at n=2000. Disclosed coverage tradeoff, recorded at the
constant: a regex literal with a BARE unescaped ']' outside a class is
no longer matched by this backstop. No census shape has that form, and
the AST rule remains the primary detector.
Verified the guard did not go blind doing it: a real census-shape
violation is still reported, and an allow-adhoc-regex-escape
suppression comment is still honored.
Regression test drives the exported findViolations on a
2000-repetition adversarial input and asserts the RESULT. It makes no
wall-clock assertion — elapsed-time tests are forbidden — so a
regression surfaces as a harness timeout, which is the correct signal.
Prompt injection scan — 'must not act as a regex wildcard' in a test
comment matched the scanner's jailbreak pattern act\s+as\s+(a|an|if|
my). Reworded to 'behave as'. Deliberately NOT allowlisted: silencing a
whole test file over one phrase would blunt the scanner permanently,
and the comment has nothing to do with injection.
Neither failure was reachable from the remote runner — CodeQL and the
injection scan are not in that matrix, so the sha it passed was green
and still wrong.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(#3025): refuse cross-runtime skill sync in sync-skills
Skill content and directory layout are runtime-specific — the installer
applies per-runtime converters, adapter headers, brand swaps, and layout
rules at install time, and grok/gemini resolve to ANOTHER runtime's skills
root. A verbatim cross-runtime cp -r therefore produces content the installer
would never have written for the destination, and can damage a runtime the
user never named. #3024 (closed) un-masked this, making the corruption live.
Fix (option b, user decision): add a functional Step 1 guard that refuses
any --to != --from with an actionable installer pointer, before any
resolution or copy. Identity sync (--from == --to) remains a no-op. The
non-functional Step 5 comment is replaced; Arguments/Limitations updated.
Regression: tests/sync-skills-cross-runtime-refuse.test.cjs (source-text-
is-the-product) asserts the guard exits non-zero for cross-runtime, points
at the installer, precedes the cp -r copy, and preserves identity.
* docs(#3025): backfill changeset PR number (#3404)
---------
Co-authored-by: sim <sim@local>
CI caught what the bench run didn't: "row 12" (every NONE-risk action
must actually apply) called applyRepairs('/fake/cwd', ...) — a literal
path that doesn't exist on disk. This was fine when applyRepairs's
handlers were stubs (pre-migration skeleton), but real handlers now
read/write actual files: createConfig writes config.json,
addNyquistKey/addAiIntegrationPhaseKey read it before patching. Against
a genuinely non-existent path these now correctly fail (ENOENT), and
an earlier fix in this same PR (applied only receives a code on real
success) correctly surfaces that as a failure instead of masking it —
so 3 of 4 codes stopped landing in `applied`, deterministically, on
any environment that actually enforces ENOENT against /fake/cwd.
Uses a real temp project (createTempProject + a valid config.json)
instead. Row 11 (DESTRUCTIVE refusal) and the ADVISE-skip test are
unaffected — both paths return before any handler touches the
filesystem, confirmed by reading applyRepairs's dispatch order.
REQ-HEALTH-05 said --repair auto-fixes recoverable issues without
qualification — now inaccurate since DESTRUCTIVE-risk remedies are
reported but never auto-applied. Adds REQ-HEALTH-06 for --backfill,
previously unmentioned in this requirements register.
docs/COMMANDS.md's /gsd-health section never documented --backfill at
all, and predates this phase's breaking changes: --repair no longer
auto-applies resetConfig/regenerateState (both destructive), and W021/
W017 split into W026/W027 for their previously-conflated second
subjects. Required by lint:docs, which needs a docs/ touch alongside
any Changed-type changeset fragment.
Both tests were written against the pre-fix behavior and never updated
once the real fixes landed:
- state-consistency.test.cjs's "KNOWN GAP" test hardcoded the
expectation that W002 incorrectly fires for a STATE.md phase
reference whose only home is an archived milestone — that gap is
now closed (0 diagnostics, confirmed against real buildPlanningSnapshot
output), so the test is renamed and its expectation flipped.
- worktree-health.test.cjs's two W020 tests asserted the OLD single
combined "timed out or failed" message/remedy — verified against
the real pre-migration src/verify.cts:2204-2219 that git_timed_out
and git_list_failed always had distinct messages; the fix that
restored this distinction is correct, these tests just never
caught up to it.
gsd-test found two independent gaps around the newly-generated
health.md tables:
- emitted-attribution.test.cjs requires an acknowledgment for
health.md's 2271-byte growth (16-code hand-maintained table -> 34-row
generated table, this phase's explicit acceptance criterion). Adds
tests/emitted-drift-acks/3309-health-docs-generated.json. Removes
2573-state-head-freshness.json's now-inert health.md entry (that
fragment's growth already landed on origin/next, the diff base this
branch is compared against, so it has nothing left to acknowledge —
and the guard forbids two fragments naming the same path).
- runtime-converters.test.cjs's health.md content-consistency checks
asserted stale text from the old hand-written table: a regex that
false-positived on the new table's own unrelated W020 row (worktree
scan degradation, a different diagnostic than the W025 isolation
warning it was meant to detect), and anchors expecting the old
table's exact last row / footnote wording. Narrowed the regex to
require the literal use_worktrees config key, and updated the
anchors to the new table's real shape (I001/I010 as the last rows,
the new generated-table footnote).
gsd-test found buildWorktreeHealthField collapsed every
inspectWorktreeHealth failure reason (git_timed_out, git_list_failed,
not_a_git_repo) into one UNREADABLE scope, discarding which one. The
migrated checkW020 then warned unconditionally on any UNREADABLE
scope — but the original (verify.cts:2202-2217) only warned on
git_timed_out/git_list_failed, staying silent on not_a_git_repo (a
.planning/-only fixture with no git repo at all is not a degraded
scan, just the absence of one). This spuriously degraded every test
fixture that isn't a real git repo.
planning-snapshot.cts's worktreeHealth field now carries `reason`
through instead of discarding it; checkW020 branches on it exactly
like the pre-migration code did.
gsd-test found the migrated W023 dropped a piece of information the
original message included: each colliding phase directory's overall
status (e.g. "Complete"), not just its raw plan/summary/verification
counts. Adds derivePhaseStatusLabel, reconstructing the status label
from already-exposed PhaseSnapshot fields (planCount/summaryCount/
complete/verificationStatus) — no new ambient I/O, no new snapshot
field.
Also fixes a non-conforming test fixture found while verifying:
tests/health-validation.test.cjs's "05-real" fixture used a bare
VERIFICATION.md, which readVerificationStatus never matches (the real
convention, and every other fixture in this repo, use the
*-VERIFICATION.md suffix) — the status was always reading as "missing"
regardless of message formatting. Renamed to 05-real-VERIFICATION.md.
gsd-test found three real regressions in the migrated STATE.md checks:
- W002 didn't exempt phase refs whose only directory lives in an
archived milestone (#3652) — now consults planning-snapshot.cts's
archivedPhaseTokens field (added alongside this fix, shared with
W006's identical need).
- W011 (STATE/ROADMAP cross-validation) never fired: currentPhaseLabel
only read the current template's bare "Phase:" field, silently
missing legacy STATE.md fixtures that use the older bold
"**Current Phase:**" field (mirrors state.cts's own resolveStatePhase
fallback ladder, which the migration didn't carry over).
- W026 (STATE milestone-complete vs. unstarted ROADMAP phases) had two
independent defects: roadmapDeclaredPhases's milestone attribution
can't see <details>/<summary>-shaped ROADMAP sections, and current-
milestone resolution could go null — both silently emptied the
"unstarted" set every time. Fixed by scoping ROADMAP.md to the
current milestone via the same <details>-tolerant extractCurrentMilestone
every other milestone-aware consumer uses, in a new dedicated
planning-snapshot.cts field (currentMilestoneRoadmapPhaseIds) rather
than reusing roadmapDeclaredPhases, which exists for a narrower,
<details>-blind derivation (W021's own original logic) and would
have regressed it if repurposed.
Also fixes an unrelated drift-guard violation this same rule file
introduced: its own phase-token regex was independently re-derived
instead of built from the canonical PHASE_NUMBER_TOKEN_SOURCE.
gsd-test found two real regressions in the migrated W006/W007:
1. A phase whose directory lives under an archived milestone
(.planning/milestones/v*-phases/<phase>/) instead of the active
phases/ dir read as "in ROADMAP but no directory on disk" — the
original forEachArchivedPhaseToken(planBase, ...) fed archived
tokens into the same existence check (verify.cts:2038); the
migrated rule's allPhaseDirNames never included them.
2. Comparing a ROADMAP-declared phase id against a disk directory name
dropped phaseVariants() normalization the original ran as a second,
independent check (verify.cts:2071-2073/2092-2093) — a ROADMAP
"01A" and a disk "1A-..." read as mismatched instead of the same
phase, since matchPhaseDirs's own token comparison never unifies
that padding/letter-suffix difference.
Adds planning-snapshot.cts's archivedPhaseTokens field (mirrors
forEachArchivedPhaseToken/listMilestoneArchiveDirs exactly, no new
regex derivation) and a phaseVariants()-based fallback in
dirsForPhase when matchPhaseDirs finds nothing.
Three user-visible changes disclosed per CONTRIBUTING.md's changeset
convention: --repair no longer auto-applies DESTRUCTIVE remedies
(Changed), W021/W017 split into W026/W027 for their previously-
conflated second subjects (Changed), and --backfill alone now actually
works (Fixed, a latent-bug fix). pr:0 placeholder, backfilled once the
PR number is known.
Closes the issue's explicit acceptance criterion: "health.md's tables
are generated rather than hand-maintained, closing the 16-vs-30+
documentation gap structurally." The published roster listed 16 codes
against 30+ actually emitted; W010-W017 and W020-W023 had never been
documented.
Adds description/repairable as static fields on Rule (health-diagnostic-types.cts)
— generation needs a fixed, human-readable summary per code, distinct
from the dynamic per-instance Diagnostic.message a rule's check()
produces. repairable is true only when --repair will actually apply
the remedy: false for ADVISE-only rules AND for DESTRUCTIVE-risk rules
(regenerateState/resetConfig), which are described but never
auto-applied — matches verify.cts's diagnosticToIssueEntry semantics
exactly, after fixing E004/E005's static field to agree with it (both
were wrongly true, an inconsistency caught during this same commit's
own review, not left for later).
New scripts/gen-health-docs.cjs (--write/--check, wired into
lint:generated-sync) regenerates the two tagged table regions in
gsd-core/workflows/health.md from RULES (31 rules) plus the 3
pre-checks that stay outside the rule table by design (E001, E010,
I010) plus a small static Effect/Risk lookup for the 6 real repair
actions — including addAiIntegrationPhaseKey, live in code since an
earlier phase but never documented until now. 34 error-code rows, 6
repair-action rows. The table's old "grep verify.cts for the next free
number" footnote is rewritten to point at the rule table and its lint
guard instead.
Still said RULES ships empty and repair handlers are stubs — true when
the skeleton batch first wrote this glossary entry, false since the
migration landed (RULES holds 31 wired rules, applyRepairs has real
per-action handlers). Found by the Standards-axis orthogonal review.
W024's committed rule (state-consistency.cts) is a documented permanent
no-op — its real check runs in cmdValidateHealth itself, outside the
rule table, since readStateHeadFreshness needs a git-log shell-out no
Rule.check may perform. The guard's §8.5 fixture-proof check previously
"passed" for W024 only because some test file's title happened to
contain the string "W024" — not because any fixture actually proves it
fires, which it structurally never can. Found by the Spec-axis
orthogonal review.
Adds an explicit PERMANENTLY_INERT_CODES map (currently just W024,
with its reason recorded) that checkFixtureProofInvariant reports
separately from real coverage. The guard's PASS output now says
"30 covered by a real fixture, 1 exempted" instead of implying uniform
proof — a code with no coverage and no exemption entry still fails.
adviseRemedy() was defined identically in two of the eight rule-group
files (config-validation.cts, agent-install.cts) while the other six
repeated the same {action: ADVISE, risk: NONE, args: {command}} object
literal inline ~20+ times. Found by the Standards-axis orthogonal
review (Duplicated Code smell).
Moves the one-line helper into health-diagnostic-types.cts, the leaf
module every rule-group file already imports for its enums/types, and
uses it consistently across all 8 files. Pure mechanical refactor — no
ADVISE remedy's command text, code, or action changed.
applyRepairs pushed a diagnostic's code onto applied unconditionally
after the try/catch around runRepairAction, even when the handler
threw (caught, recorded in details with success:false) or otherwise
failed — making applied mean "attempted" rather than "succeeded," with
no test exercising the failure path. Found by the Spec-axis orthogonal
review.
applied now only receives a code when the repair actually succeeded;
a failed attempt is still fully recorded in details (success:false,
the error message) but no longer misreported as applied. Adds a
regression test forcing addNyquistKey to throw (ENOENT on a config.json
that doesn't exist) and asserts it lands in details, not applied.
The migrated checkW027 (stale worktree) dropped the pre-migration
exclusion of the CLI's own current worktree, since a Rule.check(snapshot)
has no cwd access (§8.1 rule 1 forbids ambient I/O) — flagged as a
disclosed regression during this phase's own design work, then
confirmed as a real, fixable gap by the Spec-axis orthogonal review
rather than an inherent limitation.
Fixes it properly instead of accepting the regression: buildPlanningSnapshot(cwd)
already receives cwd as its own input, so exposing it as snapshot.cwd
is not new ambient I/O, just surfacing an existing parameter — fully
consistent with §8.1 rule 2's "parsed value" allowance. checkW027 now
excludes the entry matching snapshot.cwd before flagging, matching the
original verify.cts:2233-2242 behavior exactly.
gen-inventory-manifest.cjs's cli_modules family did a flat readdirSync
of gsd-core/bin/lib/, invisible to anything shipped in a subdirectory.
Found while registering this phase's 8 health-diagnostic-rules/*.cjs
files in docs/INVENTORY.md (Standards-axis review) — the automated
manifest cross-check couldn't see them even though the manual
INVENTORY.md rows were correct.
Adds collectOneLevelSubdirs (mirrors the existing collectNested's
defensive statOrNull style) and merges flat + one-level-subdirectory
results into cli_modules's single sorted array, using the same
<subdir>/<file>.cjs key format INVENTORY.md's rows already use.
Regenerating the manifest surfaced that three OTHER existing
subdirectories (installer-migrations/, host-integration-adapters/,
observability/ — pre-existing, unrelated to this phase) were equally
invisible and had zero docs/INVENTORY.md rows at all. Added all 15
missing rows rather than leave a gap the fix itself just exposed.
Also fixes 3 pre-existing lint-legacy-dir-name violations in the
installer-migrations rows (legitimate references to the historical
get-shit-done -> gsd-core rename these migrations clean up — marked
with the guard's own gsd-allow-legacy-name exemption) and a stale
health-diagnostic.cjs row that still said "RULES ships empty."
root-existence.cts (E002, E003) and phase-structure.cts (W009) hardcode
a canonical hyphen-form slash command in their ADVISE remedy, same as
config-validation.cts's already-disclosed W016 — forced by §8.1 rule 1
(a Rule's check(snapshot) cannot call the runtime-resolved slash()
formatter, which needs cwd). Only config-validation.cts's own header
disclosed this tradeoff; the other two sites had it happen without
recording it in their own file. Adds the same disclosure to both,
matching the established convention. No behavior change.
Replaces cmdValidateHealth's hand-rolled addIssue/switch accumulation
(961 lines) with buildPlanningSnapshot -> evaluateRules -> map to the
legacy {code, message, fix, repairable} shape, bucketed by severity.
Two pre-checks (home-dir E010/I010, .planning/-root-missing E001) stay
outside the rule table entirely, per ADR-3180 §8.2 rule 4 ("no
precedence system") — building "some rules suppress others" into the
table would itself be the forbidden precedence system.
W024 (STATE.md commit-age freshness) also stays outside the table:
its committed rule is a documented permanent no-op (readStateHeadFreshness's
git-log shell-out is ambient I/O a Rule.check may never perform, and no
PlanningSnapshot field carries a commits-behind count). Migrating onto
the rule table as designed would have silently regressed 7 passing
tests in tests/health-validation.test.cjs — found while wiring this
function, kept as a real check in the wrapper instead (same I/O
license applyRepairs already relies on), fixed inline per this repo's
no-defer policy rather than accepted as a silent loss.
Ports the real repair-handler bodies (createConfig/resetConfig,
regenerateState, addNyquistKey/addAiIntegrationPhaseKey,
backfillMilestones) into health-diagnostic.cts's applyRepairs,
replacing the skeleton's stub. DESTRUCTIVE-risk remedies
(resetConfig/regenerateState) are refused by --repair — a disclosed
breaking change; repairable now means "an automatic repair will
actually run," not merely "a remedy exists to describe," so E004/E005
now report repairable:false. --backfill alone now actually triggers
backfillMilestones, fixing a latent bug where its gate was unreachable
without --repair also being set (verify.cts:2504, confirmed dead code
pre-migration).
Test updates distinguish the two explicitly-authorized behavior
changes (DESTRUCTIVE refusal, backfill-alone fix, W021->W026 split)
from preservation — every changed assertion is commented with why, and
new regression tests were added for both changes plus W021/W026
mutual independence. Drift-guard bookkeeping (bypass-baseline shrunk
to the one disclosed W024 exception, milestone-window and
phase-enumeration exemptions, test-file-count allowlist) updated for
the relocated/new functions this migration introduces.
Enforces ADR-3180 §8.2's 1:1 rule-code invariant (every code unique,
every severity a property of the Rule) and §8.5's fixture-proof
invariant (every code has a describe()/test() block naming it,
verified statically against tests/health-diagnostic-rules/*.test.cjs
and tests/health-diagnostic.test.cjs) for the new RULES table.
Adapted from the design doc's original plan of separate
tests/fixtures/health-diagnostic/<code>.* files: implementation used
inline temp-dir fixtures instead (mirrors tests/planning-snapshot.test.cjs),
so coverage is checked statically against test-file structure, mirroring
lint-fix-has-regression-test.cjs's house style. Wired into lint:ci
adjacent to lint-planning-snapshot-bypass-drift.cjs, its closest sibling.
Passes clean against the real tree: 31 codes, all unique, all covered.
Wiring all 8 rule-group files into health-diagnostic.cts's RULES array
created a genuine CJS circular dependency: each group file required
health-diagnostic.cjs back for the shared enums, and health-diagnostic.cjs
now required the group files forward, so the enums were undefined
mid-load (destructuring health-diagnostic.cjs's still-unassigned
exports).
Fixes it by splitting the enums/types (SEVERITY, REMEDY_ACTION,
REMEDY_RISK, Remedy, Diagnostic, Rule) into a dependency-free leaf
module, health-diagnostic-types.cts, that both sides import instead of
each other. health-diagnostic.cts re-exports the enums for existing
consumers. RULES is now the real concatenation of all 8 groups (31
codes — E001 intentionally stays a pre-check outside the table).
W020 (3 conditions), W017, W027 — git worktree list degradation,
orphan and stale worktree checks, migrated onto the frozen rule table
per ADR-3180 §8.2. W027 has a documented fidelity reduction: rules
have no cwd access, so it can no longer exclude the active worktree.
W024 (deliberately inert, no snapshot field yet for stale state_head),
W002, W011, W021, W026 — STATE.md cross-checks against ROADMAP/config,
migrated onto the frozen rule table per ADR-3180 §8.2.
E002, E003, E004, W001 — PROJECT.md/ROADMAP.md/STATE.md existence and
PROJECT.md section-completeness checks, migrated onto the frozen rule
table per ADR-3180 §8.2.
Mirrors the existing health-diagnostic.cjs / planning-snapshot.cjs
pattern: gitignore the compiled output and exclude it from eslint so
the generated JS isn't linted as hand-written source. Also adds the
CONTEXT.md glossary entry and INVENTORY.md rows for the new
src/health-diagnostic-rules/ directory.