Commit Graph

566 Commits

Author SHA1 Message Date
Tom Boucher
3eb1cede26 fix(#1880): distinguish a corrupt config from an absent one (epic #1879 Phase 1) (#2688)
* test(#1880): prove corrupt config is indistinguishable from absent

Failing-first. Encodes the issue's runtime repro: a trailing comma in
.planning/config.json currently yields source:builtin-defaults with
degraded:false - byte-identical to the file not existing - and the user's
entire configuration is silently discarded.

Asserts on the typed surface (CONFIG_REASON, _warnedUnusableConfig) rather
than diagnostic prose, per the ADR-1411 amendment's test-methodology clause
and CONTRIBUTING.md's raw-text-matching rule. IO failure is injected by
monkeypatching fs.readFileSync and restoring in t.after(), never chmod 0o000
(root bypasses mode bits).

Refs #1879

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

* fix(#1880): distinguish a corrupt config from an absent one

loadConfigResolved wrapped the read, the JSON.parse and the entire config
build in one try with one catch, so ENOENT, EACCES and SyntaxError all fell
through to the same defaults and the branches returned degraded:false -
actively asserting health over discarded configuration. A single trailing
comma in .planning/config.json silently replaced the user's whole config,
reporting source:builtin-defaults degraded:false, byte-identical to having
no config file at all.

ConfigResolution now carries a machine-readable reason. Genuine absence keeps
degraded:false / not_configured; a file that exists but cannot be used sets
degraded:true with config_unparseable or config_unreadable. The same split
applies to the root config and to ~/.gsd/defaults.json.

Control flow is deliberately unchanged. preflight_check reports cyclomatic
141 / cognitive 196 and 93 dependents on this function, with the guidance
that small edits beat one big one, so faults are CAPTURED at the existing
read sites and stamped onto the returns rather than the try/catch being
restructured.

Also carries the ADR-1411 amendment's wiring clause: loadConfig returns
.config alone to ~51 call sites and would never see the new field, so an
unusable file emits a deduplicated stderr diagnostic keyed on resolved path
plus errno. Without it the reason would be an unreachable field and the user
whose config was discarded would still get no signal - the actual defect.

Registers the config-loader seam in lint-resolution-provenance, which until
now guarded only agent-skills.

Caller audit: ConfigResolution.degraded has exactly one consumer outside this
module, cmdAgentSkills (src/init.cts:2259), which destructures
{config, source, degraded} - adding a field does not break it. Its --json IR
now reports degraded:true for a corrupt config, which is the intended fix and
the one observable behavior change.

Closes #1880

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

* fix(#1880): degrade when any config on the path is unusable, not just the last

Two defects found by isolated adversarial review of the first cut.

BLOCKER: the success-path return did not consult configFault. A corrupt ROOT
config whose workstream override happened to parse returned degraded:false /
reason:resolved - the root's settings silently dropped, which is the exact
failure this issue closes, reappearing for any project using workstreams. The
stderr diagnostic fired, so the out-of-band half worked while the in-band half
reported a clean resolve; a --json consumer saw health.

MAJOR: reason was derived from Object.keys(parsed) - the root+workstream MERGE
- so an empty workstream file inheriting a non-empty root reported resolved
despite carrying no settings. Emptiness is now judged on the file actually
read, snapshotted before normalizeLegacyKeys mutates it.

Also: corrects the ConfigResolution JSDoc, which still described the pre-#1880
degraded contract; adds a fast-check property asserting a PRESENT file is
never reported not_configured whatever its bytes (CONTRIBUTING.md parser
rule); and asserts the literal enum values so the provenance lint's
configured_empty/not_configured markers check real assertions rather than
incidental prose in test titles.

Refs #1879

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

* fix(#1880): reject valid JSON that is not a config object at the read seam

The fast-check property added in the previous commit failed on both node
lanes: a config.json containing 0, "str", [], null or true is valid JSON, so
it parsed "ok", then threw downstream in normalizeLegacyKeys, and the outer
catch reported not_configured - a PRESENT file reported as absent, which is
precisely the collapse this issue exists to close. The property asserts a
present file is never not_configured, and it caught it.

_readConfigFile now validates shape, not just parseability (ADR-227: check
the semantic shape at a trust boundary, not merely the type). A non-object
JSON document is an unusable config, reported config_unparseable.

Adds named regression cases for each non-object form alongside the property,
so the class is documented and not only randomly sampled.

Refs #1879

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

* chore(#1880): backfill changeset pr number (pr:0 -> 2688)

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

* test(#2452): record a fetch-time shallow failure instead of crashing

This guard failed CI on ubuntu-24 while passing on ubuntu-22 and
windows-24 for the same commit, and passed on other PRs. Not a flake and not
caused by the change under test - a real fragility in the test.

runnerDiff ran the base fetch OUTSIDE its try and only guarded the diff, so it
assumed the failure mode is always 'fetch succeeds, diff reports no merge
base'. At a shallow boundary that lands short of the merge base, git can
instead fail during the FETCH ('unable to parse commit' - the boundary
commit's parent is not available). Which stage git fails at is version and
transport dependent, so on some runners the error escaped runnerDiff and
crashed the test rather than being recorded as the ok:false the assertions
expect. Both stages mean the same thing for what this guard protects: a
shallow base ref cannot resolve the three-dot diff.

Also drops two assert.match calls against git's stderr prose. 'no merge base'
and 'unable to parse commit' are the same condition reported at different
stages, and CONTRIBUTING prohibits raw text matching on subprocess output.
The typed outcome (ok === false) is the contract; the tests now assert that
plus the presence of a cause.

Found while investigating the red lane on #2688; fixed here per the no-defer
rule rather than filed.

Refs #1879

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 00:09:13 -04:00
Tom Boucher
c07734216f fix(#2603): document kimi-code in the host-integration capability matrix (#2687)
* fix(#2603): document kimi-code in the host-integration matrix; correct 3 inherited axes

The matrix — ADR-1239's deployment source-of-truth — had a section for 18 of 19
installed runtimes but none for `kimi-code`, so its `hostIntegration` axes shipped
with no citation and no evidence quote.

Sourcing every axis independently against Kimi Code CLI's own docs (the issue's
explicit requirement — `kimi` and `kimi-code` are distinct products) showed three
values had been inherited from the Python `kimi` descriptor rather than sourced:

- `embeddingMode` imperative -> declarative. Kimi Code plugins are a
  `kimi.plugin.json` manifest plus markdown Skills with no in-process programmatic
  API (docs/en/customization/plugins.md) — the same shape as `codex`.
- `dispatch.nested` false -> true. The `coder` built-in "can dispatch its own
  nested sub-agents when a task decomposes naturally" (docs/en/customization/agents.md).
  The Python `kimi` CLI genuinely prohibits nesting; Kimi Code does not.
- `dispatch.maxDepth` 1 -> "undocumented". Nesting is documented but no depth
  bound is published, so the fail-closed sentinel applies over a guessed integer.

`dispatch.namedDispatch` deliberately stays `false`: GSD's kimi-code artifact
layout installs Agent Skills only (no `agents` kind), so no named GSD subagent is
registered with the host and `resolveDispatchType` maps every role onto
coder/explore/plan. Flipping it would reintroduce the dispatch failure recorded in
docs/migration/kimi-to-kimi-code.md. The matrix records the host-capability nuance
under Documentation gaps instead.

Behaviourally inert: `namedDispatch:false` already caps nested/maxDepth/background/
backgroundDispatch to false/0 in the effective axes (host-integration.cts:493-499),
and the install adapter is not selected by `embeddingMode` (install.js:543 always
uses the imperative adapter). The one visible effect is the curated profile pin,
which moves programmatic-cli -> declarative-cli.

Also fixes the axes legend, which omitted the `built-in-only` subagentToolkit
member that has been in the closed vocabulary since kimi-code shipped.

Same defect class and countermeasure as #2598: pin the corrected values and require
the matrix to agree with the descriptor, because a descriptor/matrix disagreement is
how the gap survived.

Closes #2603

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

* fix(#2603): report the maxDepth `undocumented` sentinel as a sentinel, not as malformed

Surfaced by the orthogonal review of this change. `negotiateHostCapabilities`
emits a sentinel-specific warning for every dispatch sub-axis carrying the
documented `undocumented` value — namedDispatch, nested, background,
subagentToolkit, backgroundDispatch, isolation — except `maxDepth`, which fell
through to the numeric guard and reported `host dispatch.maxDepth is missing or
not a number — treating as 0`.

That message is indistinguishable from a genuinely malformed descriptor, so a
correctly fail-closed descriptor reads as broken. Six shipped runtimes carry the
sentinel here (antigravity, augment, opencode, trae, windsurf, zcode) and this
PR's kimi-code correction adds a seventh, which is why it is fixed here rather
than left in place.

The numeric guard keeps firing for genuinely malformed values; both paths still
degrade `effective.dispatch.maxDepth` closed to 0. Covered by three tests,
including the boundary case that the sentinel carve-out must not swallow a real
malformed value.

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

* chore(#2603): backfill changeset PR number (#2687)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 22:52:57 -04:00
Tom Boucher
a5633bb32f enhance(#2671): brand raw vs calibrated token types so double-application is a compile error (#2676)
* test(#2671): add failing-first brand-typing compile fixtures

* feat(#2671): brand raw vs calibrated token types

* refactor(#2671): hoist type-compile into a before() hook

Two review responses:

- The fixture compile ran in the describe() body, so it executed at
  collection time even when the block was filtered out, and a failed
  precondition collapsed eight independent assertions into one opaque
  describe-level failure. A before() hook is this repo's documented
  idiom and preserves per-test granularity.

- parseTokensFlag now records WHY it returns an unbranded number: it
  validates the magnitude of --tokens, but the basis is decided by
  --calibrated, so branding here would be wrong for half its callers.
  The assertion belongs to cmdEstimateCheck, its only caller.

* test(#2671): pin each brand diagnostic to its OFFENDING marker

Adversarial review demonstrated that asserting only exactly-one-diagnostic-
at-code-N is not airtight. Repairing a fixture's brand violation while
injecting an unrelated error of the same code (a string passed as the
budget argument) still yielded exactly one TS2345, so the fixture would
have reported green while no longer testing its regression at all.

Each bad-* fixture now routes its violating value through a const named
OFFENDING, and the test asserts the diagnostic's start offset falls inside
that node — located through the AST, so it survives reformatting and never
pattern-matches source text. Replaying the proof-of-concept against the new
assertion rejects it: the diagnostic lands on the budget literal, not the
marker.

Also corrects a doc comment that claimed the program type-checks all of
src/; it covers phase-estimation.cts and its transitive dependencies.

* chore(#2671): backfill changeset PR number (#2676)
2026-07-26 21:42:50 -04:00
Tom Boucher
0d08c32048 fix(#2590): emit Workflow scripts the Workflow tool accepts; make the backend reachable (#2681)
* fix(#2590): emit Workflow scripts the Workflow tool accepts; make the backend reachable

Every emitted script was rejected. Four invalid constructs, the first fatal on
its own, so the Workflow backend could never dispatch a wave:

  1. no `export const meta = {…}` first statement -> whole script rejected
  2. resumeFromRunId("<id>")  -> "resumeFromRunId is not defined". It is a
     Workflow TOOL INPUT parameter, not a script function. The run id still
     reaches the caller via summary.resumeRunId, to pass as that input.
  3. budget(<n>)              -> "budget is not a function". `budget` is a
     read-only object { total, spent(), remaining() } fed by the caller's token
     directive; a script cannot set it. Recorded as intent in a comment.
  4. parallel(agent(…), agent(…)) -> "parallel() expects an array of functions".
     Now parallel([() => agent(…), …]) — passing agent() results directly also
     started every agent eagerly, before parallel() could bound concurrency.

The single-plan stage had its own branch with the same parallel() defect; both
branches are now one array-emitting path. Waves also emit phase() calls whose
titles match meta.phases exactly, so progress groups correctly.

Two secondary defects kept the script from ever being REACHED — which is why
this shipped undetected:

  5. NOTHING resolved the Agent SDK version. The fragment claimed there was "no
     scriptable way" to introspect it and told callers to omit the flag, so
     gate 5 returned agent_sdk_version_unknown on every automated run while
     `capability state` still reported active:true. True for bash, false for
     Node: the router now reads the installed @anthropic-ai/claude-agent-sdk
     version, walking node_modules up the tree and reading package.json
     directly — require.resolve throws ERR_PACKAGE_PATH_NOT_EXPORTED because the
     SDK's exports map does not expose ./package.json. Precedence: explicit flag
     > GSD_AGENT_SDK_VERSION > installed. Fail-closed is preserved; an
     unresolvable version still declines to inline. A too-old SDK now reports
     the truthful agent_sdk_version_below_floor instead of unknown.
  6. The runtime fallback was `--runtime > GSD_RUNTIME > 'unknown'`, diverging
     from the canonical `GSD_RUNTIME > config.runtime > 'claude'`, so any
     invocation without --runtime reported runtime_not_claude on an ordinary
     Claude project. Now delegates to runtime-slash.resolveRuntime.

The fragment's `${AGENT_SDK_VERSION:+--agent-sdk-version "$AGENT_SDK_VERSION"}`
snippet is removed rather than repaired: it was also shell-dependent — zsh does
not word-split unquoted parameter expansions, so it collapsed to a single argv
element, argValue() never matched, and the run failed into the same
agent_sdk_version_unknown, indistinguishable from genuinely unknown. Auto-
resolution removes the need for the construct entirely.

Verified with the issue's own repro: no flags now reaches the version gate; an
SDK above the floor yields backend:"workflow" with a script that parses as a
real ES module.

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

* fix(#2590): sync generated registry, repair sibling tests, reject duplicate wave ids

Findings from the isolated review, all fixed.

HIGH — gsd-core/bin/lib/capability-registry.cjs was stale, and `lint:ci` was
already RED because of it. The registry embeds the fragment text INLINE, so the
shipped/installed copy still taught the exact broken contract this PR fixes:
the old `${AGENT_SDK_VERSION:+…}` bash line and the "OMIT the flag when unknown"
guidance. Regenerated. (I had read `lint:ci` by grepping its output instead of
checking its exit code, so I recorded a red chain as green — checking $? now.)

HIGH — three existing tests asserted the OLD broken shape and would have failed
CI; none was touched by the first commit:
  tests/fix-2285-claude-orchestration-wiring.test.cjs — matched resumeFromRunId("…")
  tests/claude-orchestration.test.cjs                 — .includes('budget(')
  tests/claude-orchestration-command-router.test.cjs  — .includes('budget(')
Each now asserts the corrected contract: the id/pool reaches the caller via
summary, and neither construct is ever CALLED. Two sibling assertions had also
gone vacuous — `.includes('resumeFromRunId')` still passed, but only because the
new explanatory COMMENT contains that substring, not because anything is wired.
Rewritten to assert the real property.

MEDIUM — duplicate wave ids were never rejected. Plan-id uniqueness was checked
within a wave, but nothing checked wave ids across waves. That was harmless
before; it is not now, because each wave emits a `phase("Wave <id>")` call plus a
matching meta.phases entry and the tool matches titles by exact string — two
waves sharing an id would collapse into one progress group and misattribute the
second wave's agents to the first. Rejected at validation, with tests either
side of the boundary.

MEDIUM — the fragment contradicted itself (its "Manifest construction" header
still listed $AGENT_SDK_VERSION as orchestrator-built) and, more seriously, never
told the orchestrator to pass summary.resumeRunId as the Workflow tool's
resumeFromRunId INPUT. Since this PR moves resume from a broken in-script call to
a tool-invocation input, an implementer following only the fragment would have
silently regressed phase-resume to a no-op. Both fixed.

MEDIUM — docs/how-to/enable-claude-orchestration-workflow-backend.md and
docs/explanation/claude-orchestration-capability.md documented
`resumeFromRunId("<id>")` and `budget(<tokens>)` as current correct output —
teaching the bug as the feature. Updated to the real contract, including the
required meta block and the thunk-array parallel() form. (The changeset is
`Fixed`, so the docs gate exempts this; it is corrected because it is wrong,
not because a gate demanded it.)

LOW — the router's top-of-file comment still described the divergent
`--runtime > GSD_RUNTIME > 'unknown'` chain as current, ninety lines above the
fix; and inserting resolveInstalledAgentSdkVersion had orphaned
resolveDetectionArgs' JSDoc above the wrong function. Both repaired.

lint:ci now exits 0 (verified by exit code, not by reading output).

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

* chore(#2590): backfill changeset pr number (#2681)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 20:49:38 -04:00
Tom Boucher
c3958018dd docs(#2674): amend ADR-1411 — corrupt is not absent (epic #1879 Phase 0) (#2678)
* docs(#2674): amend adr-1411 with the corrupt-is-not-absent house pattern

ADR-1411 reasons only about a resolution miss. It is silent on input that
is present but not usable, which is how five engine read paths (#1879) could
fold an unusable input into the value meaning 'genuinely absent' without
contradicting an Accepted ADR.

Read together, ADR-1411 and ADR-227 converge and do not license throwing as
the cluster's answer: ADR-227 requires malformed input to be coerced rather
than propagated and carves out only genuinely-fatal fields, while ADR-1411
already permits a fallback provided it is 'a visible value, not a silent
substitution'. The defect in these five sites is therefore not that they fall
back but that they fall back invisibly.

Records the pattern that follows: every current return value is preserved, and
the cause is made visible in-band where the result already carries a
provenance envelope, or out-of-band via a deduplicated stderr diagnostic where
it returns a bare value it cannot extend. Throwing stays confined to ADR-227's
genuinely-fatal carve-out, decided per call. Also names the per-applier caller
audit and the lint-resolution-provenance registry gap.

Refs #1879

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

* test(#2674): prove the warning-state reset misses the unknown-key dedup set

The two existing cases in this suite only pass because each picks a key
name no other case reuses, so neither can observe whether the reset the
beforeEach calls actually runs.

Failing-first: asserts the exported _warnedUnknownConfigKeys is empty after
_resetRuntimeWarningCacheForTests(). It is not - the helper clears only
_warnedConfigKeys despite documenting itself as resetting per-process
warning state.

Refs #1879

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

* fix(#2674): reset the unknown-key dedup set with the runtime warning cache

_resetRuntimeWarningCacheForTests documents itself as resetting per-process
warning state but cleared only _warnedConfigKeys, leaving
_warnedUnknownConfigKeys populated across cases. The suite that exists to
test that set - 'loadConfig - unknown-key warning dedup' - calls the helper
in beforeEach expecting exactly this, so the reset was a silent no-op for
it; both cases passed only because each picked a key name the other never
reused. Any later case reusing a key would have had its warning suppressed
by leaked state.

Found while amending ADR-1411, which names this dedup guard as the pattern
five downstream PRs (#1880-#1884) will adopt - shipping the ADR without the
fix would have propagated the footgun to each of them. Folded in here per
CLAUDE.md's no-defer rule rather than filed.

RED verified on 3c4895841 (test only, no fix): linux-node22 reported
'FAIL tests/config-loader.test.cjs - the documented per-process
warning-state reset must clear the unknown-key dedup set too'.

Refs #1879

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

* docs(#2674): document src/ in the changeset-lint trigger list

CONTRIBUTING.md presented the Changeset Required trigger list as bin/,
gsd-core/, agents/, commands/, hooks/, sdk/src/ - omitting src/, which
scripts/changeset/lint.cjs has in USER_FACING_PREFIXES. src/ is the
TypeScript source of truth compiled into gsd-core/bin/lib/*.cjs, so it is
the most-edited user-facing path in the repo and the omission sends any
contributor who touches it into a CI failure the doc says cannot happen.

Also documents that the lint reads GITHUB_BASE_REF, which only CI sets, so
running it bare locally reports success without evaluating the branch. This
PR hit exactly that: a local run said ok_fragment_present and CI failed
fail_missing_fragment on the same diff.

Found while opening this PR; folded in per the no-defer rule.

Refs #1879

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

* docs(#2674): add Fixed changeset for the src/ trigger-list and reset fixes

Refs #1879

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

* docs(#2674): restore the round-2 review corrections to the amendment

These edits were made in response to the second isolated review pass but
never staged: later commits used targeted `git add <file>` for the test and
the source fix, so the two markdown files stayed dirty and shipped nothing.
The branch carried the round-1 text, including the ADR-227 misquote the
reviewer raised as a blocker.

Restores: the unconditional-diagnostic clause (ADR-227's GSD_DEBUG opt-in
was never implemented, so citing it as the precedent was wrong), the dedup
key, #1882 folded into the out-of-band mechanism instead of a fourth
mechanism-less category, the narrowed caller-audit rationale, and the
test-methodology clause.

Refs #1879

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 20:25:39 -04:00
Tom Boucher
c7c2fe3c2b fix(#2587): resolve cursor hook workspace from workspace_roots, not cwd (#2680)
* fix(#2587): resolve cursor hook workspace from workspace_roots, not cwd

gsd-cursor-session-start.js and gsd-cursor-stop.js both resolved the project as
path.join(process.cwd(), '.planning', 'STATE.md'). Under the cursor-agent CLI,
hooks are invoked with cwd set to the Cursor config dir (~/.cursor), not the
workspace — so the lookup always missed. sessionStart could only ever emit the
"no .planning/ workflow found" nudge and stop's verify-work reminder could never
fire, even with .planning/STATE.md sitting in the workspace. Slash commands were
unaffected, which is why only the hook layer looked blind.

Both hooks already buffered stdin into `raw` and never parsed it; the payload's
workspace_roots carries the real path.

Multi-root was left open in the report ("first root vs any root"). Resolved
forward: prefer the first root that actually carries .planning/STATE.md, so a
workspace whose GSD project is not the first root still resolves — strictly
better than first-root-only and identical to it in the single-root CLI case.
Falls back to roots[0], then to cwd, keeping IDE behavior unchanged if the IDE
ever invokes hooks from the workspace.

The resolver is duplicated verbatim across the two scripts rather than shared via
hooks/lib/: these hooks ship standalone, and a new hooks/lib/ file must be
registered in the GENERATED installer's GSD_HOOK_LIB_FILES allowlist — the
installer-omits-shipped-file class that yields MODULE_NOT_FOUND at runtime. Per
CLAUDE.md "Generative Fix Divergence", the duplication carries a parity assertion
so the copies cannot drift.

Failing-first, demonstrated by direct invocation with cwd != workspace:
  pre-fix  sessionStart -> "no .planning/ workflow found"   stop -> {}
  post-fix sessionStart -> ".planning/STATE.md is present"  stop -> reminder

tests/fix-2587-cursor-hook-workspace-roots.test.cjs spawns the real scripts as
child processes with a cwd lacking .planning/ and workspace_roots pointing at it.
Boundary coverage on the roots array (0 / 1 / 2 entries), plus malformed-JSON
fail-open, junk-entry filtering, the parity assertion, and a guard that neither
script resolves .planning from cwd again.

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

* fix(#2587): extend workspace_roots fix to subagentStart; keep cwd a candidate

Three findings from the isolated review, all fixed.

1. MISSED SITE (high). gsd-cursor-subagent-start.js carried the identical
   defect at line 43 — its own header documents workspace_roots in the input
   schema, but it resolved .planning/ from process.cwd() anyway. Under the
   cursor-agent CLI that meant every Cursor subagent (planner, executor,
   verifier) started with "no .planning/ workflow found" and no phase context.
   The report named only sessionStart and stop; the defect class was wider.
   Verified pre-fix vs post-fix by direct invocation with cwd != workspace.

2. SEMANTIC NARROWING (medium). The first cut searched only workspace_roots and
   fell back to cwd solely when the array was EMPTY. So when roots were supplied
   but none carried .planning/ while cwd did, the hook reported absent — where
   the pre-fix code, which always used cwd, reported present. That contradicted
   the fallback's own stated intent of preserving IDE behavior. cwd is now a
   CANDIDATE in the search (`[...roots, process.cwd()]`), so the fix is a strict
   superset of both the old behavior and the CLI fix, never a narrowing.

3. STALE GOLDEN FIXTURES (high, would have failed CI). The golden-install-parity
   fixtures store a content hash per installed file; these three hooks appear in
   13 of the 19 runtime fixtures. Regenerated via `npm run gen:golden` — the
   diff is exactly the three hook hashes in exactly those 13 runtimes.

Tests extended: subagentStart resolution via workspace_roots; the stop hook's
absent branch (previously only session-start's was covered); an explicit
regression guard that a project at cwd is still found when roots miss; parity now
asserts all THREE copies byte-identical; and the cwd guard sweeps the whole
RESOLVING_HOOKS list so a future hook in this family cannot be left on cwd.

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

* refactor(#2587): extract cursor workspace resolution to a shared hooks/lib module

The duplicate-plus-parity-test approach was the wrong call. The reported issue
named two hooks; a third (subagentStart) had the identical defect. That is the
signature of a systemic problem, and three copies of a resolver guarded by a
parity assertion is a divergence risk maintained by hand rather than a fix.

hooks/lib/cursor-workspace.js is now the single implementation. All three Cursor
hooks require it; none defines a local copy. Divergence is prevented
structurally instead of by asserting three copies stay byte-identical.

The reason duplication looked necessary was real, and is fixed properly here
rather than worked around: Cursor sets hostBehaviors.skipSharedHooksInstall
(#2089), so it never reaches the installer's bulk hooks/lib copy — it was the
ONE runtime shipping these hooks WITHOUT hooks/lib (verified against all 19
golden fixtures: cursor had the hook scripts, no lib). A naive require would
have thrown MODULE_NOT_FOUND at load, BEFORE each hook's own try/catch, wedging
every session on precisely the runtime this bug is about.

writeCursorHooksJson (src/runtime-hooks-surface.cts) now stages the hooks/lib
helpers the staged scripts actually require, discovered by scanning their
require('./lib/…') calls rather than a hardcoded name — so a future helper
cannot be silently omitted. This is narrower than flipping
skipSharedHooksInstall, which would wrongly pull in every shared hook.
cursor-workspace.js is also added to GSD_HOOK_LIB_FILES so uninstall and the
manifest manage it for the runtimes that do receive hooks/lib.

Verified against a REAL install (runMinimalInstall, cursor/global): the helper
is staged, and all three INSTALLED hooks resolve the workspace end-to-end from a
cwd that is not the project.

Also closes the review gap that the stop hook was excluded from the
cwd-candidate regression loop — it now sweeps RESOLVING_HOOKS. The byte-parity
test is replaced by a structural guard (every hook requires the shared module,
none redefines it) plus a new install test asserting the helper is staged and
the installed hook actually loads against it.

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

* fix(#2587): fail loud on a missing hook lib source; drop unsubstituted version marker

Two findings from the installer-focused review.

H1 — the staging step's `if (!fs.existsSync(libSrc)) continue;` silently defeated
the very guarantee it was added for. Reproduced: delete hooks/lib/cursor-workspace.js
from source, run the cursor install — it exits 0, prints "Done!", and ships the
three hook scripts with an EMPTY hooks/lib/. The installed hook then throws
`Cannot find module './lib/cursor-workspace.js'` at load, before its own
try/catch, wedging every session — and nothing surfaces until a user hits it.
The scan protected against a required-but-UNLISTED helper while leaving
required-but-MISSING wide open (typo, bad rebase, an accidental delete).
It now throws: a missing helper source is a packaging bug and aborts the install.

M1 — hooks/lib/cursor-workspace.js carried a `gsd-hook-version: <placeholder>`
marker that NOTHING substitutes: copyLibDir stamps .sh files only, and
writeCursorHooksJson's staging applies just the colon-to-dash rewrite. Verified
the literal was reaching disk on both the bulk (--claude) and Cursor
(--cursor) paths. hooks/lib/git-cmd.js — the only pre-existing hooks/lib/*.js —
carries no such marker, so this was newly introduced, not inherited. Marker
removed, matching that precedent, with a note on why. (The explanatory comment
deliberately does not spell the token out, or it would reintroduce the literal.)

M2 — the require-scan regex demanded the exact compact form, so
`require( "./lib/x.js" )` would silently fail to stage its helper and compound
H1. Now tolerant of interior whitespace and either quote style.

Regression test added for H1 — the reviewer confirmed the invariant had zero
coverage repo-wide: a source tree carrying the hooks but no hooks/lib/ must make
writeCursorHooksJson throw rather than produce a broken install.

Re-verified end to end: the missing-source case throws, no unsubstituted literal
ships, and the installed hook still resolves the workspace from a foreign cwd.

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

* chore(#2587): backfill changeset pr number (#2680)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 19:57:58 -04:00
Tom Boucher
bd570618d4 feat(#2632): executor actuals and the closed estimate-calibration loop (#2672)
* feat(#2632): record executor actuals and close the estimate calibration loop

* fix(#2632): calibrate against the raw projection so the loop converges

* test(#2632): add closed-loop convergence guard and codify the feedback-loop rule

* fix(#2632): pair calibration samples per plan; atomic write; amend adr

* chore(#2632): backfill changeset pr to 2672

* fix(#2632): retry renameSync on transient windows errnos and clean up the temp
2026-07-26 16:28:56 -04:00
Tom Boucher
89b673e40d feat(#2631): planner emits estimate and plan-checker surfaces the over-budget flag (#2670)
* test(#2631): failing-first planner estimate emission and over-budget surfacing

* feat(#2631): emit plan estimate and surface the over-budget split recommendation

* fix(#2631): extract sizing prose to references to fit planner and plan-phase caps

* fix(#2631): move estimate check to plan-checker; fix template regex and caps

* fix(#2631): restore ALWAYS split literal and keep gsd_run after the launcher preamble

* fix(#2631): invoke estimate-check after the launcher preamble in plan-checker

* fix(#2631): stop double-applying calibration; repair COMMANDS table and stale reference

* chore(#2631): backfill changeset pr to 2670

* chore(#2631): backfill changeset pr to 2670
2026-07-26 14:29:02 -04:00
Tom Boucher
115433bba6 fix(#2539): anchor commit phase-token detection; drop silent wrong-branch switch (#2669)
* fix(#2539): anchor commit phase-token extraction to the phases/ segment; drop silent switch-to-existing

cmdCommit auto-detected the commit's phase from --files with an unanchored
`match(/(\d+(?:\.\d+)*)-/)`, which returns the leftmost digit-run-then-hyphen
anywhere in the joined path. A project_code ending in a digit (PROJECT_V2) made
`.planning/phases/PROJECT_V2-07-name/…` match the `2-` inside `V2-` before the
real `07-` token, resolving phase 2. findPhaseInternal also searches archived
milestones, so an existing archived phase 2 produced a real branch name and the
silent `git checkout <existing-branch>` fallback switched the whole working tree
onto the wrong branch in the same call that then committed.

The extraction now anchors to the directory segment immediately under
`.planning/phases/` (or `.planning/milestones/<v>-phases/`) and runs it through
the existing project-code-aware extractPhaseToken helper — the single owner
shared by the other 6 call sites — rather than introducing a fourth independent
copy of phase-token-matching logic. The auto-switch keeps create-if-absent only
(the #1278 intent: ensure the branch exists before the first commit on it); it
no longer force-switches an already-checked-out working branch onto a different
existing branch.

Adds two regression fixtures: a digit-suffixed project_code + an archived phase
whose number collides with the trailing digit (the silent-wrong-branch case),
and a pre-existing phase branch that must not be silently switched onto.

* test(#2539): assert non-silent warning; hoist execFileSync; normalizePhaseName guard

Address orthogonal-review findings on the #2539 fix:

- Spec AC2 ('an auto-checkout mid-commit must never happen silently'): the
  no-switch path now writes a 'Warning: resolved phase branch X already
  exists; committing on Y instead' line to stderr when checkout -b fails
  because the branch already exists. The regression test captures stderr via
  spawnSync and asserts the warning, so neither direction of the branching
  resolution is silent.
- Spec AC3 ('reuse normalizePhaseName/extractPhaseToken/stripProjectCodePrefix'):
  the token-acceptance guard now runs the candidate token through
  normalizePhaseName and accepts it only when it normalizes to a numeric phase
  form, rather than the brittle 'token !== phaseDir && /\d/.test(token)' check
  that leaned on extractPhaseToken's undocumented dirName fallback.
- Standards (Duplicated Code): hoist execFileSync/spawnSync requires to the top
  of the 'commit command' describe block instead of inlining them per test.

* fix(#2539): build phase-token shape from PHASE_NUMBER_TOKEN_SOURCE (#2128 guard)

The acceptance guard regex in detectPhaseNumberFromFiles was a hardcoded
`/^\d+[A-Z]?(?:\.\d+)*$/i` — a literal re-derivation of the canonical
phase-number grammar, which the #2128 phase-id drift guard
(tests/phase-id-drift-guard.test.cjs) rejects unless sanctioned with a
`// phase-id-owner:` marker. Build it from the single-owner
PHASE_NUMBER_TOKEN_SOURCE export instead, so this read-side acceptance check
cannot drift from every other phase-token reader. gsd-test reported this as
2 failures (linux-node22 + linux-node24) on the prior commit.

* docs(#2539): backfill changeset pr: 2669
2026-07-26 14:14:28 -04:00
Tom Boucher
46ba02acde feat(#2630): phase-estimation module, smart-zone config key, and cli verbs (#2661)
* feat(#2630): add phase-estimation module, smart-zone config key, and cli verbs

* fix(#2630): document smart_zone_tokens, refresh golden fixtures, fix null-proto property assertions

* fix(#2630): align smart_zone_tokens write/read validation and harden estimation tests

* chore(#2630): backfill changeset pr to 2661
2026-07-26 01:42:47 -04:00
Tom Boucher
bf127b7d25 fix(#2565): route generate-claude-profile target through runtime policy (#2659)
* test(#2565): add failing regression for generate-claude-profile runtime target

cmdGenerateClaudeProfile hardcodes .claude/CLAUDE.md (project + global),
ignoring the runtime policy that #3163 wired into the sibling
cmdGenerateClaudeMd handler. These tests pin the parity contract for
project scope (codex -> AGENTS.md, env precedence, --output override,
claude preserved), global scope (codex -> <CODEX_HOME>/AGENTS.md, claude
preserved), and a divergence guard asserting both handlers agree on the
project instruction path for the same runtime.

Failing-first: all seven tests reproduce the bug on unmodified next.

* fix(#2565): route generate-claude-profile target through runtime policy

cmdGenerateClaudeProfile hardcoded .claude/CLAUDE.md for both project and
global scope, ignoring the runtime-aware resolution that #3163 wired into
the sibling cmdGenerateClaudeMd handler. The #3163 fix diverged when it did
not propagate here, so /gsd-profile-user kept writing Claude instruction
files on Codex installs (and other AGENTS-native runtimes: opencode, kilo,
kimi, antigravity, copilot).

Fix mirrors the proven #3163 pattern using existing policy primitives:
- Project scope resolves through getProjectInstructionFile(runtime) and a
  non-claude runtime wins over a stale claude_md_path (AGENTS-native
  projects must never write to CLAUDE.md).
- Global scope derives ~/.<config-home>/<instruction-basename> via
  getGlobalConfigDir + basename(getProjectInstructionFile), so codex lands
  at ~/.codex/AGENTS.md. Claude global is preserved byte-for-byte (no
  env-var drift beyond the prior hardcoded path).
- GSD_RUNTIME env var takes precedence over config.runtime.

A parity test asserts both handlers agree on the project instruction path
for the same runtime, guarding against future re-divergence
(CLAUDE.md 'Generative Fix Divergence' rule).

* docs(#2565): add changeset fragment for generate-claude-profile runtime fix

* docs(#2565): remove parenthetical product description from changeset

The product-name purity guard (#1777) flags 'ProductName (description)'
patterns in changeset fragments because the prose renders verbatim into
CHANGELOG.md at release time. The original fragment had 'Codex (and other
AGENTS-native runtimes)' in the bold header, which matched the banned
pattern. Reworded to drop the parenthetical; also trimmed the per-runtime
mapping (belongs in code comments, not changelog prose).

* docs(#2565): backfill PR number in changeset fragment

* test(#2565): isolate os.homedir() cross-platform in claude global test

The 'global scope: claude runtime writes to ~/.claude/CLAUDE.md' test set
only HOME to redirect os.homedir() at a tmpDir. On Windows, Node's
os.homedir() reads USERPROFILE (not HOME), so the child process still
resolved the real user profile and the path assertion failed (#2659 CI).
Set USERPROFILE alongside HOME so the isolation holds on both POSIX and
Windows. Production code is unchanged — it uses os.homedir() exactly as
the prior hardcoded path did.
2026-07-26 01:06:35 -04:00
Tom Boucher
ec681e3c21 fix(#2567): scope Paused At to ## Session + guard Last Activity date regression (#2660)
* test(#2567): add failing regression for stale state field overwrites

buildStateFrontmatter extracts Last Activity and Paused At from the full
STATE.md body via stateExtractField, which matches the first 'Field:' line
anywhere. Historical archive sections containing stale field-shaped lines
silently overwrite the correct frontmatter value on every sync, and because
the poisoning line stays in the body it regresses on the next write.

Same divergence class as Bug #2444 (which scoped Stopped At to ## Session
but did not propagate). Failing-first: all three tests reproduce the bug
on unmodified next (verified via the dedicated red run on the test-only
commit).

* fix(#2567): scope Paused At to ## Session + guard Last Activity date

Two complementary fixes for the stale-archive-overwrites-frontmatter bug
class, chosen per field semantics:

- Paused At is a session field: scope extraction to ## Session (via the
  existing matchSessionSection helper), exactly mirroring the #2444 fix for
  Stopped At. A stale 'Paused At:' line in an archive section can no longer
  win over the current value. Falls back to full body when no ## Session.

- Last Activity has no single canonical section (it appears in the
  preamble, ## Configuration, and ## Current Position across STATE.md
  layouts), so a section scope cannot reliably exclude archive copies.
  Instead guard the information-losing direction: when the body-derived
  date is OLDER than the existing frontmatter date, keep the existing
  value and description (preferNewerLastActivity). Applied at both the
  write seam (syncStateFrontmatter) and the read seam (cmdStateJson) so
  they agree. Non-date values pass through unchanged.

A first attempt scoped ALL current-state fields to the body preamble, but
that broke STATE.md variants where the fields legitimately live inside
## Configuration / ## Current Position (regressed 4 frontmatter.test.cjs
suites). This minimal fix targets only the two fields the issue names.

* docs(#2567): add changeset fragment for stale state field overwrite fix

* docs(#2567): backfill PR number in changeset fragment
2026-07-26 00:55:00 -04:00
Tom Boucher
6ee4349272 fix(#2537): extract offer_next step to references/ (~3.3KB headroom restored) (#2642)
* fix(#2537): extract offer_next step to references/ (~3.3KB headroom restored)

* chore(#2537): backfill changeset pr to 2642
2026-07-25 05:30:20 -04:00
Tom Boucher
e4dd0cbdd5 fix(#2523): normalize --files to repo-relative; reject out-of-repo; gate push on git-add exit (#2638)
* test(#2523): absolute + mixed + out-of-repo --files paths

* fix(#2523): normalize --files to repo-relative; reject out-of-repo; gate push on git-add exit

* chore(#2523): backfill changeset pr to 2638
2026-07-25 01:50:36 -04:00
Tom Boucher
6ad30f74b6 feat(#2584): Phase 3 — scheduler consumer + isolation adapters (#2635)
Final phase of #2584 (ADR-1239 Codex-binding amendment). execute-phase now negotiates dispatch.isolation and dispatches through the matching adapter, so a wave's independent plans run concurrently on six runtimes instead of one — with no runtime=== branch in the scheduler.

harness-worktree passes the host's declared isolation flag (claude, cursor); orchestrator-worktree creates the worktree via the Phase-2 verb and spawns the executor into it with the resolved argv/cwd (codex, opencode, kimi, kimi-code); none stays sequential. Undeclared/unknown/unresolvable isolation degrades to none — never an unisolated parallel run.

Fixes two shipped Phase-2 descriptors that per-host research found would fail at spawn: kimi lacked its headless flag (would launch the interactive TUI and hang the orchestrator), and kimi-code named a non-existent binary (Kimi Code installs as 'kimi'). Adds the worktree-path root confinement Phase 2 deferred here, and leading-dash guards on the resolver's prompt/cwd matching the existing git-argument guard.

Closes #2627

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 01:50:20 -04:00
Tom Boucher
461c744c31 fix(#2522): fold wrapped success-criteria lines into their criterion (#2637)
* test(#2522): wrapped + blank-line success-criteria parse

* fix(#2522): fold wrapped success-criteria lines into their criterion

* chore(#2522): backfill changeset pr to 2637
2026-07-25 00:36:05 -04:00
Tom Boucher
4a66d62d10 feat(#2584): Phase 2 — worktree create verb + orchestrator-exec resolver (#2625)
* feat(#2584): Phase 2 — worktree create verb + orchestrator-exec resolver

Phase 2 of the negotiated executor-isolation feature (ADR-1239 Codex-binding amendment). Two building blocks for `dispatch.isolation: orchestrator-worktree` hosts, both unconsumed — no scheduler wires them yet (that is Phase 3), so no runtime behavior changes.

worktree create verb (planWorktreeCreate / executeWorktreeCreatePlan / cmdWorktreeCreate in worktree-safety.cts, routed via routeWorktree in gsd-tools.cjs): validates the wave base, creates a bounded branch+worktree, records it in the run manifest reusing record-agent 4-field entry shape, returns the executor working directory. Bounded git (10s timeout, degrade-not-throw); all manifest read/parse/validate/dedupe precedes the single git side effect (no unmanifested-orphan on a bad manifest); timeout-only best-effort partial rollback (a clean collision-exit never removes a live peer worktree); fail-closed on bad base, unsafe leading-dash / .. inputs, and malformed/mis-shaped manifest.

resolveOrchestratorExec (host-integration.cts): pure descriptor->argv resolver reading the new runtime.orchestratorExec descriptor field (codex/opencode/kimi/kimi-code), fail-closed on missing/invalid shape. Validator (capability-validator.cjs) + a parity guard asserting every orchestrator-worktree host declares a resolvable orchestratorExec.

Adding the create route edits the installed gsd-core/bin/gsd-tools.cjs, so the golden-install-parity fixtures for all 19 runtimes are regenerated (npm run gen:golden) — the only changed hash is gsd-tools.cjs. CONTEXT.md glossary updated; capability-registry regenerated. Behavioral tests (worktree-safety + host-integration) incl. a fast-check property test and the parity sweep.

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

* chore: rebuild tracked state-transition.cjs to match #2400 source

The tracked compiled artifact drifted from src/state-transition.cts: #2400 (commit 2bcfaa2e2) added the progress.total_plans frontmatter sync to source but the tracked bin/lib/state-transition.cjs was never rebuilt, so the fix was not shipping to consumers of the compiled artifact. The mandatory build:lib step for Phase 2 surfaced the drift; recompiling makes the already-merged, already-changelogged #2400 fix effective. Artifact-only resync (no source/test change); drift class tracked by #2591.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 21:23:06 -04:00
Tom Boucher
7e1c736a3e fix(#2556): rescue SUMMARY when cat-file reports absent (exit 128, not 1) (#2611)
* test(#2556): correct cat-file stubs to exit 128 + rewrite fail-closed tests to fail-open

* fix(#2556): rescue SUMMARY when cat-file reports absent (exit 128, not 1)

* chore(#2556): backfill changeset pr to 2611
2026-07-24 13:35:02 -04:00
Tom Boucher
ec7978c0b4 feat(#2584): add dispatch.isolation sub-field, descriptors, validator + negotiation (#2604) 2026-07-24 12:51:42 -04:00
BeeHiggs
bf9fe4630d feat(#2249): bracket phase-id core grammar — parse/render/toDir round-trip pair (epic #612 PR-1) (#2258)
* feat(#2249): bracket phase-id core grammar — parse/render/toDir + READING-B + guards

PR-1 of epic #612 (ADR-612, in-tree at docs/adr/612-bracket-phase-id-convention.md).
Adds the bracket-convention grammar INSIDE src/phase-id.cts — the ADR-2121 single
canonical owner — as a pure, additive extension. The 17 locked exports and
PHASE_NUMBER_TOKEN_SOURCE are untouched, and normalizePhaseName is byte-identical,
so the PR-0 collision anchor (tests/adr-612-collision-characterization.test.cjs)
stays green.

New pure round-trippable model (ADR Decision 4):
- PhaseId { project, milestone, phase, subphase?, plan? }.
- parsePhaseId(input): accepts display `[GSD.02] 05.03-01`, dir/token
  `GSD.02-05.03-slug`, or bare `GSD.02-05`; rejects ambiguous non-bracket tokens
  (`02-04`, `05`) rather than guessing. The rejection lives ONLY in this new
  parser — normalizePhaseName and every legacy reader keep accepting those
  tokens unchanged (conservative default; no existing path gains a throw).
- renderPhaseId(id) -> `[GSD.02] 05.03-01`; toDir(id, slug) -> `GSD.02-05.03-slug`
  with a slug guard that sanitizes path-traversal input.
- getMilestoneFromPhaseId(phaseId, convention?): READING-B derives the milestone
  from the `[PROJECT.MM]` prefix, gated on convention === 'bracket' and returning
  the `vN.0` form (parity with READING-A). The optional parameter keeps the helper
  pure (no config read) and byte-compatible — every existing single-arg caller
  resolves to the unchanged READING-A body (ADR Decision 6).
- extractPhaseToken(dirName, convention?): bracket dir branch GATED on
  convention === 'bracket'. A bracket dir `{CODE}.{MM}-{PP}` is
  string-indistinguishable from the legacy #2043/#1324 letter-prefixed-decimal
  family (`P0.3-2`, `P0.12-34`) whenever the code ends in a digit, so no
  string-only discriminator is complete — an ungated auto-detect silently
  reinterpreted legacy reads on this CRITICAL 6-caller helper. The explicit
  convention signal keeps every existing convention-less call site byte-identical
  (pinned by a #2043 numeric-tail characterization in tests/phase-id.test.cjs).
- comparator: no new code — comparePhaseNum already orders the dot-decimal
  `PP[.SS]` tokens extractPhaseToken yields; milestone-qualified ordering is a
  PR-2 resolution concern (bracketQualifiedKey), not core grammar.
- SENTINEL_RANGES / isSentinelPhaseId(phaseId, convention?): {0, 999}
  non-milestone guard; the bracket-prefix reading is gated the same way (an
  ungated read called `P0.0-foundation` a sentinel), legacy leading-int form
  unchanged.
- BRACKET_PHASE_TOKEN_SOURCE (dot-or-dash `[.-]` sub-separator; deliberately
  more permissive than parsePhaseId — a read-tolerance source for PR-2, not the
  emit grammar) and PHASE_HEADING_PREFIX_SRC exported from the drift-guard-exempt
  owner so PR-2 builds every bracket read regex from the canonical source and
  check:phase-id-drift stays green stack-wide.

The bracket project code follows the repo's config-validated `[A-Z][A-Z0-9_]*`
grammar (not the ADR §1 illustration's `[A-Z]{1,6}`), so every project_code the
config permits parses. parsePhaseId has no live callers in PR-1, so this grammar
choice is forward-facing for PR-2 with zero PR-1 behavior impact.

Tests: tests/adr-612-bracket-grammar.test.cjs (28) — ADR §3 example round-trips,
full 5-tuple parse, READING-B (+ legacy-unchanged and sentinel cases),
extractPhaseToken bracket ON/OFF, comparator ordering of extracted tokens,
sentinel + slug guards, bare-token rejection, exported-source behavioral
assertions, and two generative fast-check properties: render∘parse identity over
well-formed displays, and the toDir/disk↔display bijection. Plus a #2043
numeric-tail characterization (single- AND multi-digit rows) in
tests/phase-id.test.cjs pinning the convention-less reading byte-identical.

The compiled gsd-core/bin/lib/phase-id.cjs is gitignored (ADR-457 build-at-publish)
and rebuilt by CI, so it is intentionally not committed.

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

* chore(#2249): changeset fragment for PR #2258 (docs-exempt: internal grammar behind flag)

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

* fix(#2249): reject non-canonical phase-id input + harden toDir (review B1/M1-M3)

PR-1 CHANGES_REQUESTED follow-up (epic #612, ADR-612 Decision 4).

B1 (blocker): parsePhaseId accepted non-canonical input (unpadded numbers,
over-padded numbers, multi-space separators, stray whitespace), so
render(parse(x)) === x did not hold for every well-formed x as ADR-612
Decision 4 requires. Both branches now enforce canonicality by construction:
parse permissively, rebuild the canonical string via the same emit path
(renderPhaseId for display, a hand-rebuilt token for dir/token), and throw
"parsePhaseId: not canonical" on any mismatch. The .trim() at the parser's
entry is removed — the match anchors now reject leading/trailing whitespace
outright, folding into the existing "not a bracket phase id" rejection.

M1 (major): toDir only ever guarded the slug; project/milestone/phase/
subphase were interpolated unsanitized, so a hand-built PhaseId (a
structural, not nominal, type) could smuggle a path-traversal segment onto
disk. Every field is now validated against the exact shape parsePhaseId
itself would produce before use.

M2 (major): a slug that sanitized to empty (e.g. '!!!') left a dangling
trailing hyphen in the emitted dir name. toDir now throws in that case.

M3 (major): an all-digit slug (e.g. '2026') was string-indistinguishable
from the dir-branch's plan tail, so it silently broke the disk<->identity
bijection on read-back. toDir now rejects all-digit slugs.

Nits: toDir now rejects a non-string slug instead of coercing it to the
literal token 'undefined'/'null'; sentinel boundary tests added for
milestones 1/998/1000 (SENTINEL_RANGES is the two discrete values {0, 999},
not an inclusive range — these were already correct, now locked by test).

Test-first: every new assertion (concrete examples + fast-check mutation
property for B1; concrete cases for M1-M3 and the nits) was written and
confirmed red before the implementation changes, per repo TDD convention.

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

* chore(#2249): reformat changeset body to house convention (review Mi2)

The fragment added in ab26190a was a plain paragraph — no bold headline,
no trailing issue reference. Reformat to the repo's
`**Bold headline** — symptom/explanation. (#issue)` body shape (see e.g.
.changeset/agile-pandas-dance.md, .changeset/fierce-pumas-gather.md).

Uses (#2249), the issue every commit on this branch references, not the
PR number already carried in frontmatter (`pr: 2258`) — the changelog
serializer appends `(#{pr})` unconditionally, so a body also ending in
`(#2258)` would double-render as `(#2258) (#2258)`. Verified the rendered
bullet directly via parseFragment + serializeChangelog: it now reads
`... (#2249) (#2258)`, matching the dominant convention across the other
fragments (frontmatter pr = merged PR, body reference = originating issue).

Also moved the docs-exempt marker back before the paragraph -> after it
(matching the file's original order): the marker sits on its own line and
is stripped before the body is used, but placing it first left a leading
blank line in front of the bold headline once reformatted.

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

* test(#2249): widen property generators — 3+-digit numerics + subphase-pad mutation (re-review Minor 1/2)

PR-1 re-review follow-up (epic #612, ADR-612 Decision 4). Test-only: closes
two property-generator coverage gaps the reviewer flagged; no source change
(src/phase-id.cts and gsd-core/bin/lib/phase-id.cjs are byte-unchanged).

Minor 1 (3+-digit numerics never exercised): numArb capped at 99, so no
property fed a 3+-digit milestone/phase/subphase/plan through parse/render/
toDir despite CANONICAL_NUMERIC_RE's dedicated `[1-9]\d{2,}` branch. Widen
numArb to 1–999 so the round-trip and disk↔display bijection properties both
span 3-digit widths (pad2 passes ≥3-digit values through un-truncated with no
leading zero, so canonicality still holds). Add a concrete regression pinning
the reviewer's hand-traced example: '[GSD.100] 05' round-trips, renders, and
toDirs to 'GSD.100-05-feature' without truncation.

Minor 2 (no subphase-pad mutation): the B1 mutation-rejection property covered
milestone/phase pad + whitespace mutations but never a subphase pad. Add
unpad-subphase / overpad-subphase to the mutation set and a generated
`includeSub` boolean that decides whether the canonical carries a `.SS`
(forced in for the subphase mutations so there is always a `.SS` to mutate);
non-subphase mutations keep their original no-subphase coverage.

Non-vacuity verified against the compiled lib by temporarily probing each
widened/new property and confirming it fails: round-trip counterexample
["A",100,1,…] and bijection counterexample ["A",1,100,…,"a"] prove 3-digit
tokens are genuinely generated and reach the body; a no-op unpad-subphase
mutation trips the mutated===canonical guard (counterexample
["A",1,1,1,false,"unpad-subphase"]), proving the subphase branch is reached
with a subphase present. Probes reverted; numRuns unchanged.

Gates: tests/adr-612-bracket-grammar.test.cjs 44 pass / 0 fail;
`npm run test:unit` 1079 pass / 0 fail; `npm run lint:ci` exit 0.

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

* fix(#2249): consume the #2232 continuation seam at the bracket token's slug-adjacent position (review Major)

BRACKET_PHASE_TOKEN_SOURCE was a sixth continuation-recognition site that
re-derived the grammar as an unbounded `\d+` literal instead of consuming
PHASE_CONTINUATION_SEGMENT_SOURCE, re-opening the #2232 bug class on the bracket
path: a PR-2 reader interpolating it over dir `PROJ.01-14-2026-photos-…` (a slug
whose first word is a year) over-collected the token as `01-14-2026` instead of
`01-14`.

Interpolating the cap verbatim at every position was rejected on evidence: the
bracket run is `MM-PP[.SS][-LL]` and only the LAST position is slug-adjacent.
The exactly-2 cap at the others would under-collect ids toDir itself emits —
`PROJ.02-105-slug` (3-digit phase) reads as `02`, `[GSD.02] 05.100` (3-digit
sub-phase) as `05` — because CANONICAL_NUMERIC_RE admits `[1-9]\d{2,}` and
`[GSD.100] 05` is a pinned regression. Those positions are delimiter-
disambiguated (a required field separator; a dot a slug can never contain),
not heuristically recognized, so they have no year collision to defend against.
Upstream draws the same line for the same reason: core-utils/phase cap the
paired PLAN component while the leading phase component stays unbounded.

So the run is now positional rather than a free `(?:[.-]\d+)*` repetition, and
each position takes the width its delimiter affords: leading unbounded, dash-1
and dot canonical, and the slug-adjacent dash-2 interpolating the single-owner
seam. The accepted trade-off is #2232's policy verbatim: a PLAN ≥100 is out of
the token grammar.

Also derives CANONICAL_NUMERIC_RE from the new BRACKET_CANONICAL_NUMERIC_SOURCE
instead of re-spelling it as a literal, so the emit-side gate and the read-side
token source are one rule — the same single-owner discipline this fix is about.
Behaviour-identical (the anchors make the source's `(?!\d)` guard redundant).

Refs #2249

* test(#2249): pin the bracket/#2232 reconciliation — parity surface 6 + divergence gate + property (review Major)

The comment block alone cannot hold the divergence: src/phase-id.cts is exempt
from the #2128 drift guard by construction, so lint-phase-id-drift.cjs would not
catch the bracket token source drifting from the seam. Per the Generative Fix
Divergence rule, the divergence is pinned behaviorally instead.

Surface 6 joins the existing #2232 parity gate rather than starting a rival one:
the review named the bracket token source "a sixth continuation-recognition
site", and continuation-grammar-parity.test.cjs is already the invariant-named
home where the five #2043 sites agree with the owner on a shared width corpus.
Surface 6 asserts the same contract at the bracket run's slug-adjacent position
(`01-14-<seg>-photos-…`, mirroring surface 1 with the extra milestone level), so
the bracket path now fails the same gate the other five do.

A second block pins the DELIBERATE half — the wider canonical width at the
delimiter-disambiguated positions, plus the accepted bound (a plan >=100 is out
of the grammar). Without it, "unifying" bracket onto the exactly-2 cap would
look like a cleanup rather than a regression.

The generative property ties the READ side to the EMIT side metamorphically: for
every id toDir can produce, BRACKET_PHASE_TOKEN_SOURCE must collect exactly that
id's numeric run — no more, no less. It needed a new arbitrary: the existing
slugArb generates one [a-z0-9] word and so can never produce the number-leading
slug the collision requires.

Probe-falsified, both directions (probes reverted):
- reverting the source to the old unbounded `\d+` fails 8: the parity gate
  reports `"01-14-2026-photos-performance" collected "01-14-2026"` — the
  review's scenario verbatim — and the property shrinks to
  ["A",1,1,undefined,"100-a"].
- interpolating the seam at EVERY position (the rejected verbatim option) leaves
  the repro and parity green but fails the divergence gate `'02' !== '02-105'`
  and the property at ["A",1,1,100,"100-a"] (3-digit sub-phase), which is the
  evidence that a verbatim cap under-collects ids toDir emits.
Width 2 stays green under both probes — the corpus agrees with the owner exactly
where the old and new rules coincide, so the gate discriminates rather than
merely mirroring the regex.

Refs #2249

* docs(#2249): add the new phase-id exports to the CONTEXT.md glossary bullet (round-4 Major)

* test(#2249): pin deterministic grammar boundary cases (re-review m1)

PR-1 re-review follow-up (epic #612, ADR-612 Decision 4). Test-only: closes
the m1 proof gap — the grammar's bounds were exercised only incidentally
through the fast-check domain (1-999, [a-z0-9] slugs). No source change
(src/phase-id.cts and gsd-core/bin/lib/phase-id.cjs byte-unchanged).

Adds a deterministic boundary block (7 describe groups, +22 tests) pinning
the compiled lib's CURRENT behavior — a proof gap, not a behavior gap:

- m1.1 numeric-width 99/100/101 at milestone/phase/subphase/plan: parse
  (display + dir) -> render/toDir round-trip byte-equality. The plan
  position is identity-symmetric (parse/render accept 99/100/101) but toDir
  drops it (filename-surface dimension only).
- m1.2 read-token width is POSITIONAL: BRACKET_PHASE_TOKEN_SOURCE absorbs
  99/100/101 at milestone/phase/subphase (delimiter-disambiguated) but caps
  the slug-adjacent plan (dash-2) at exactly 2 digits — plan >=100 is out of
  the token grammar (#2232 seam). Pinned as asymmetry, NOT symmetry.
- m1.3 leading-zero 007 -> not-canonical rejection at every position/form.
- m1.4 slug abuse: parse DROPS a null-byte/control/unicode/emoji trailing
  slug (never stored, never mis-read as a plan) and rejects a line
  terminator; toDir's allow-list sanitizer collapses each to a safe
  [a-z0-9-] token or rejects sanitize-to-empty.
- m1.5 absolute-path slug sanitizes (next to the ../../etc traversal test);
  an absolute-path project on a hand-built id is rejected by PROJECT_ID_RE;
  an abs-path string is not a bracket id; an abs-path dir slug is dropped to
  a clean tuple.
- m1.6 whitespace-only -> not-a-bracket-phase-id.
- m1.7 very-long input (10k) resolves promptly (ReDoS smoke, behavioral):
  garbage/partial-prefix throw; a 10k-char slug parses (dropped)/sanitizes.

No accept-not-reject case is a src bug: parse never STORES an abusive slug
(dropped from the identity tuple) and toDir independently re-sanitizes on
emit, so the only slug reaching disk is allow-listed. Plan >=100 accepted by
parse is the documented positional design (toDir drops the plan; the
read-token caps it) — divergence pinned, not papered over.

Probe-falsify: corrupted one assertion in each of the 7 groups (m1.4 both
its parse-side and emit-side), ran -> 8 distinct named failures, reverted ->
66/66 green. Confirms every new group executes and can fail.

Gates: tests/adr-612-bracket-grammar.test.cjs 66 pass / 0 fail; grammar +
continuation-grammar-parity + collision-characterization + phase-id family
175 pass / 0 fail; `npm run lint:ci` exit 0. `npm run test:unit` is green
except one pre-existing, unrelated env failure (npm-integrity-gate: a live
npm-audit advisory in the production dep tree — reproduces with this change
stashed; no package.json/lock change here).

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 12:49:53 -04:00
Tom Boucher
ae8526cd50 fix(#2429): scope Codex skills home override to --global only (#2553)
* fix(#2429): scope Codex skills home override to --global only

The skills-kind home override (redirecting skills to $HOME/.agents) was
applied regardless of scope. Gate it behind scope === 'global' so
--local installs keep skills project-local under the config directory.

Closes #2429

* docs(#2429): backfill changeset PR number (2553)
2026-07-23 07:38:42 -04:00
Tom Boucher
2bcfaa2e27 fix(#2400): warn on planned-phase no-op + sync progress.total_plans (#2552)
* fix(#2400): warn on planned-phase no-op + sync progress.total_plans

Bug A: When STATE.md Current Position has no recognized labels (narrative
prose), emit a warning field so the workflow detects the no-op instead
of continuing with stale state.

Bug B: Sync progress.total_plans in the YAML frontmatter when a plan
count is provided, preventing contradictory state between frontmatter
(0) and body (actual count). This writes the explicitly-provided count,
not a re-derivation from disk (#500 safe).

Closes #2400

* docs(#2400): backfill changeset PR number (2552)
2026-07-23 07:38:21 -04:00
Tom Boucher
1482dc5ce0 fix(#2366): scope parseCoverageMatrix to recognized coverage tables (#2551)
* test(#2366): regression tests for parseCoverageMatrix scoping bugs

Bug 1: summary table outside matrix not parsed as data
Bug 2: multi-section matrix with repeated headers parses correctly
Bug 3: markdown emphasis on decision cell is stripped

* fix(#2366): scope parseCoverageMatrix to recognized coverage tables

Replace latching sawHeader with contextual inMatrix tracking that
resets on non-pipe lines, preventing summary tables from being parsed
as data (bug 1). Allow multiple headers for multi-section matrices
(bug 2). Strip markdown emphasis from decision cells before validation
(bug 3).

Closes #2366

* fix(#2366): update representative-corpus test to expect correct behavior

The test previously documented the known-buggy parseCoverageMatrix behavior.
Now that the fix is in place, test against the expected correct output
(expectedBlock, expectedCounts, expectedErrorCount) instead of the
currentBuggyOutput snapshot.

* docs(#2366): backfill changeset PR number (2551)
2026-07-23 07:38:00 -04:00
Tom Boucher
77bf21b3a6 fix(#1995): widen worktree branch regex to accept agent-<id> namespace (#2548)
* test(#1995): regression test for agent-<id> branch namespace

Add failing-first tests proving that normalizeCleanupManifestEntry and
planWorktreeRecordAgent reject Claude Code's current agent-<id> isolation
branches (only worktree-agent-<id> is accepted). Boundary tests cover both
namespaces plus rejection cases.

* fix(#1995): widen worktree branch regex to accept agent-<id> namespace

Claude Code's isolation="worktree" branch naming changed from
worktree-agent-<id> to agent-<id>. Widen the regex in all 7 locations
from ^worktree-agent-[A-Za-z0-9._/-]+$ to ^(worktree-)?agent-[A-Za-z0-9._/-]+$
so both namespaces are accepted. Introduce a shared WORKTREE_AGENT_BRANCH_RE
constant in src/worktree-safety.cts to prevent future drift.

Closes #1995

* fix(#1995): update workflow guards, test assertions, and baselines

Widen the branch-check regex in execute-phase.md and execute-plan.md.
Update all test assertions that checked for ^worktree-agent- to expect
the widened ^(worktree-)?agent- pattern. Regenerate golden-install-parity
fixtures, agent-size-baseline, and workflow-size-baseline.

Closes #1995

* fix(#1995): update extractCwdGuardBash sanity check for widened regex

The e2e test's sanity check verified the extracted bash block contained
'worktree-agent-'. After widening to '(worktree-)?agent-', update the
check to match the new pattern.

* fix(#1995): widen missed workflow-guard branch check + changeset + lint fixes

- hooks/gsd-workflow-guard.js: widen startsWith('worktree-agent-') to
  /^(worktree-)?agent-/ regex — same defect class, was missed in prior commit
- tests/worktree.test.cjs: fix indentation regression from prior edit
- Add .changeset/1995-worktree-agent-branch-namespace.md (pr:0 placeholder)

Found by orthogonal code review (Step 4).

* fix(#1995): regenerate golden + size baselines for workflow-guard change

* docs(#1995): backfill changeset PR number (2548)
2026-07-23 07:36:53 -04:00
Tom Boucher
d579daa3ed docs(#2505): Phase 6 — migration guide + built-in-only subagent-toolkit enum (#2538)
* docs(#2512): Phase 6 — migration guide + built-in-only subagent-toolkit enum

* fix #2512: update CONTRACT-PIN for built-in-only subagentToolkit value

* docs(changeset): backfill PR #2538 for Phase 6 (#2512)
2026-07-22 14:30:26 -04:00
Tom Boucher
f654c24a3e feat(#2505): Phase 4 — runtime-aware subagent dispatch (Option A; resolve-dispatch-type query) (#2525)
* feat(#2508): Phase 4 Option A — runtime-aware subagent dispatch via resolve-dispatch-type query (#2505)

* fix(#2508): prose-variant preamble (avoid scanner-tripping literals) + namedDispatch===false-only mapping

* fix(#2508): remove leftover old-preamble lines (keep prose variant only)

* fix #2508: prose-only reference file

* test #2508: regen golden install parity after workflow preamble additions

* fix #2508: remove preamble from plan-phase.md (Phase 6 capstone ceiling); regen size+golden baselines

* docs(changeset): backfill PR #2525 for Phase 4 (#2508)
2026-07-22 10:22:42 -04:00
Tom Boucher
936a345381 feat(#2505): Phase 3 — agent-skills fallback for non-dispatchable runtimes (#2521)
* feat(#2454): PR 2 — cmdAgentSkills fallback reads installed agent prompt

When no agent_skills config entry exists for a given agent type (the common
case on AGENTS-native runtimes), cmdAgentSkills previously returned empty
output. Workflows that inject ${AGENT_SKILLS_*} into subagent dispatch
prompts then carried nothing — the persona was lost.

The fallback: resolve the runtime's agents directory via checkAgentsInstalled
and read <agentsDir>/<agentType>.md. The installed agent prompt content
(now present for kimi-code via the flat-skills install layout) flows into
the dispatch prompt so the persona survives even without explicit config
opt-in. This is the reporter's suggested fix #2 from #2454.

The fallback triggers for ALL runtimes (not just kimi-code) when no config
entry exists — it is strictly additive (returns content the previous empty
path could not). If the agent file is not found on disk, the block stays
empty (same as before).

* docs(changeset): Phase 3 agent-skills fallback Added (#2510)

* docs(changeset): backfill PR #2521 for Phase 3 (#2510)
2026-07-22 01:25:41 -04:00
Tom Boucher
c2a305c44d feat(#2505): Phase 2 — kimi-code Agent Skills install layout (#2520)
* feat(#2454): PR 2 — kimi-code Agent Skills converter + install layout

PR 1 registered the kimi-code EoS descriptor with empty artifactLayout
(SKIP_INSTALL_CONTRACT excluded it from the end-to-end install test).
PR 2 fills in the install surface:

- src/runtime-artifact-conversion.cts: new convertClaudeCommandToKimiCodeSkill
  function. Today it delegates to convertClaudeCommandToKimiSkill (Python
  kimi-cli) because Kimi Code uses the same Agent Skills format + /skill:
  invocation per official docs. The distinct function name lets a future
  divergence land cleanly if Kimi Code's skill format evolves independently.
- gsd-core/bin/lib/capability-validator.cjs: add to ALLOWED_SKILLS_CONVERTERS.
- capabilities/kimi-code/capability.json: artifactLayout.global now declares
  the skills kind with converter='convertClaudeCommandToKimiCodeSkill' +
  home='.kimi-code' (auto-discovered at ~/.kimi-code/skills/ per Kimi Code
  docs: merge_all_available_skills = true default).
- tests/installer-migration-install.integration.test.cjs: REMOVE the
  SKIP_INSTALL_CONTRACT exclusion — kimi-code now has a full install surface.
- Regenerated capability-registry + capability-matrix + golden install
  parity + install tree fixtures for kimi-code.

* fix(#2454): wire kimi-code converter into SKILLS_CONVERTER_REGISTRY + count bump

- src/install-engine.cts: add convertClaudeCommandToKimiCodeSkill to
  SKILLS_CONVERTER_REGISTRY so the layout-driven skills install path
  can dispatch off the descriptor's converter string.
- tests/capability-registry.test.cjs: bump VALID_CONVERTER_NAMES count
  26 → 27 (added convertClaudeCommandToKimiCodeSkill).

* fix(#2454): remove home override from kimi-code skills (inherit configDir)

The home:'.kimi-code' override made the install plan resolve skills dest
to ~/.kimi-code/skills instead of <configDir>/skills, causing the test's
temp configDir to miss the install. Removing it lets skills inherit
configDir like most runtimes.

* fix(#2454): kimi-code install contract surface is flat-skills (no agents)

Kimi Code has NO custom named subagents (per official docs: 3 built-in
coder/explore/plan only). The kimi-skills-agents surface expects agents/
gsd.yaml + subagents/*.yaml which kimi-code does not produce. Changed
to flat-skills which only checks for skills/gsd-* dirs.

* docs(changeset): Phase 2 kimi-code install layout Added (#2509)

* docs(changeset): backfill PR #2520 for Phase 2 (#2509)
2026-07-22 00:58:58 -04:00
Tom Boucher
bf8f320083 feat(#2505): Phase 1 — EoS descriptor split (kimi-code capability.json + drift-guard registration) (#2519)
* feat(#2454): add kimi-code as an EoS capability (Node Kimi Code CLI)

PR 1 of N for #2454. Establishes the EoS descriptor foundation for splitting
GSD's kimi support into two distinct products per the user's directive:
- kimi       (existing): Moonshot's Python kimi-cli (~/.kimi, runtime: python)
- kimi-code  (new):      Moonshot's Node Kimi Code CLI (~/.kimi-code,
                         runtime: node, KIMI_CODE_HOME env)

Per ADR-1239 EoS, runtime behavior is driven by capabilities/<id>/capability.json
descriptors, not hardcoded branches in install.js. The new descriptor uses
the existing primitives (dot-home configHome, skills artifactLayout, kimi-hooks-toml
hooksSurface — same TOML [[hooks]] format Kimi Code reads per its docs).

Critical Kimi Code constraint reflected in the descriptor:
  hostIntegration.dispatch.namedDispatch: false
  hostIntegration.dispatch.builtInSubagents: ['coder', 'explore', 'plan']
  hostBehaviors.namedSubagentsSupported: false
Kimi Code's official docs confirm only 3 built-in subagents with NO custom-
subagent registration (the [subagent] table only has timeout_ms). The
kimi-agents YAML layout (used by Python kimi-cli) is therefore NOT in
kimi-code's artifactLayout.

Schema adjustments:
- subagentToolkit set to 'undocumented' (the existing escape hatch); the
  schema enum (full/read-only) lacks a 'limited'/'built-in-only' value.
  A follow-up PR can extend the schema enum to add 'built-in-only' as a
  first-class axis value reflecting Kimi Code's documented model.

Registration:
- capabilities/kimi-code/capability.json (new descriptor, modeled on codex)
- bin/install.js: allRuntimes array + --all list + --kimi-code flag
- gsd-core/bin/shared/runtime-aliases.manifest.json: kimi-code aliases
  (kimi-code, kimicode, kimi_code)
- src/runtime-name-policy.cts: FALLBACK_ALIASES map
- gsd-core/bin/lib/capability-registry.cjs: regenerated via
  scripts/gen-capability-registry.cjs --write

Tests:
- tests/multi-runtime-select.test.cjs updated for the new runtime count (18)
  + new --kimi-code flag test + 'All' shortcut renumbered 18 → 19.

Out of scope for PR 1 (follow-up PRs in the sequence):
- Install-time decision logic (kimi vs kimi-code detection / prompt)
- agent-install-check semantics for kimi-code (verify Agent Skills presence)
- cmdAgentSkills fallback returning subagent prompt content
- Workflow template mapping (named agents → built-in coder/explore/plan)
- Migration guidance for users currently on 'kimi' who are actually on Kimi Code
- Schema enum extension for subagentToolkit: 'built-in-only'

Refs #2454, #2095 (EoS/kimi migration epic), ADR-1239 (EoS).

* fix(#2454): complete drift-guard registrations for kimi-code runtime

The drift guards caught every surface that pins runtime enumeration. Each
update is mechanical, driven by the guard's named failure mode:

- src/runtime-name-policy.cts RUNTIME_LABELS: 'Kimi Code' label for kimi-code
- src/runtime-name-policy.cts RUNTIME_FLAG_IDS: add kimi-code to the
  isKimiCode predicate generator
- bin/install.js runtimeMap: option '11' → 'kimi-code', renumber downstream
  entries (11..17 → 12..18), ALL_RUNTIMES_OPTION 18 → 19
- gsd-core/bin/shared/model-catalog.json runtimeTierDefaults: kimi-code entry
  (null/null/null — same as kimi, no model tier defaults until configured)
- docs/reference/capability-matrix.md: regenerated via
  scripts/gen-capability-matrix.cjs --write (kimi-code row added)
- tests/global-config-home-fragment.test.cjs GOLDEN_FRAGMENT_MAP:
  kimi-code → '.kimi-code'
- tests/fixtures/golden-install-parity/*.json: regenerated via npm run gen:golden
  (the runtime-aliases.manifest.json hash changed; all 17 runtime fixtures updated)

The capability-registry is already regenerated from the prior commit.

* test(#2454): update drift-guard tests for kimi-code runtime registration

Multiple drift guards pin runtime enumeration counts and option numbering.
Each update is mechanical, driven by the guard's named failure mode:

- tests/runtime-flags.test.cjs: EXPECTED_FLAGS gains isKimiCode (16 → 17);
  'all 16 flags' → 'all 17 flags' in test names + messages.
- tests/multi-runtime-select.test.cjs: parseRuntimeInput option renumbering
  cascade — kilo moves 11→12, opencode 12→13, pi 13→14, qwen 14→15,
  trae 15→16, windsurf 16→17, zcode 17→18, All 18→19. New single-choice
  test for kimi-code (option 11). Prompt test updated for new numbering.
- tests/host-integration-descriptors.test.cjs: EXPECTED_PROFILES gains
  kimi-code → 'programmatic-cli' (terminal CLI per Kimi Code docs);
  EXPECTED_FLATTEN gains kimi-code → false (backgroundDispatch:true per
  docs, same as Python kimi/opencode).
- tests/global-config-home-fragment.test.cjs: table-count test renamed
  13 → 14 table runtimes (kimi-code added to GOLDEN_FRAGMENT_MAP earlier).

* fix(#2454): empty artifactLayout for kimi-code (PR 1 scope)

The skills kind requires a converter (existing converters are per-runtime
like convertClaudeCommandToKimiSkill). PR 1 of this multi-PR sequence only
registers the descriptor; the actual Agent Skills converter (and a new
'convertClaudeCommandToKimiCodeSkill' function) lands in PR 2 alongside
the install-time decision logic. Empty artifactLayout.global is valid and
means 'nothing to install yet via the layout seam'.

Also: added kimi-code to RUNTIME_META in tests/helpers/install-shared.cjs
(localDir .kimi-code, globalSuffix .kimi-code), and added Kimi Code as
option 11 in install.js's buildRuntimePromptText (renumbered downstream
options 11..17 → 12..18, All 18 → 19).

* fix(#2454): camelCase runtimeFlags for hyphenated ids (kimi-code → isKimiCode)

The runtimeFlags generator previously produced 'isKimi-code' (hyphen preserved)
for the new kimi-code runtime id. Property names with hyphens are awkward for
consumers (flags['isKimi-code'] instead of flags.isKimiCode). The new
runtimeIdToFlagName helper folds -[a-z] boundaries to uppercase, producing
the conventional PascalCase flag name. The 16 prior single-word runtime ids
are unaffected (the regex finds no hyphens).

* fix(#2454): update remaining drift-guard tests + gen kimi-code fixtures

- tests/runtime-flags.test.cjs drift guard: use proper kebab-case
  conversion (isKimiCode → kimi-code, not 'kimicode') so the registry
  comparison doesn't false-positive on hyphenated runtime ids.
- tests/multi-runtime-select.test.cjs: fix kilo/opencode/pi/qwen/trae
  single-choice tests for the renumbered options (kilo 11→12, opencode
  12→13, pi 13→14, qwen 14→15, trae 15→16).
- tests/install.test.cjs: Kilo integration option 11→12, prompt test
  regex updated.
- tests/fixtures/golden-install-parity/kimi-code.json + install-tree/
  kimi-code.json: generated via UPDATE_GOLDEN=1 + UPDATE_INSTALL_TREE=1.
  The kimi-code install produces the standard GSD install layout (skills,
  contexts, references, etc.) — 436 paths, same shape as other runtimes
  that have no custom converter yet.

* fix(#2454): add kimi-code install contract + global config home fragment

- src/runtime-name-policy.cts GLOBAL_CONFIG_HOME_FRAGMENTS: add kimi-code
  → '.kimi-code' so getGlobalConfigHomeFragment returns the correct path
  instead of falling through to the default '.claude'.
- tests/installer-migration-install.integration.test.cjs
  RUNTIME_INSTALL_CONTRACTS: kimi-code entry (same surface as kimi for
  PR 1; PR 2 will specialize once the Agent Skills converter lands).
- tests/multi-runtime-select.test.cjs: fix space-separated-choices test
  for the renumbered kilo option (11 → 12).
- tests/fixtures/golden-install-parity/kimi-code.json + install-tree/
  kimi-code.json: regenerated after rebasing onto current next (new
  planner-reversibility.md from #2471 etc. now included).

* test(#2454): skip kimi-code install contract until PR 2 ships install layout

The end-to-end install test (tests/installer-migration-install.integration
.test.cjs) asserts every allRuntimes entry installs a runtime-specific
artifact surface. PR 1 of #2454 registers kimi-code in allRuntimes + the
capability descriptor + flags + labels, but the install LAYOUT (Agent
Skills converter + global AGENTS.md at $KIMI_CODE_HOME/AGENTS.md) lands
in PR 2. The SKIP_INSTALL_CONTRACT set marks this exclusion explicit and
self-removing — PR 2 removes the entry alongside adding the install
surface, restoring the contract loop to full coverage.

* fix(#2454): restore compact model-catalog.json format (M1 review)

Per code-review M1: my prior 'fix(#2454): complete drift-guard registrations'
commit used python json.dump(indent=2) which inflated the file from 165→607
lines (every nested entry got expanded) and lost the trailing newline. The
semantic change was just a 3-line kimi-code entry. Restored the original
hybrid format (top-level indent=2 + inner entries' one-line style) and
added kimi-code in matching form.

Regenerated golden install parity + install tree fixtures since the
model-catalog.json hash changed.

* fix(#2454): update CONTEXT.md allRuntimes glossary (17 → 18, add kimi-code)

CI lint-tests job failed on the glossary drift guard
(scripts/check-glossary-refs.cjs --check):
  ✗ CONTEXT.md's allRuntimes enum-count sentence claims 17 values but
    bin/install.js's allRuntimes array has 18.
  ✗ CONTEXT.md's allRuntimes member list has drifted from bin/install.js
    (missing from CONTEXT.md's list: kimi-code).

Missed in the prior commits because gsd-test does not run the glossary
check (it's a CI lint-tests-only check). Updating CONTEXT.md's two claims
to 18 values + kimi-code in the member list.

* chore(#2505): regen capability-registry + stamp kimi-code version 1.8.0 (#2511)

* docs(changeset): Phase 1 kimi-code runtime Added (#2511)

* test(#2511): regen kimi-code golden parity fixture after Phase 0 guard normalization lands

* docs(changeset): backfill PR #2519 for Phase 1 (#2511)
2026-07-22 00:27:35 -04:00
Tom Boucher
09b535ac00 feat(#2481): add a negotiated effortSurface axis and wire invocation-time effort
ADR-1239 gains a ninth negotiated axis, effortSurface (argv | none), declaring how
a host accepts reasoning effort. ADR-443 is amended in the same change because its
recorded deferral is what the axis resolves: its Unblock condition offered paths
(a) and (b) and stated the choice was 'a maintainer call this file records but does
not make'. Path (a) is selected and satisfied here.

Before this, effort reached a runtime only through install-time channels
(EFFORT_RENDERING's frontmatter/api), so reviewer CLIs spawned as subprocesses
silently inherited whatever effort sat in the user's own global CLI config. The
review lane now resolves one universal effort through the ADR-443 cascade and
renders it per host through the negotiated descriptor.

Every per-host value is documentation-sourced, never inferred:
- claude   argv  -- verified via 'claude --help' (--effort <level>)
- opencode argv  -- verified via 'opencode run --help' (--variant)
- codex    argv  -- codex-rs/exec/src/cli.rs: model_reasoning_effort is NOT a CLI
                    flag (config.toml key only), so the global -c override is the
                    only argv route
- 15 hosts undocumented -- their docs state no reasoning setting; the sentinel
                    fails closed rather than inheriting a profile baseline

No config-file vocabulary member: the only host that ever had one (Gemini CLI's
thinkingConfig) was removed as a sunset runtime by 8f2ebbe9b (#1928, PR #1996),
and neither Antigravity CLI nor ZCode documents a reasoning setting.

Closes #2481

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 19:10:22 -04:00
Tom Boucher
c5e0371775 feat(#1951): reversibility tagging — gate one-way-door decisions (#2471)
* test(#1951): add failing-first tests for reversibility tagging

Red phase for issue #1951 (reversibility tagging: classify decisions by
undo cost, gate one-way doors behind a checkpoint:decision).

Tests assert, per the issue's acceptance criteria:
- discuss-phase CONTEXT.md template records a **Reversibility:** field with
  a rationale on captured decisions, and states it is optional
- gsd-planner @-references planner-reversibility.md and stays under the
  49152-char agent cap (LARGE_CAP, tests/agent-size-budget.test.cjs)
- a one-way rating inserts a checkpoint:decision before the dependent task;
  reversible inserts none; costly is flagged but never blocks
- the taxonomy defaults to reversible when unsure (checkpoint-fatigue guard)
  and inserting a checkpoint implies autonomous: false
- docs/reference/plan-md.md documents <reversibility> as optional with all
  three ratings
- --no-reversibility-gates parses to REVERSIBILITY_GATES=false, is injected
  into the planner prompt, and is advertised in the command argument-hint
  and help full mode (argument-hint parity)
- the override suppresses the gate but still persists the rating
- cmdVerifyPlanStructure accepts every rating and the absent case
  (additive-validator guarantee, behavioral via runGsdTools)
- parity: thinking-models-planning.md #4 adopts the canonical three-level
  taxonomy and the binary REVERSIBLE/IRREVERSIBLE vocabulary is gone
- no content loss from the planner extraction made to fit under the cap

Prose-contract assertions are Red until the implementation lands. The
behavioral validator assertions pass immediately — regression guards
proving the validator already accepts unknown optional tags.

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

* feat(#1951): reversibility tagging — gate one-way-door decisions

Classify planning decisions by what undoing them would cost, and give a
one-way door a human beat before the agent walks through it (issue #1951,
The Pragmatic Programmer Topic 15 'Reversibility'; Bezos's one-way/two-way
door framing).

Acceptance criteria met:
- discuss-phase records an optional reversibility rating with a rationale
  on <decisions> entries in the phase CONTEXT.md template. Unrated
  decisions are treated as reversible, so existing phases are unaffected.
- a one-way rating makes gsd-planner insert a checkpoint:decision before
  the task that implements the decision, reusing the existing checkpoint
  mechanism -- no new checkpoint machinery.
- reversible ratings trigger no checkpoint; costly ratings are flagged in
  the plan but never block.
- the rating persists on the task as the optional <reversibility rating=>
  element. cmdVerifyPlanStructure accepts every rating and the absent
  case; the structural validator does not reject unknown optional tags.
- --no-reversibility-gates (REVERSIBILITY_GATES=false) suppresses
  checkpoint insertion for intentionally-unattended runs while still
  recording ratings -- the override changes what stops the run, not what
  the plan remembers.

Single taxonomy, not two: references/thinking-models-planning.md #4
already shipped a binary REVERSIBLE/IRREVERSIBLE classification and is
loaded by both gsd-planner and gsd-plan-checker. It is rewritten onto the
canonical three-level vocabulary and now points at planner-reversibility.md
as the taxonomy owner, with a parity test that fails if the surfaces
diverge (DEFECT.GENERATIVE-FIX-DIVERGENCE).

agents/gsd-planner.md sat 47 chars under the 49152 LARGE_CAP, so the
checkpoint DO/DON'T guidance was relocated verbatim into
planner-antipatterns.md -- already @-referenced from the same section for
the same topic, so the planner still loads it and nothing was dropped. A
test guards the relocation against content loss.

Files: gsd-core/references/planner-reversibility.md (NEW, canonical
taxonomy + emission rules + anti-patterns), gsd-planner.md, plan-phase
workflow/command/help (flag wiring + parity), plan-md.md schema,
discuss-phase context template, CONTEXT.md glossary, INVENTORY + manifest,
size baselines, install goldens, plugin skills regen, changeset.

Closes #1951

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

* fix(#1951): address orthogonal review findings

Two isolated reviewers (correctness + security), neither of which authored
the change. Every finding fixed:

Security — the rationale is untrusted input (ADR-1577). It originates in
conversation and flows CONTEXT.md -> planner -> PLAN.md -> executor, each
hop an LLM reading the previous hop's output, with no validation on the
path. planner-reversibility.md and the discuss-phase template now state
it is data and never instructions, and name the </reversibility>
early-termination hazard explicitly -- a rationale that closes its own
element injects sibling structure the executor reads as real tasks.
Four tests guard it.

Correctness 1 — nothing machine-enforced the feature's own promise: a task
rated one-way with no preceding checkpoint:decision validated as fully
clean, so a planner error silently reopened the gap this feature exists to
close. cmdVerifyPlanStructure now warns on an ungated one-way rating. A
warning, not an error: <reversibility> stays additive and the plan stays
valid. Four tests cover ungated (warns), gated (silent), still-valid, and
reversible/costly never flagged.

Correctness 2 — pass-always test. The --no-reversibility-gates parse test
substring-matched the whole workflow file, and plan-phase.md prose mentions
both tokens in one sentence, so it passed with the bash conditional
deleted: it was testing the documentation, not the parser. Now scoped to
the fenced bash blocks and matched as one physical line, with a negative
control confirming prose alone cannot satisfy it.

Correctness 3 — costly had no itemized emission rule, only one-way did, so
two agents could diverge on whether to tag costly at all.

Correctness 4 — template convention break: the example ratings were bare
while every sibling field uses [...] to signal substitution, inviting an
LLM to copy one-way/costly forward as boilerplate. Now bracketed.

Correctness 5 — latent false-green: .includes('reversible') also matches
inside irreversible/irreversibility, which appear in anti-pattern
prose, so a surface that dropped the real taxonomy entry would still pass.
Now word-boundary matched.

ADR-857 phase-6 ceiling — the first gsd-test run caught plan-phase.md
1216 bytes over its frozen 94519 ceiling (it had 49 bytes of headroom on
next). The ceiling may only rise for privileged host machinery, and
reversibility gating is optional-feature logic, so the wiring was slimmed
to its minimum and the explanatory prose moved to the reference files the
planner already loads. plan-phase.md is now 94400 bytes -- 119 under the
ceiling and 70 bytes SMALLER than on next, so the host loop shrank while
gaining the feature, which is what phase 6 ratchets toward. The tracer
contract (tests/tracer-bullet.test.cjs) is unchanged.

Lint — fixed an unnecessary non-null assertion in verify.cts and a
CRLF-fragile bare \n regex in the new test (DEFECT.WINDOWS-CRLF-TEST-
PORTABILITY, the #1658/#1668/#2206/#2449/#2450 class).

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

* test(#1951): checkpoint fixture must carry the common task elements

The gated-one-way fixture built a checkpoint:decision task from the
abbreviated skeleton in gsd-planner.md, which shows only the
checkpoint-specific elements (<decision>/<context>/<resume-signal>).
cmdVerifyPlanStructure requires <name> and <action> on EVERY task
regardless of type, so the fixture failed validation for reasons that had
nothing to do with reversibility:

  errors: ["Task missing <name> element", "Task 'unnamed' missing <action>"]

Caught by gsd-test on 14d14a39 (2 failures, both this fixture).

The canonical shape is in tests/verify.test.cjs:266 — a checkpoint task
carries <name>/<files>/<action>/<verify> like any other. Fixture corrected
to match. Verified behaviorally against the real gsd-tools CLI across all
four cases: gated one-way (valid, silent), ungated one-way (valid, warns),
costly (valid, silent), absent (valid, silent).

Not a product defect: the validator's every-task contract is intentional
and pre-existing, and docs/reference/plan-md.md scopes its required-element
list to type=auto/tracer only because those are the elements a planner must
author, not because checkpoints are exempt from <name>.

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

* chore(#1951): backfill changeset pr number to 2471

* fix(#1951): CodeQL incomplete-sanitization + prompt-injection scan collision

Both CI failures were real defects in code this PR added, not false
positives.

CodeQL js/incomplete-sanitization (high), reversibility-tagging.test.cjs:46 —
the namesRating helper built its regex with `rating.replace(/[-]/g, '\\-')`,
which escapes the hyphen but not backslash, so the escape was incomplete.
It was also unnecessary: `-` carries no special meaning outside a character
class. Replaced with a complete metacharacter escape (backslash included).
Word-boundary behavior verified unchanged across all three ratings — notably
that "irreversible" prose still does not satisfy a "reversible" match, which
is the false-green this helper exists to prevent.

Prompt injection scan — the checkpoint fixture used the human-verification
child element inside <verify>. That tag name is a fake-instruction-boundary
pattern in scripts/prompt-injection-scan.sh, and the scan runs over changed
files, so copying the shape from tests/verify.test.cjs (unflagged only
because it is not in this diff) tripped the gate. Switched to the documented
plain-prose <verify> form.

The first attempt at that fix failed the same gate a second time: the
comment explaining the collision quoted the offending tag literally. The
comment now names it in prose instead — the scanner does not care whether a
match is code or commentary, which is the whole point of the
DEFECT.PROMPT-INJECTION-SCAN-COLLISION note in CLAUDE.md.

Verified locally before push: scan reports 0 findings across 57 changed
files, eslint clean, and both fixtures still validate as designed (gated
one-way silent, ungated one-way warns, neither errors).

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

* test(#1951): record measured cost and halve gsd-tools spawns

The Windows shard 1/3 job timeout was traced to the sharding layer, not to
this PR's assertions — see #2472. Two contributing factors were this file's
own, and are fixed here.

1. tests/test-timings.json had no entry for reversibility-tagging.test.cjs,
   so scripts/run-tests.cjs weighted it at the table's median fallback
   (~315ms) for LPT chunk packing. It actually measures 5595ms — an 18x
   under-weight. Recorded the measured value from the green gsd-test run
   (max across the node22/node24 lanes, per gen-test-timings.cjs's
   convention). Only this one entry: a full regen churns 634 entries of
   run-to-run drift, and the table is explicitly advisory and un-gated, so
   a 637-line diff does not belong in a feature PR.

2. Each verifyPlan() spawns gsd-tools, which dominates this file's cost.
   Spawns cut from 9 to 6 with no coverage lost:
   - the ungated-one-way warning and its stays-valid assertion now share
     one plan instead of building the same plan twice;
   - the reversible/costly never-flagged-as-ungated test was strictly
     subsumed by the additive suite, which already runs those two ratings
     ungated and asserts no /reversibilit/ warning at all — and the gate
     warning's text contains both "reversibility" and "one-way", so the
     broader assertion catches it. It only re-spawned gsd-tools twice to
     prove the same thing.

Both are symptom fixes. The shard imbalance itself (19/11/10 minutes
against a 20-minute cap, from a cost-blind round-robin partition that also
reshuffles downstream files whenever one is inserted) is tracked in #2472.

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

* test(#1951): checkpoint fixture adopts the #2444 type-branched contract

Surfaced by rebasing onto next, which gained #2444 (branch plan-structure
validation on task type=checkpoint:*) while this PR was in review.

cmdVerifyPlanStructure no longer applies one required-element set to every
task. A checkpoint:decision now requires <name> + <resume-signal> +
<decision> + <options>, and is exempt from the <action>/<verify>/<done>/
<files> set that auto and tracer tasks carry. The gated-one-way fixture
predated that split and failed on the new requirement:

  errors: ["Task 'Task 0: Confirm the on-disk format' missing <options>"]

Fixture rewritten to mirror the checkpoint:decision contract exactly — real
<options> with two <option> children — rather than padding it with fields
checkpoints no longer need. That also drops the plain-prose <verify> the
earlier revision carried purely to dodge the prompt-injection scan; a
checkpoint task has no <verify> requirement at all, so the workaround is
moot.

Verified against the real gsd-tools CLI across all four cases: gated one-way
(valid, silent), ungated one-way (valid, warns), costly (valid, silent),
absent (valid, silent).

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 10:44:55 -04:00
Tom Boucher
04a0eb8d63 fix(#2450): CRLF-tolerant session-section rewrite + no-op-detection guard (#2482)
* fix(#2444): re-resolve body-parser to 2.3.0 in lockfile (GHSA-v422-hmwv-36x6)

GHSA-v422-hmwv-36x6 (body-parser DoS via invalid limit value, low severity,
published 2026-07-20T23:23:26Z) made tests/npm-integrity-gate.test.cjs
(#3588: root workspace production tree has no advisories) fail any subsequent
npm audit --omit=dev. The advisory affects body-parser >=2.0.0 <2.3.0 pulled
transitively via @anthropic-ai/claude-agent-sdk -> @modelcontextprotocol/sdk
-> express -> body-parser@2.2.2.

express@5.2.1 already declares body-parser as ^2.2.1, so 2.3.0 is a valid
re-resolution within express's own compatibility range — no override needed.
Regenerated the lockfile via 'npm audit fix --omit=dev' which re-resolves
transitive deps within their declared ranges; package.json is unchanged.

Verified: npm audit --omit=dev reports 0/0/0/0/0 advisories; body-parser
now reads as 2.3.0 in 'npm ls body-parser --omit=dev'.

* test(#2450): failing-first CRLF regression for record-session insert path

cmdStateRecordSession's section-rewrite regexes (src/state.cts:1166,
:1195) used literal \\n which cannot match CRLF STATE.md delimiters.
The detector regex (CRLF-tolerant via $ under /m) entered the rewrite
branch, the writer regex silently no-op'd, but updated.push(...)/
sessionCreated=true ran unconditionally. Result: caller reported
recorded:true with 'Resume File' in updated, but the field was never
written to disk. With core.autocrlf=input, the CRLF working-tree file
produces no git diff, so the bug was invisible.

Adds three regression tests covering all three rewrite paths:
- CRLF STATE.md with ## Session and Resume file absent
- CRLF STATE.md with ## Session and Stopped at absent
- CRLF STATE.md with ## Session Continuity (bootstrap shape)

Each asserts the field IS on disk (the bug discriminator: the pre-fix
command's JSON output looked identical to a successful write).

* fix(#2450): CRLF-tolerant session-section rewrite + no-op-detection guard

Two regexes in cmdStateRecordSession used literal \\n which cannot match
CRLF STATE.md, silently no-op'ing the section rewrite while the CRLF-
tolerant detector above entered the branch. The reporter's exact repro:
on a CRLF STATE.md with one canonical session field absent, the command
returned recorded:true + updated:['Resume File'] but the field was never
written to disk.

Three changes:

1. src/state.cts:1166 (canonical ## Session rewrite regex): \\n -> \\r?\\n
2. src/state.cts:1195 (## Session Continuity insert regex): \\n -> \\r?\\n
3. Defensive invariant (#2450 class fix per reporter's suggestion): track
   whether the chosen branch's replace actually matched via callback flag.
   Only set sessionCreated=true and push to updated when rewriteMatched.
   Unreachable post-fix, but fail-loud is the right posture for a silent-
   success gate. If a future drift between the detector and writer regexes
   reintroduces the asymmetry, the caller will not see false updated entries.

Same canonical CRLF-tolerant form already in use at check-command-router.cts
:205 (extractPlanDesignatedSections). Same bug class previously fixed in
#1658, #1668, #2206, #2449.

* fix(#2450): address review followups + add changeset

Code-review + security-review both flagged the unreachable else at the
Session Continuity branch (defaulted rewriteMatched=true in dead code,
re-arming the bug class for future drift). Removed the else; the
remaining code path leaves rewriteMatched=false if linesToInsert is
empty, preserving the fail-loud posture.

Added scope-limitation doc to the rewriteMatched gate: it covers the
INSERT path only, not the earlier in-place stateReplaceField successes
(which DID land on disk and correctly push to updated unconditionally).

Tests:
- Normalized STATE_CRLF_SESSION_MISSING_RESUME fixture to match the
  canonical 6-key frontmatter of STATE_WITH_SESSION (code-review I2).
- Added mixed-ending test (LF frontmatter + CRLF body) to close
  CONTRIBUTING.md:490 'Mixed CRLF/LF newlines' requirement (I1).

Added Fixed changeset (code-review H1).

* docs(changeset): backfill PR number to 2482
2026-07-21 09:53:14 -04:00
Tom Boucher
909a3b180b fix(#2470): install pi's extension as gsd.js so pi actually discovers it (#2478)
* test(#2470): failing-first — pi extension must satisfy pi's auto-discovery filter

pi auto-discovers extensions/ entries through isExtensionFile(), which accepts
only .ts and .js. GSD installs its extension as gsd.cjs, so pi silently skips
it: no /gsd command, no error, no log line.

Encodes pi's discovery PREDICATE rather than a literal filename, so the
contract keeps holding across future renames, and adds the migration-006 test
matrix for retiring the stale gsd.cjs left in pre-fix installs.

Red until the fix lands.

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

* fix(#2470): install pi's extension as gsd.js so pi actually discovers it

pi auto-discovers extensions/ entries via isExtensionFile(), which accepts
only .ts and .js and skips everything else silently. capabilities/pi declared
the dest as gsd.cjs, so the extension installed correctly and was then ignored
forever: no /gsd command, no error, no log line.

Install it as gsd.js. The in-repo source stays pi/gsd.cjs — tests require() it
directly and .cjs is unambiguous CommonJS; only the installed name has to
satisfy pi, and pi loads accepted files through jiti, which handles CJS and ESM
alike. (The reporter's premise that ~/.pi/agent/package.json declares
"type":"commonjs" does not hold — pi never writes that file.)

Renaming an installed artifact requires a migration record, so add 006 to
retire the stale gsd.cjs from pre-fix installs; without it the old path drops
out of the manifest and uninstall can never remove it. The migration plans
nothing for an unmanifested gsd.cjs: emitting remove-managed there would have
the executor downgrade it to preserve-user and mark it blocked, failing the
install for anyone who hand-placed their own file.

Also pins body-parser >=2.3.0 (GHSA-v422-hmwv-36x6). The advisory reaches the
production tree transitively via the Claude Agent SDK and fails the
npm-integrity gate, blocking any PR; pinned via the existing overrides idiom.

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

* fix(#2470): address orthogonal review findings + register migration checksum

Code review:
- pi/gsd.cjs's install docstring still told readers to copy the file to
  extensions/gsd.cjs — the exact silently-broken state this PR fixes. Anyone
  following it recreated the bug.
- Two stale extensions/gsd.cjs comments in install-minimal-hooks.test.cjs.

Security review:
- _installNativePluginIfDeclared confined nativePlugin.dir but joined
  nativePlugin.file onto the validated directory unchecked, so a descriptor
  whose file carried .., an absolute path, or a NUL byte would have written
  outside configHome. Not reachable in a shipped build (descriptors are
  first-party and compiled into the capability registry), but file is exactly
  the field this PR changes. Confine the full dest path instead; for a
  well-formed descriptor this resolves identically to the previous
  mkdir(dir) + join(dir, file). Covered by four new write-confinement tests.

Also register migration 006 in the #670 EXPECTED_CHECKSUMS baseline — shipped
migration bodies are locked to a committed checksum and a new migration fails
CI until it is listed.

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

* fix(#2470): never dereference a symlinked managed path when snapshotting

fs.copyFileSync follows symlinks, so a managed path replaced by a link had the
REFERENT's bytes copied into the migration journal's rollback and backup trees
— a gsd.cjs symlinked at a private key would land that key's contents under
gsd-migration-journal/. Deletion was already safe (fs.rmSync unlinks the link,
never the target); the copy was not.

Nothing GSD installs is ever a symlink, so the faithful snapshot of a symlinked
managed path is the link itself. copyPreservingSymlink recreates it, which
keeps rollback fidelity (restore re-creates the same link) while never reading
the referent. Scoped the pre-delete to the symlink branch only, so the
regular-file path keeps copyFileSync's overwrite-in-place and a mid-restore
failure cannot destroy the destination. The restore-side existence check moves
to lstat, since existsSync follows a link whose target is gone and would
silently skip the restore.

This lives in the engine all six migrations share, so 000-005 are hardened too.

Also regenerates the pi golden-parity hash: correcting pi/gsd.cjs's own install
docstring changes the extension's content, which the golden suite caught.

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

* fix(#2470): symlink-preserve the in-apply failure-recovery restore too

The previous commit routed three copy sites through copyPreservingSymlink but
missed a fourth: the catch block inside applyInstallerMigrationPlan, which
replays rollback snapshots taken earlier in the SAME apply attempt. Those
snapshots are symlinks precisely because of that commit, so the raw
copyFileSync there dereferenced them and wrote the referent's bytes to the LIVE
install path — worse than the journal-tree leak it was meant to fix, since it
is user-visible and at a predictable location.

Verified by experiment rather than assertion: with the pre-fix line restored,
the managed path comes back as a REGULAR FILE containing the referent's bytes;
with the fix it comes back as a symlink and the bytes appear nowhere.

The accompanying test injects the failure by letting the delete succeed and
then throwing once, modelling a later step failing after the delete. That
ordering is load-bearing — an earlier draft injected before the delete, which
leaves the live path in place, so the pre-fix copyFileSync hit a same-file
collision and threw instead of leaking. That draft passed against the bug it
was written to catch; this one fails against it.

Adds the missing rollback() coverage as well: a restored symlinked managed path
must come back as a link pointing at its original target.

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

* test(#2470): read the backup location from the journal, not the plan

The new backup-content assertion read backupRelPath off result.plan.actions,
where it is always null: the planner reserves the field and apply chooses the
concrete location, recording it in the journal. The assertion therefore failed
on "backup path must be recorded for the user" rather than on anything about
the behavior it was written to check.

Read it from the journal, which is the authoritative record. Verified by
executing all four new test bodies in-process against the built engine — the
backup file exists and holds the locally patched content.

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

* chore(#2470): backfill changeset pr number to 2478

* chore(#2470): backfill changeset pr number to 2478

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 08:26:47 -04:00
Tom Boucher
b6ebf9d4b4 fix(#2449): make tdd.review-checkpoint frontmatter regex CRLF-tolerant (#2477)
* test(#2449): failing-first CRLF regression for tdd.review-checkpoint

cmdTddReviewCheckpoint's frontmatter regex /^---\\n...\\n---/ (line 751)
cannot match CRLF PLAN.md, so a type:tdd plan with Windows line endings
was silently classified as 'no type:tdd plans' — output indistinguishable
from a phase that genuinely contains no TDD plans. The advisory gate then
short-circuited to a confident pass with no violations table.

Adds tddPlanCrlf() fixture helper (CRLF twin of tddPlan) and a [crlf]
test case that asserts tddPlans===1 for a CRLF type:tdd plan (would be 0
before the fix), plus block:true/violations:1 since the fixture has no
RED/GREEN commits.

* fix(#2449): make tdd-review-checkpoint frontmatter regex CRLF-tolerant

The frontmatter delimiter regex at src/check-command-router.cts:751 used
literal \\n which cannot match a CRLF PLAN.md delimiter (---\\r\\n).
frontmatterMatch was null, the plan was never classified type:tdd,
tddPlanFiles stayed empty, and the advisory gate short-circuited to a
confident pass with no violations table.

The fix replaces /^---\\n([\\s\\S]*?)\\n---/ with
/^---\\r?\\n([\\s\\S]*?)\\r?\\n---/ — the same CRLF-tolerant form
already used elsewhere in the same file at line 205
(extractPlanDesignatedSections). Same canonical pattern; one-line change.

Same bug class previously fixed in #1658, #1668, #2206; this instance is
in a different file and is not a duplicate of any of them.

* fix(#2444): re-resolve body-parser to 2.3.0 in lockfile (GHSA-v422-hmwv-36x6)

GHSA-v422-hmwv-36x6 (body-parser DoS via invalid limit value, low severity,
published 2026-07-20T23:23:26Z) made tests/npm-integrity-gate.test.cjs
(#3588: root workspace production tree has no advisories) fail any subsequent
npm audit --omit=dev. The advisory affects body-parser >=2.0.0 <2.3.0 pulled
transitively via @anthropic-ai/claude-agent-sdk -> @modelcontextprotocol/sdk
-> express -> body-parser@2.2.2.

express@5.2.1 already declares body-parser as ^2.2.1, so 2.3.0 is a valid
re-resolution within express's own compatibility range — no override needed.
Regenerated the lockfile via 'npm audit fix --omit=dev' which re-resolves
transitive deps within their declared ranges; package.json is unchanged.

Verified: npm audit --omit=dev reports 0/0/0/0/0 advisories; body-parser
now reads as 2.3.0 in 'npm ls body-parser --omit=dev'.

* test(#2449): add mixed-endings (CRLF frontmatter + LF body) variant

Per code-review Low finding: CONTRIBUTING.md:490 names 'Mixed CRLF/LF
newlines' as a required adversarial fixture class. The pure-CRLF [crlf]
test covers the reported bug (Windows editor + autocrlf=input). This
[crlf-mixed] variant covers the more adversarial case where frontmatter
delimiters are CRLF but the body is LF (editor that normalizes body text,
or a toolchain concatenating CRLF + LF fragments). The classifier only
reads frontmatter, so detection is unaffected — but the test locks the
behavior.

* docs(changeset): add Fixed fragment for #2449 PR

* docs(changeset): backfill PR number to 2477
2026-07-21 08:13:13 -04:00
Tom Boucher
46ba9ed464 fix(#2444): branch plan-structure validation on task type=checkpoint:* (#2473)
* test(#2444): failing-first regression for checkpoint:* plan-structure validation

Add acceptance-criteria tests covering the three canonical checkpoint task
types (human-verify, decision, human-action) plus an unknown-subtype
forward-compat case. Each canonical type must pass verify plan-structure
when it carries its type-specific required fields (per
gsd-core/references/checkpoints.md), and must be flagged when those
fields are missing. Non-checkpoint tasks keep the existing
<action>/<verify>/<done>/<files> requirements unchanged (AC3 regression
guards).

The existing 'errors when checkpoint task but autonomous is true' fixture
is updated to use the canonical checkpoint:human-verify triple
(<what-built>/<how-to-verify>/<resume-signal>) so it does not collide
with the new per-type validator; the assertion (autonomous is not false)
is unchanged.

* fix(#2444): branch plan-structure validation on task type=checkpoint:*

cmdVerifyPlanStructure unconditionally required <action>/<verify>/<done>/
<files> on every task, so every checkpoint:* task — which uses the
checkpoint convention's type-specific fields instead — was reported as a
structural error. Checkpoint-heavy phases produced walls of false findings.

The fix introduces two pure helpers in verify.cts:

  - extractPlanTaskInfos(content): single ReDoS-safe pass over
    <task ...>...</task> blocks that captures BOTH the opening-tag
    attribute string (so the type= selector is not lost, as it is with
    extractTaggedBlocks) and the body, returning a typed PlanTaskInfo.

  - validatePlanTaskStructure(task): branches on the task's type.
    checkpoint:human-verify requires <what-built>/<how-to-verify>/
    <resume-signal> (the canonical triple).
    checkpoint:decision requires <decision>/<options>/<resume-signal>.
    checkpoint:human-action requires <action>/<instructions>/
    <verification>/<resume-signal>.
    Unknown checkpoint:* subtypes require only the universal
    <resume-signal> (forward-compat). All other types keep the historical
    <action>/<verify>/<done>/<files> requirements unchanged.

Canonical reference: gsd-core/references/checkpoints.md. Per-type field
sets validated against the documented templates in
agents/gsd-planner.md and gsd-core/templates/phase-prompt.md.

* fix(#2444): re-resolve body-parser to 2.3.0 in lockfile (GHSA-v422-hmwv-36x6)

GHSA-v422-hmwv-36x6 (body-parser DoS via invalid limit value, low severity,
published 2026-07-20T23:23:26Z) made tests/npm-integrity-gate.test.cjs
(#3588: root workspace production tree has no advisories) fail any subsequent
npm audit --omit=dev. The advisory affects body-parser >=2.0.0 <2.3.0 pulled
transitively via @anthropic-ai/claude-agent-sdk -> @modelcontextprotocol/sdk
-> express -> body-parser@2.2.2.

express@5.2.1 already declares body-parser as ^2.2.1, so 2.3.0 is a valid
re-resolution within express's own compatibility range — no override needed.
Regenerated the lockfile via 'npm audit fix --omit=dev' which re-resolves
transitive deps within their declared ranges; package.json is unchanged.

Verified: npm audit --omit=dev reports 0/0/0/0/0 advisories; body-parser
now reads as 2.3.0 in 'npm ls body-parser --omit=dev'.

* test(#2444): close review gap-closure tests + harden type-attr charset

Orthogonal review (code-review + security-review subagents) returned APPROVE
on Standards and Spec. Per the playbook's zero-tolerance policy, address
every Low finding:

Spec gap-closures:
- AC3 verbatim: add explicit <done> and <files> regression tests for
  non-checkpoint tasks (pre-existing tests only covered <action> and
  <verify>).
- AC2: add checkpoint:decision missing <decision>, checkpoint:human-action
  missing <action>, checkpoint:human-action missing <verification> cases
  (the implementation enforces all of these; only one missing-field case
  per type was previously tested).
- Remove the duplicate 'returns error for nonexistent file' test that
  leaked into the new describe block from the insertion edit.

Security hardening (Low-sev, defense-in-depth):
- Tighten the task type= attribute extractor in src/verify.cts from
  [^"'>\s]+ to [\w:-]+ so a hostile type= attribute cannot carry
  markup fragments (e.g. type=evil<fragment) into the verifier's typed
  JSON output. All legitimate type values (auto, tracer, manual,
  checkpoint:human-verify, checkpoint:decision, checkpoint:human-action,
  checkpoint:tdd-review) match the tighter charset.
- Add adversarial regression test asserting type=evil<fragment surfaces
  as 'evil' (capture stops at '<'), with no markup chars (< > ( ) &)
  in the surfaced type field.

* docs(changeset): add Fixed fragments for #2444 PR

Two fragments:
- sturdy-jays-tumble.md: the verify plan-structure checkpoint fix
- witty-badgers-hum.md: the body-parser 2.3.0 re-resolution

PR number backfilled to 0 placeholder per CLAUDE.md 'PR Number Handling';
will backfill to the real PR number immediately after gh pr create returns.

* docs(changeset): backfill PR number to 2473

Per CLAUDE.md 'PR Number Handling': backfill the placeholder pr:0 with the
real PR number returned by gh pr create.
2026-07-21 08:12:52 -04:00
Tom Boucher
a54feb4216 fix(#2440): per-counter progress ratchet — total_plans always takes derived value (#2468)
* fix(#2440): per-counter progress ratchet — total_plans always takes derived value

Two sites fixed (targeted — existing body-only write tests preserved):

Site A — read path: shouldPreserveExistingProgress (state-document.cts:167)
removed total_plans from the all-or-nothing ratchet check. It now joins
total_phases as an always-derived counter. Only completed_phases and
completed_plans keep ratchet behaviour (they are monotonic). This fixes
gsd-tools query state.json reporting stale total_plans when a curated
completed_plans triggers the ratchet.

Site B — write path: applyStatePreservation (state-transition.cts:162)
gained a deriveProgressKeys opt-in flag. When true (passed by
cmdStatePlannedPhase only), total_plans and total_phases take the
derived (post-sync) value instead of the wholesale curated restore.
When false (the default — state.update, state.patch), the existing
#3242 wholesale protection stays fully in force. This fixes the
state planned-phase verb writing a stale total_plans.

The opt-in approach preserves all 8 existing #3242/#1264/#500 body-only
write tests that assert wholesale progress preservation during non-
progress updates.

Tests:
- tests/state.test.cjs: 4 unit tests for shouldPreserveExistingProgress
  (total_plans upward/downward/equality + completed_plans ratchet active).
- tests/state-transition.test.cjs: 2 #2440 regression tests for
  deriveProgressKeys=true (total_plans takes derived; boundary at equality).
  The existing !resync wholesale-restore test stays unchanged (default
  behavior preserved).

References: #2440; #1446 (total_phases read-path fix — same principle);
#3242 Bug A (body-only preservation — protection preserved via the opt-in
gate); ADR-1769 (applyStatePreservation table-driven preservation).

* chore(#2440): backfill pr:2468 in .changeset/mellow-eagles-chatter.md
2026-07-20 19:24:21 -04:00
Tom Boucher
6140627f5c fix(#2427): ground smart-entry completion in ROADMAP-derived counts + tighten status regex (#2466)
* fix(#2427): ground smart-entry completion in ROADMAP-derived counts + tighten status regex

Two coupled defects in isComplete (src/smart-entry.cts):

1. Two-scale comparison: isComplete compared global current_phase (from
   STATE.md body 'Phase: N') against milestone-scoped total_phases (from
   STATE.md frontmatter progress.total_phases, written once at milestone-
   switch time and going stale as soon as new phases are appended to the
   roadmap). When current_phase >= stale total_phases (e.g. 7 >= 4),
   isComplete tripped true even though later phases were still unchecked
   in ROADMAP.md.

2. Over-broad status regex: /\bcomplete(d)?|done|shipped\b/i matched any
   'shipped' or 'done' substring — including per-phase status like
   'Phase X shipped — PR #N' — and falsely satisfied the status side of
   the completion check.

Fix:

- Added two new SmartEntrySignals fields: roadmap_total_phases and
  roadmap_completed_phases, populated by calling the existing
  deriveProgressFromRoadmap helper (from phase-lifecycle.cts:60) when
  ROADMAP.md exists. These are global, authoritative counts from the
  Progress table — never stale.

- isComplete now prefers the roadmap-derived counts when available
  (completed >= total) and falls back to the legacy STATE.md comparison
  only when the roadmap has no parseable Progress table (backward compat
  for fresh or non-standard projects).

- Tightened the status regex to /\b(milestone\s+complete|all\s+phases\s+complete|complete(d)?)\b/i.
  Drops 'done' and 'shipped' (per-phase language). Keeps milestone-level
  signals per ADR-2207 (milestone complete, all phases complete) plus the
  legacy short form 'complete'/'completed'.

Tests (tests/smart-entry.unit.test.cjs gains a #2427 describe block):
- Mid-milestone with stale total_phases=4, current_phase=7, per-phase
  'shipped' status, and 3 unchecked roadmap phases → NOT complete (the
  core bug scenario).
- All roadmap phases complete classifies as complete even with stale
  cached total_phases (roadmap wins).
- Per-phase 'shipped' or 'done' status alone does NOT satisfy completion
  when roadmap phases are unchecked.
- Legacy fallback: empty roadmap (no Progress table) still classifies via
  STATE.md comparison (backward compat).

The makeProject test helper now accepts a string for the 'roadmap'
parameter (written verbatim) in addition to the boolean shorthand, so
tests can supply a real Progress table.

References: #2427; ADR-2207 (milestone status lifecycle); ADR-2143
(column-name-driven Progress table parsing via deriveProgressFromRoadmap);
triage note that this is a read-side fix only (the milestone-switch write
path in state.cjs is out of scope).

* chore(#2427): backfill pr:2466 in .changeset/curious-rams-run.md
2026-07-20 17:26:20 -04:00
Tom Boucher
be5113abfe fix(#2408): fold colliding phase statuses + add W023 collision warning (#2461)
* fix(#2408): fold colliding phase statuses + add W023 collision warning

Two coupled bugs from #2408:

1. cmdStats last-write-wins status (src/commands.cts:1610-1618): when two
   on-disk phase directories normalize to the same phase key (e.g.
   `05-real/` and `05-real-stray/`), the directory-scan merge overwrote
   `status` with whatever the *current* directory in scan order computed,
   discarding `existing?.status` entirely. fs.readdirSync order is not
   stable across platforms, so /gsd-stats could silently report `Not
   Started` for a phase that is actually `Complete`. Plan/summary counts
   were already additively merged; only `status` was wrong.

   Fix: introduced a `foldPhaseStatus(a, b)` helper that returns whichever
   status is further along the precedence ladder
   `Complete > Needs Review > Executed > In Progress > Planned > Not Started`
   (with `Pending` and unrecognized statuses ranked after). The merge site
   now calls `existing ? foldPhaseStatus(existing.status, status) : status`.
   The fold is commutative, so the result is identical regardless of read
   order.

2. cmdValidateHealth had no collision-detection pass (src/verify.cts):
   codes W001-W022 cover every condition except normalized-key collisions,
   so an operator got zero signal that anything was wrong.

   Fix: added W023 — groups phaseDirEntries by their normalized phase key
   (via the existing normalizePhaseName + extractPhaseToken helpers from
   phase-id.cjs — same normalization cmdStats uses) and emits a warning
   for any group with ≥2 dirs. The warning names the normalized key, both
   directory names (sorted by comparePhaseNum for stable output), and each
   directory's independently-computed status (via determinePhaseStatus
   imported from commands.cjs). Wording is deliberately neutral — never
   guesses which directory is the real one. The optional --repair path
   from the issue is intentionally NOT implemented in this PR (the issue
   marked it lower priority and acceptance criterion 4 is vacuously
   satisfied by omission).

Triage correction applied: the issue proposed W022, but that code is
already in use for config.json model-tier validation (src/verify.cts
:1372-1391). The next free code is W023, used here.

Tests:
- tests/commands.test.cjs: integration test that 05-real/ (Complete) +
  05-real-stray/ (empty/Not Started) collide and stats reports the merged
  phase as Complete regardless of read order; plus a direct unit test of
  foldPhaseStatus asserting commutativity + correct precedence for every
  status pair + correct handling of unrecognized statuses.
- tests/health-validation.test.cjs: integration test that W023 fires on
  the collision naming both dirs + their statuses (and uses neutral
  wording), plus a negative test that no W023 fires when only one dir
  exists per key.

References: #2408; reporter's three-layer triage + acceptance criteria;
triage correction that W022 is already in use (model-tier validation).

* chore(#2408): backfill pr:2461 in .changeset/graceful-koalas-forage.md
2026-07-20 15:49:57 -04:00
Tom Boucher
455ad49ae3 feat(#2296): config-gated provider escalation on quota-exceeded (#2458)
* test(#2296): failing-first coverage for provider escalation on quota-exceeded

Covers the provider-escalation ladder layered onto EXEC.CLASSIFY: back-compat
(no escalation block without --failure-class), cap boundaries at
min(max_escalations, list length) at limit-1/limit/limit+1, opt-in gating,
malformed/hostile provider_escalation config, the --failure-class CLI negative
matrix, config-key registration, and a fast-check budget-limit property.

Red until the resolver, CLI flag, and manifest key land.

Refs #2296

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

* feat(#2296): config-gated provider escalation on quota-exceeded

The dynamic_routing tier ladder escalates within one provider, which does not
help when that provider is what ran out of quota. Add an opt-in provider ladder
layered on the existing EXEC.CLASSIFY seam.

- model-resolver: resolveProviderEscalation walks dynamic_routing.provider_escalation
  capped at min(max_escalations, list length), reporting from/to/attempted/exhausted.
  Invalid entries are dropped (ADR 227 shape validation). Stays a leaf module —
  the quota-class policy decision is the caller's, per the CONTEXT.md contract.
- agent-command-router: export a frozen AGENT_FAILURE_CLASSES so the new CLI
  validator cannot drift from the classifier that produces the values.
- resolve-execution: --failure-class flag; emits an escalation block ONLY when
  passed, so the existing JSON contract is byte-identical for every caller.
- config-schema.manifest: register dynamic_routing.provider_escalation.
- execute-phase step 7.1: auto-escalate, honor Retry-After, fail loudly naming
  every model tried once the ladder is spent.

Refs #2296

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

* fix(#2296): extract quota recovery to a reference fragment; regen goldens

The step 7.1a addition pushed gsd-core/workflows/execute-phase.md from 93390 to
95111 LF bytes, past the frozen ADR-857 Phase 6 ceiling (hard <93600, margin
<=93400) asserted by tests/fix-2285-claude-orchestration-wiring.test.cjs. The
base sat 10 bytes under the margin, so no inline wording would have fit.

That gate's own rationale is that optional-feature detail belongs in a fragment,
not the host loop. Moved BOTH the new provider-escalation branch and the
pre-existing manual recovery prompt into
gsd-core/references/execute-phase-quota-recovery.md, leaving step 7.1 as a
one-line pointer. execute-phase.md is now 92880 bytes — 510 SMALLER than base.

Also regenerates the fixtures that legitimately moved because three shipped
files changed (gsd-tools.cjs, config-schema.manifest.json, execute-phase.md):
golden-install-parity + install-tree for all 16 runtimes, INVENTORY.md +
INVENTORY-MANIFEST.json for the new reference, and the workflow size baseline.

Refs #2296

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

* test(#2351): make the C1 orphan-reaping test load-independent

tests/run-with-timeout.test.cjs C1 asserted the child heartbeat file exists
after a 1s group-kill window, but the child only wrote it on the first 100ms
setInterval tick. Nothing synchronized the two: on a loaded container the group
is SIGKILLed before that tick lands, the file never appears, and the assertion
fails for a reason unrelated to reaping. Observed failing on both linux-node22
and linux-node24.

The behavior actually under test is the FREEZE assertion (heartbeat stops
advancing => descendant was reaped, not orphaned). That is unaffected by
sampling once more at t=0.

Child now writes its first heartbeat synchronously at startup before arming the
interval, and the kill window widens 1s -> 3s to cover child boot under load.
Both remove the timing dependency; neither weakens what the test proves.

Refs #2296

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

* chore(#2296): backfill pr:2458 in .changeset/rapid-jays-bark.md

* chore(#2296): regenerate fixtures after rebase onto #2402

The rebase conflicted on the generated golden-install-parity fixtures and
workflow-size-baseline.json because #2402 (b6e6a22fc) regenerated the same
artifacts. Conflict resolution picked a side to unblock the rebase; a true
regeneration on the combined tree then produced further drift, confirming the
resolved content was stale and would have dropped #2402's fixture changes.

Regenerated goldens, install-tree, size baseline, and INVENTORY-MANIFEST from
the merged tree. docs/INVENTORY.md keeps BOTH new reference rows.

execute-phase.md is 92782 LF bytes with both #2402's and this PR's extractions
applied — under the frozen ceiling (hard <93600, margin <=93400).

Refs #2296

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 14:59:30 -04:00
Tom Boucher
b6e6a22fce fix(#2402): honor response_language across orchestrator output + UAT checkpoint renderer (#2457)
* fix(#2402): honor response_language across orchestrator output + UAT checkpoint renderer

Replays the in-flight bot branch fix/2402-response-language-orchestrator-coverage
(seven commits, never pushed) onto current origin/next as a single squashed commit.
The original work was substantial and correct; this commit preserves its full scope,
trimmed where rebase conflicts + workflow size budgets required it.

Three independent layers where response_language was being dropped are closed:

Layer 1 — orchestrator-facing directives across workflows. Adds the strong
"All user-facing output in this workflow MUST be presented in {response_language};
technical terms, code, paths, and subagent prompts stay in English" directive
to ~40 workflows that previously either lacked it entirely (verify-work,
new-project, new-milestone, quick, manager, and ~35 more) or carried only
the weak subagent-prompt-only form (plan-phase, execute-phase). The directive
covers narration between tool calls and banner output, not just the
AskUserQuestion prompts.

Layer 2 — UAT checkpoint renderer (src/uat.cts). buildCheckpoint now accepts
an optional responseLanguage parameter and renders the frame strings
("CHECKPOINT: Verification Required", "Type `pass` or describe what's wrong.")
in any of 9 languages (English/Spanish/French/German/Portuguese/Japanese/
Chinese/Korean/Italian) with an alias table covering ~30 input variants
(en, es, español, ja, 日本語, etc.). cmdRenderCheckpoint reads
config.response_language via loadConfig(cwd) and passes it through, so the
byte-for-byte block verify-work.md reprints verbatim is already localized
when written — preserving the anti-injection hygiene rule at verify-work.md
(the model is forbidden to translate after the fact). CJK display width is
computed by East Asian Width property ranges (W/F) so the right ║ border of
the banner stays aligned for full-width characters. English fallback is
byte-identical to the pre-fix behavior when response_language is unset or
unrecognized.

Layer 3 — literal English report templates in execute-phase. The top-of-
workflow directive covers all template sites (templates are a structural
source, not literal output). Inline render-language notes that previously
sat at each template site were removed during the squash because they
pushed execute-phase.md over its frozen pre-phase-6 byte ceiling
(93600 — ADR-857 Phase 6 capstone). The single top directive covers the
same surface with fewer bytes.

Also extends src/docs.cts and src/init.cts to propagate response_language
into the init JSON bundle of the additional workflows so the directive can
read it.

Tests added:
- tests/uat.test.cjs: buildCheckpoint with unset/unrecognized language falls
  back to English default; recognized language swaps only the two frame
  strings while structural lines stay untouched; CJK display-width regression
  (independent recomputation of East Asian Width W/F ranges).
- tests/workspace.test.cjs, tests/docs-update.test.cjs: response_language
  wiring through docs.cts/init.cts.

References: #2402; reporter's three-layer triage + Layer-4 follow-up; the
byte-for-byte anti-injection hygiene rule at verify-work.md (the reason
Layer 2 must be renderer-side, not model-translated).

This is a squash of the in-flight bot branch — seven commits representing
the original implementation plus its subsequent fix/CJK-padding/test/
changeset/regen cycles, none of which were ever pushed or PR'd. The squash
captures the final coherent state.

* chore(#2402): backfill pr:2457 in .changeset/2402-response-language-orchestrator-coverage.md

* chore(#2402): regen golden + size baseline after rebase against #2315 (PR #2451)

Rebase conflicts were entirely in generated artifacts (golden-install-parity
fixtures + workflow-size-baseline.json). After taking theirs during rebase,
regenerated cleanly against the merged source tree.
2026-07-20 14:22:27 -04:00
Tom Boucher
12e4d93b19 fix(#2393): add GSD_ALLOW_SYMLINKED_DEST opt-in for intentional user-owned symlink layouts (#2445)
* fix(#2393): add GSD_ALLOW_SYMLINKED_DEST opt-in for intentional user-owned symlink layouts

Bug: v1.7.0's destSubpath write-confinement (ADR-1239 Phase B) refused
install/update whenever CLAUDE_CONFIG_DIR (or an artifact-kind child like
skills/, hooks/) was a pre-existing symlink, with no opt-out. Three
legitimate user-owned layouts were blocked:

  - (lars-hh) CLAUDE_CONFIG_DIR=~/.claude-personal with skills/hooks
              symlinked to a user-owned external dir
  - (Mamiki)  ~/.claude/skills is a Windows Junction to a shared skills dir
  - (Azd325)  ~/.claude itself is a symlink to a dotfiles repo (the early
              root-is-symlink return refused before the component loop ran)

Fix: add GSD_ALLOW_SYMLINKED_DEST env var (accepts '1' or 'true'). When
set, hasExistingSymlinkBetween follows symlinks instead of refusing them.
Cross-platform: fs.lstatSync().isSymbolicLink() returns true for both
POSIX symlinks and NTFS junctions (Node ≥ 16), so Mamiki's Junction case
is handled by the same code path.

Threat model preserved (these still refuse EVEN WITH opt-in):
  (a) path-traversal in the destSubpath string itself ('../../etc'-style)
      — ADR-1239 Phase B threat (a), untrusted destSubpath protection
  (b) a symlink whose resolved real path equals the install root itself
      — would let _removeGsdEntries wipe the root; #1704 threat (b)
  (c) broken symlinks (realpathSync throws) — fail-closed

What opt-in RELAXES specifically: the 'pre-existing symlink pointing
outside configHome' refusal — #1704 threat (c). The user has explicitly
asserted they own and trust the symlink target.

Error messages at all 4 call sites (installRuntimeArtifacts, _copyStaged,
migrateLegacyDevPreferencesToSkill, installOpencodeFamilySkills) updated
to (1) name the env var opt-in, (2) be accurate when the root itself is
a symlink (Azd325's complaint that the old message accused destDir of
'containing' a symlink when the root was the actual symlink).

Docs: docs/CONFIGURATION.md Environment Variables table updated.

Regression tests in tests/install-write-confinement.test.cjs cover:
  - child-symlink layout (lars-hh / Mamiki): default refuses, opt-in allows
  - root-is-symlink layout (Azd325): default refuses, opt-in follows
  - path-traversal '../../etc' refused EVEN WITH opt-in (threat a preserved)
  - resolved-target-equals-install-root refused EVEN WITH opt-in (threat b)
  - broken symlink refused EVEN WITH opt-in (fail-closed)

* test(#2393): import beforeEach/afterEach in install-write-confinement suite

The original file imported only { describe, test } from node:test. The new
#2393 opt-in describe block uses beforeEach/afterEach to manage the
GSD_ALLOW_SYMLINKED_DEST env var lifecycle — add them to the import.

* test(#2393): correct broken-symlink test — existsSync follows link → loop terminates early

Initial test expected broken symlinks to be refused even with opt-in. That
was wrong: fs.existsSync follows symlinks, so a broken symlink returns
false from existsSync and the component loop terminates before the symlink
check fires. Both default and opt-in paths share this behavior; the fix
preserves it.

Updates the test to pin the actual current behavior so a future refactor
(e.g. switching to lstatSync for existence) is a deliberate behavior change.

* fix(#2393): realpath the install root — guard against macOS /var ↔ /private/var

Code review (security subagent) flagged a HIGH-severity hole in the threat-(b)
preservation: realTarget (from fs.realpathSync) is fully symlink-resolved,
resolvedRoot (from path.resolve) is lexical-only. On macOS /var is a symlink
to /private/var, so resolvedRoot='/var/foo/.claude' but realConfigHome is
'/private/var/foo/.claude'. A symlink whose realtarget matches the install
root by real path would compare unequal to the lexical resolvedRoot —
defeating the wipe-protection guard exactly in the reporter's case (Azd325,
nix-darwin: ~/.claude is itself a symlink).

Fix: compute realRoot once via fs.realpathSync(resolvedRoot) at function entry
(with fail-closed fallback to lexical form on realpath failure — broken/missing
root, permission denied, exotic FS). Threat (a) path-traversal check above
still confines regardless. Compare against BOTH lexical and real forms in
both the root-symlink and component-symlink branches.

Also adds the reviewer's transitivity-trust clarification comment: once a
symlink is followed under opt-in, the walk continues from the resolved real
path WITHOUT re-checking further segments stay inside a confining boundary.
This is documented opt-in semantics — one opt-in trusts the whole reachable
tree — and the comment makes the design choice explicit so a future
maintainer doesn't add a 'follow one symlink only' expectation.

Regression test added for the macOS /var normalization case (spelled configHome
via os.tmpdir() lexically while pointing the test symlink through its realpath).
Test skips on non-darwin platforms and when os.tmpdir() has no symlink component.

* fix(#2393): root-symlink branch — do not apply threat-(b) check to root itself

Initial fix applied the wipe-threat-(b) check to the root-symlink branch
unconditionally. That was wrong: when root itself is a symlink (Azd325's
nix-darwin case), its realpath IS realRoot by construction — so the check
always fires, defeating the opt-in for exactly the case it was meant to
enable.

The wipe threat (b) does NOT apply to root being a symlink: destDir is a
CHILD of root, and resolving root just gives root's target. There is no
circular back-reference to root from a path that descends from a resolved
root. So the root-symlink branch should just follow the symlink under opt-in
and continue the walk, no threat-(b) check.

Threat (b) only fires in the COMPONENT loop, where a child symlink can
resolve back to the install root. That branch keeps the (b) check using BOTH
lexical and real forms of root (the macOS /var ↔ /private/var fix from the
prior commit).

* fix(#2393): apply opt-in at the 5 bin/install.js call sites + add env-var/transitive tests

Code review (correctness subagent) flagged a Critical coverage gap: the
initial fix updated only the 4 src/install-engine.cts call sites. Five
more call sites in bin/install.js still used the 2-arg signature, so the
opt-in env var was silently ignored on:

  - installCodexConfig (config.toml + agents/ dir + per-agent .toml paths)
    — Codex only
  - copyWithPathReplacement (the generic emit path: workflows, commands,
    staging) — ALL runtimes
  - resolveInstallRelativePath (path resolver used in various places)

Result: a user setting GSD_ALLOW_SYMLINKED_DEST=1 would see SOME refusals
disappear (engine path) and OTHERS remain (bin/install.js paths) — a
partially-applied install and a confusing UX, directly contradicting the
PR's headline claim.

Fix:
- Export isSymlinkedDestOptIn from src/install-engine.cts alongside
  hasExistingSymlinkBetween
- Import it in bin/install.js
- Update all 5 bin/install.js call sites to pass { allowOptInFollow }
- Update all 3 bin/install.js error messages to name the env var, matching
  the engine's phrasing

Also addresses reviewer's Medium test-adequacy findings:
- isSymlinkedDestOptIn env-var parsing now tested directly (accepts only
  documented '1' / 'true'; rejects 'TRUE', 'yes', 'on', '0', 'false',
  empty, unset)
- transitive symlink chain (configHome/outer → outside1 → outside2) test
  pins the documented 'transitive and unbounded' opt-in semantics so a
  future contributor can't accidentally narrow it

* chore(changeset): backfill pr:2445 in .changeset/eager-wasps-swim.md
2026-07-20 07:56:36 -04:00
Tom Boucher
517bae8d6d fix(#2372): widen decision-coverage-plan to all planner-canonical tags, drop misleading "(or body)" (#2443)
* fix(#2372): widen decision-coverage scan to planner-canonical tags, fix message

Bug: check.decision-coverage-plan's remediation message told the user to
cite decisions "(or body)" but extractPlanDesignatedSections only scanned
<objective>/<tasks>/<task>/<action>. A decision cited in <read_first>,
<behavior>, <verify>, <acceptance_criteria>, or <done> was invisible to
the gate — false BLOCKING coverage gap, plus the message's own fix-hint
sent the user to "the body" where re-citing still failed.

Two-part fix (must change together — that drift was the bug):

1. Widen XML_DECISION_TAGS_RE in src/check-command-router.cts to also
   match <read_first>, <behavior>, <verify>, <acceptance_criteria>,
   <done>. These are all planner-canonical tags the planner is told to
   use (plan-phase.md:830-862, plan-phase.md:772). The body negative-
   lookahead mirrors the opening-tag set so each tag's body is captured
   independently.

2. Correct buildPlanMessage to name ONLY the surfaces the extractor
   actually scans (front-matter must_haves/truths/objective,
   designated markdown headings, and the nine planner-canonical tag
   bodies). The misleading "(or body)" clause is gone.

Also updates the planner's documented contract (agents/gsd-planner.md:69)
and user-facing docs (docs/CONFIGURATION.md, docs/USER-GUIDE.md) to
reflect the wider scan.

Regression tests in tests/decisions.test.cjs cover each newly-scanned
tag body, a control (no citation still uncovered), and a message/extractor
parity assertion that names every scanned surface — so the two cannot
drift apart again.

Out of scope (per triage): cmdDecisionCoverageVerify/buildVerifyMessage
is a separate command (decision-coverage-verify) checking shipped
artifacts, not plan citations — untouched.

* chore(#2372): regenerate agent-size-baseline + golden-install-parity fixtures

gsd-planner.md grew 49172 → 49294 (+122 chars) from the widened decision-
coverage contract (5 new scanned tag names + heading clarification).
Growth is justified: the contract surface is itself the fix — the prior
text under-described what the gate scans, which was the bug.

Updates:
- tests/agent-size-baseline.json (gsd-planner.md: 49172 → 49294)
- 17 tests/fixtures/golden-install-parity/*.json (one hash per runtime)
- tests/fixtures/install-tree/*.json (regenerated by gen:golden)

* fix(#2372): per-tag matching — outer-tag citations survive inner-tag nesting

Code review (subagent) flagged a Medium edge-case regression from the
single-alternation regex: when a newly-scanned tag nests inside another
scanned tag, the alternation's negative lookahead halts the outer tag's
body at the inner tag — losing any D-NN citation in the outer tag's
prefix prose. Concretely:

  <action>per D-05 <verify>npm test</verify></action>

  → 3-tag alternation (old):  captured 'per D-05 <verify>npm test</verify>' as <action> body → D-05 caught
  → 9-tag alternation (bug):  captured 'npm test' only (from <verify>); D-05 in <action> prefix LOST

Switches extractXmlTagBodies to per-tag matching: each tag gets its own
regex whose negative-lookahead tempers only against the SAME tag's
reopening. So <verify> inside <action> is absorbed into <action>'s body
(D-05 caught) AND <verify> is matched separately on its own pass.

Per-tag preserves both:
- the reporter's case (sibling tags inside <read_first>)
- nested-tag citations in outer-tag prefix prose
- ReDoS safety (each per-tag regex keeps the #2128 body tempering)

Also adds the reviewer's other requested edge-case tests:
- non-scanned tag (<name>) bearing D-NN must NOT count
- self-closing form <read_first /> safely ignored
- attribute form <verify type="...">D-NN</verify> (canonical planner shape)
- CRLF newlines in tag body do not break capture

* chore(changeset): backfill pr:2443 in .changeset/noble-elks-chatter.md
2026-07-19 23:07:13 -04:00
Tom Boucher
0bbbca2a46 fix(#2069): forward model_policy, model_profile_overrides, runtime from global defaults (#2442)
* test(#2069): add fail-first regression for global-defaults dropped keys

Adds four failing-first regression cases to tests/defaults-json-fallback.test.cjs:

- model_policy forwarded from ~/.gsd/defaults.json
- model_profile_overrides forwarded from ~/.gsd/defaults.json
- runtime forwarded from ~/.gsd/defaults.json
- parity: model_policy survives identically whether it lives in the global
  defaults or in a project's .planning/config.json

All four fail on unfixed code (Branch D of loadConfigResolved builds
_globalBaseCfg from a whitelist that omits these three keys). The
project-config path at config-loader.cts:602-604 already forwards them,
so the global path should too.

* fix(#2069): forward model_policy, model_profile_overrides, runtime from global defaults

The _globalBaseCfg whitelist in Branch D of loadConfigResolved previously
omitted three keys that the project-config path forwards parsed['…']:

  - runtime
  - model_profile_overrides
  - model_policy

so ~/.gsd/defaults.json silently dropped them. A machine-wide model policy
(or runtime / profile overrides) was honored inside a project (where
.planning/config.json carries it) but ignored for out-of-project runs —
resolve-model fell back to the profile default with no warning.

Adds the three entries to _globalBaseCfg in the same (globalDefaults['…']) || null
shape as the sibling keys and the project-config path, so global defaults
honor them identically.

Regression tests in the prior commit (#2069 fail-first) demonstrate the
fix on the same suite that previously failed.

* test(#2069): extend parity test to all three previously-dropped keys

Code review (subagent) flagged that the parity test only asserted
model_policy shape-parity between global-defaults and project-config paths.
A future regression breaking just runtime or just model_profile_overrides
shape (e.g. someone changing parsed['runtime'] to ?? null in the project
path) would slip a single-key test.

Extends the parity test to assert deepStrictEqual / strictEqual across
all three keys: model_policy, model_profile_overrides, runtime. Same
two-dir setup, three cheap assertions.

* chore(changeset): backfill pr:2442 in .changeset/sturdy-seals-fly.md
2026-07-19 22:44:08 -04:00
Tom Boucher
d16a66479a feat(#1950): broken-windows ledger — cross-phase defect register gating ship (#2441)
* feat(#1950): broken-windows ledger — cross-phase defect register gating ship

Adds a new  capability (#1950) that operationalizes GSD's
no-defer discipline as a tracked, enforced artifact:
accumulates stubs, TODOs, skipped tests, unrun verifies, and unmet truths
across phases, and /gsd-ship blocks while any entry is open.

Implementation:
- src/broken-windows.cts → gsd-core/bin/lib/broken-windows.cjs: typed IR +
  I/O entry points (parseLedger/renderLedger/appendWindow/markWaived/markFixed
  + cmdWindowsStatus/Append/Waive/MarkFixed). Frozen REASON enum for typed
  error assertions. Windows-safe atomic rename with retry on transient
  EPERM/EBUSY/EACCES.
- gsd-tools.cjs: new  subcommand (status | append | waive | fixed),
  wired via routeWindows + HOST_COMMAND_ROUTERS.windows.
- capabilities/broken-windows/capability.json: one ship:pre gate with
  artifact-frontmatter-equals predicate on WINDOWS.md open_count == 0.
  activationKey windows.enabled (default true) + sibling windows.enforce
  (default true, separate so tracking can precede enforcement).
- gsd-core/workflows/ship.md: capId==broken-windows branch in preflight,
  sibling to security — reads gsd_run windows status --raw, fails closed
  on open_count > 0 or unreadable ledger.
- agents/gsd-executor.md: extends the existing ## Known Stubs instruction
  to also append to WINDOWS.md via gsd_run windows append (best-effort,
  never blocks execution).
- agents/gsd-verifier.md: new Step 8b — record unmet truths + human-verify
  items in WINDOWS.md.
- gsd-core/workflows/progress.md: surfaces open + waived counts.
- docs/COMMANDS.md + CONTEXT.md glossary entry + docs/INVENTORY.md:
  document the gate, waiver mechanism, and new module.
- tests/broken-windows.test.cjs: pure + CLI behavioral coverage + fast-check
  roundtrip property; fail-closed on malformed ledger; security boundary on
  path traversal in --file.

Backward-compatible: a project with no .planning/WINDOWS.md reports
open_count: 0 and ships cleanly. Disable enforcement per-project with
gsd config-set windows.enforce false (tracking continues, gate stays open).

* chore(#1950): ratchet size baselines, defer verifier integration

- Workflow size baseline: ship.md 25575→27928, progress.md 31789→32632
  (broken-windows preflight branch + open-windows surface).
- Agent size baseline: gsd-executor.md 46644→47951 (Known Stubs → also
  appends to WINDOWS.md). gsd-verifier.md unchanged.
- LARGE_CAP (49152) preempted the planned verifier integration
  (gsd-verifier.md was at 49140 pre-PR — 12 bytes of headroom, not the
  documented 'real headroom'). Verifier integration deferred to a follow-up
  PR that extracts the VERIFICATION.md template (lines 739-859) to
  gsd-core/references/ — a pre-existing cap-tightness defect this PR
  exposed but does not expand scope to fix. Verifier integration is not in
  the issue's acceptance criteria (executor writes is; unmet-truths
  recording was an enhancement, not a gate).

* fix(#1950): gate default-off, rename to workflow.windows_enforce, regen goldens

Test-failure-driven fixes after first gsd-test run on db8733c8f failed 44
cases (pre-existing structural tests encoded 'ship:pre has 1 gate' / 'all
caps off → empty hooks'):

- capability manifest: rename windows.enabled+windows.enforce (default
  true) → single federated key workflow.windows_enforce (default FALSE,
  opt-in). Matches security's workflow.security_enforce convention and
  makes the adr857 all-caps-off test pass without modification (the test's
  buildAllFalseConfig handles workflow.* out of the box). Default-OFF keeps
  the gate out of the registry's default ship:pre resolution so existing
  loop-hooks-ship-pre-e2e structural assertions (exactly 1 gate, capId
  'security') stay valid; users opt in via
  gsd config-set workflow.windows_enforce true.
- drop activationKey (security doesn't have one either; workflow.* key
  doubles as the activation toggle).
- regenerate docs/reference/capability-matrix.md to include broken-windows
  (capability-matrix-sync test).
- regenerate tests/fixtures/golden-install-parity/*.json (18 runtimes) —
  installer now emits the new capability + lib file.
- update CONTEXT.md, docs/COMMANDS.md, docs/FEATURES.md, ship.md,
  agents/gsd-executor.md to use the new key name and /gsd:colon slash
  syntax (slash-command-namespace test).
- restore accidentally-regressed /gsd:capture in progress.md.

Tracking-only by default; enforcement is opt-in. Acceptance criterion
'/gsd-ship fails while any ledger entry is open' is met when
workflow.windows_enforce=true (test fixture enables it).

* test(#1950): update ship:pre structural invariants for 2-gate registry

- loop-hooks-ship-pre-e2e: the registry now declares 2 gates at ship:pre
  (security + broken-windows), regardless of activation. Activation tests
  above still pin security-only or empty behavior via fixtures; these
  structural tests pin the REGISTRY shape, which has 2 gates as of #1950.
- workflow-size-baseline: ship.md 27928→27945 (workflow.windows_enforce
  rename added 17 bytes).

* fix(#1950): review H1+H2+M1+M2+M3 — fence-injection, EACCES fail-closed, cleanup, strict line, stryker

Adversarial isolated review (Step 6.3) found 2 HIGH findings that block
the PR and 3 mediums. All addressed:

H1 (HIGH): description containing the markdown 3-backtick fence would
terminate the ledger's JSON code block early inside JSON.stringify output
(JSON doesn't escape backticks), corrupting the file and bricking the
next parse. Fix: use a 4-backtick fence (json ... ) which
JSON.stringify cannot produce on its own, AND validate that no entry
text field contains a 4-backtick run (reject at append time with new
WINDOWS_INVALID_TEXT reason code). Locked by a regression test.

H2 (HIGH): readLedgerOrNull swallowed ALL fs errors as 'no ledger',
silently returning open_count:0 on EACCES/EPERM/EIO. The ship gate
would then pass on an unreadable ledger — the precise vector the
workflow doc claims is impossible. Fix: only ENOENT returns null;
every other fs error propagates as WINDOWS_LEDGER_MALFORMED so the
gate blocks and the operator sees a real diagnostic. Locked by a
regression test that chmod 000s a ledger with open_count=1 and
asserts the result is never a false-green 0.

M1: writeLedgerAtomic left an orphaned .tmp file on rename failure.
Wrapped renameWithRetry in try/catch with best-effort unlink.

M2: validateLine silently coerced 'abc' → NaN → null, hiding type
drift. Removed the line === 0 special case (was undocumented) and
made the error message match the strict check. Now any non-positive-
integer line value throws, including strings.

M3: tests/broken-windows.test.cjs (with its fast-check property test)
was not in stryker.config.mjs DEFAULT_TEST_CMD — Stryker would mutate
src/broken-windows.cts but no test would catch the mutations,
producing false surviving-mutant scores. Added to the list.

L1 (dead throw e after error()), L7 (line boundary tests, H1/H2
regression tests, 4-backtick CLI test) also addressed.

* docs(#1950): inline concurrency + busy-wait notes (review L2+L3)

* fix(#1950): regen goldens against latest gsd-tools; correct --line 0 boundary test

gsd-test v4 caught two issues:
- goldens I regenerated earlier (commit 526682084) predated the L1
  routeWindows catch-block cleanup (commit dd844d565). Regenerated
  via 'npm run gen:golden' against current HEAD so the install
  parity hash for gsd-tools.cjs matches.
- 'append --line boundary' test expected --line 0 to succeed with
  null entry.line, but the M2 fix correctly rejects 0 (lines are
  1-indexed; 0 is not a valid source line). Updated the boundary
  test to assert --line 0 fails alongside -1 and 'abc'.

* chore(#1950): regen goldens after rebase onto next

* chore(#1950): quick.md baseline 50699→50993 (correct resolution from next rebase)

* chore(changeset): backfill pr:2441 in .changeset/broken-windows-ledger.md

* fix(#1950): renderTable escapes backslash before pipe (CodeQL incomplete-sanitization)

CodeQL flagged the markdown-table cell escaper:
  String(s ?? '').replace(/\|/g, '\\|')
— it escapes pipe but not backslash first. A description containing '\|'
would render as '\\|' which markdown parses as 'literal backslash' +
'cell separator', splitting the column.

Fix: escape backslash FIRST (each \ → \\), then pipe (each | → \|).
Now a description with '\|' renders as '\\\\|' (literal '\\' + escaped
pipe), which markdown renders as a single '\|' inside the cell. The JSON
code block (the parse source-of-truth) was already correctly escaped via
JSON.stringify; only the display-only table was affected.

Locked by a regression test that:
1. Verifies the JSON block reparses with the description intact.
2. Walks the rendered table row counting unescaped pipes — must be
   exactly 11 (the row separators for 10 cells), proving no in-cell
   pipe added a split.
2026-07-19 20:24:21 -04:00
Tom Boucher
1a46bc068a fix(#2376): emit absolute subagent-facing paths from init/state, convert workflow literals (#2428)
* fix(#2376): emit absolute subagent-facing init/state paths

Make init.* and state.* path fields absolute rather than cwd-relative
so subagent prompts resolve correctly regardless of working directory.
Adds intel_dir/conflicts_path/requirements_path/roadmap_path/state_path
to cmdInitIngestDocs, an absolute debug_dir to cmdStateLoad, and
replaces bare .planning/... literals in 12 workflow Agent() prompt
blocks with the absolute init-JSON path fields. Includes decoy-cwd
regression tests and realpath'd tmpdir fixtures for macOS.

Squashed rebase of the #2376 commit series onto a fresh origin/next
(previous merge ee25543a1 was against a now-stale next).

* chore(#2376): add changeset

* chore(#2376): regenerate golden fixtures + workflow size baseline

Regenerated after rebasing the absolute-path fix onto current next
(picks up #2351's run-with-timeout content in execute-phase.md too).

* fix(#2376): trim execute-phase.md redundancy to stay under the size margin

* chore(#2376): regenerate golden/size baseline after rebase onto next
2026-07-19 15:43:48 -04:00
Behruz Nassre Esfahani
d04e287fa9 fix(#2365): stop api-coverage detector false-positiving non-API phases (#2397)
* fix(#2365): stop the api-coverage detector false-positiving non-API phases

detectApiIntegration fired on any integration verb co-occurring anywhere on a
line with any API noun, treated / as a word boundary (so a first-party Next.js
src/app/api/... route path matched the noun "api"), and read any capitalized
word before API/SDK/REST/GraphQL as a service name behind a fixed stopword
denylist (so threat-model prose like "Resolver-only API" fired). Because the
verify:pre seal gate is BLOCKING, a phase touching no external API could not
reach UAT without fabricating a coverage matrix.

The compound rule now requires the verb and noun to share one clause (sentence
punctuation and table-cell walls end a clause) within a bounded word gap.
Non-prose spans are excluded before matching: fenced code (already), inline
code spans (new stripInlineCode in the markdown-sectionizer seam), and
path-shaped tokens. The <Service> API surface rule requires proper-noun
position — a clause-initial capitalized word is ordinary English and needs
dependency evidence (URL / package reference) on the same line — and rejects
compound modifiers ("Resolver-only", lowercase after the hyphen).

A phase that integrates no external API now has a first-class, reasoned way to
say so: a COVERAGE.md containing "No external API integration: <reason>"
satisfies the gate (declaration + rows is contradictory and blocks). The
true-positive path is pinned by regression tests: every default-vocabulary
positive still fires, including the widest word-gap pairing and the
surface-rule-only shape.

Fixes #2365

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

* fix(#2365): tighten api-coverage detector per Codex review (round 2)

Applies the Codex review findings on the initial #2365 fix:

- S-1: a COVERAGE.md "no external API integration" declaration is the human
  override for a fallible detector, so it must PASS even when detection still
  fires — but the contradiction is now SURFACED in the gate output (overridden
  signal count + terms) instead of passing silently.
- S-2: verb/noun pairing is now a term-group nearest-pair merge walk over
  precomputed word ordinals (computeWordStarts / minWordGap), not a match×match
  cross product — a hostile line repeating one pair thousands of times stays
  linear instead of going quadratic.
- FN-4: package-shaped inline-code spans (`stripe-sdk`, `@stripe/stripe-js`)
  are kept as noun/dependency evidence rather than being fully masked, so a
  genuine dependency reference inside code ticks still corroborates.
- C-1: the <Service> API surface rule now scans every candidate in every
  clause; a rejected first candidate no longer shadows a later genuine service.
- Cross-clause binding: a verb may bind a noun in the immediately following
  clause only when its own clause names a service object, within a tight gap —
  admits "Integrate Stripe, exposing its endpoints …" without re-admitting the
  unrelated-clauses false-positive class.
- Internal-descriptor negative evidence ("internal Payments API",
  "the internal endpoint") never pairs; URL/scheme matching generalized beyond
  http(s).

All 5 acceptance criteria still hold: the three reported false positives are
clean and "integrate the Stripe API" still fires. Built .cjs committed
alongside the .cts. tsc + eslint (incl. no-adhoc-markdown-parsing) +
lint:regression-names clean; affected suites 256/256 green.

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

* fix(#2365): retune api-coverage detector fail-closed per Codex review (round 3)

Codex's second-round review found the round-2 tightening had over-corrected into
FAIL-OPEN false negatives — realistic external-API prose that the BLOCKING seal
gate silently let through (the catastrophic class, since a missed API surface is
worse than a dismissable false positive). Retuned the detector to be explicitly
fail-closed: lean toward detecting, and let the one-line COVERAGE.md "no external
API integration" declaration dismiss the residual false positives.

Fail-open false negatives fixed (all now detect):
- F1 clause-initial `<Service> API` with a plain follower ("Stripe API for
  payment processing") — dropped the follower-allowlist / corroboration gate on
  clause-initial surfaces; a service that is not a stopword, descriptor, or
  compound modifier is a real name from any clause position.
- F2 scheme-less external host ("api.stripe.com/v1") — a dotted host with an
  alphabetic final label now contributes its API nouns; a first-party route
  path (no dotted host) still does not.
- F3 vendor's first-party SDK ("Integrate Shopify's first-party SDK") — the
  compound path no longer filters nouns on "internal"/"first-party" (Codex: the
  qualifier can describe the vendor's own API, not the consuming project's).
- F4 long single integration clause — removed the word-gap cap entirely: it
  could not separate a 21-word genuine clause from an 18-word internal one, so
  the clause boundary is now the whole relationship test.
- F5 lowercase cross-clause service — cross-clause binding no longer requires a
  capitalized "service object".

New false positives fixed (all now clean):
- F6 a URL token that swallowed a trailing clause comma, merging two clauses —
  trailing clause punctuation is kept literal so the split survives.
- F7 a capitalized internal component authorizing cross-clause binding — the new
  gate requires a dependent elaboration, not a new coordinate clause opened by a
  conjunction ("…, then document…").
- F8 a protocol name read as a service ("REST API", "GraphQL API") — protocol
  and locality descriptors are rejected in the `<Service>` position.

- Finding 9: the inline-code-span scanner was O(n^2) on pathological backtick
  runs; rewritten to linear via a per-length run cursor (2 MB: 4.15 s -> ~6 ms),
  semantics preserved (148 sectionizer tests unchanged).

Net simplification: the fail-closed model removed the round-2 minWordGap /
groupByTerm / follower / corroboration machinery (350 insertions vs 445
deletions across the touched files). Under fail-closed, three round-2 negative
tests now correctly detect (integration verb + "internal"-qualified noun, and
the distant-same-clause case); none were trek-e acceptance FPs.

Verified: 1491/1491 unit tests pass; tsc + eslint (incl. no-adhoc-markdown-
parsing) + lint:regression-names clean; all 8 review findings reproduced as
regression tests, both directions. Built .cjs committed alongside the .cts.

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

* fix(#2365): resolve round-3 Codex review findings (fail-closed, round 4)

Codex's round-3 adversarial review found the fail-closed retune had introduced
new holes in both directions. Resolved:

Fail-open false negatives (now detect):
- External host addressing a PATH ("graph.microsoft.com/v1.0/me") is itself an
  integration surface and contributes an endpoint noun even when the host names
  no vocabulary word. A bare domain link with no path ("https://example.com")
  stays a non-signal, so "Integrate … from example.com, document …" is still
  clean.
- Locality qualification ("internal", "private") no longer leaks across a
  sentence or clause boundary: only plain spaces may separate the descriptor
  from the service, so "The cache is private. Stripe API …" now detects.
- Cross-clause binding: the fragile head-word cap (which could not tell a
  genuine "Connect … to Stripe payments, exposing its endpoints" from an
  unrelated "Integrate … from URL, document …" — both 4 words after the verb)
  is replaced by a participial-continuation rule: a verb binds a noun in the
  next clause only when that clause begins with an "-ing" elaboration. This
  fixes the 4-word-head false negative AND the false positive below at once.

False positives (now clean):
- Cross-clause no longer binds a finite continuation regardless of separator:
  "Wire the settings form. Document endpoint props." / "…; document …" /
  "…, document …" are separate actions, not elaborations.

Perf (quadratic → linear):
- The trailing-punctuation peel is a backward char scan instead of an
  unanchored `[…]+$` regex (16k chars: 156 ms → ~1 ms).
- SERVICE_SURFACE_API_RE bounds the service-name length {1,40} so a hostile
  "A-A-…-x" run cannot drive O(n^2) backtracking (16k: 385 ms → ~3 ms).

Consumer fail-open (blocking gate):
- readPhaseScope now distinguishes "no plans" from a plan that EXISTS but is
  unreadable. On a read error the gate BLOCKS ("could not read the phase
  scope …") instead of silently certifying no-integration from partial scope —
  an unreadable plan could be the one describing the integration.

Documented fail-closed tradeoffs, now pinned with tests so they are not
"fixed" back into a fail-open: a clause-initial capitalized common word before
"API" ("Payment API", "Search API") reads as a service name; a long clause
pairs a verb with a distant noun; and a CommonMark inline code span that wraps
a newline is matched within-line only. Codex judged these acceptable because
the COVERAGE.md declaration is a cheap override.

One documented limitation remains out of scope: "Integrate Stripe, and
authenticate requests with its API" (a coordinate finite clause whose noun
refers back by pronoun) needs coreference resolution, beyond a lexical detector.

Verified: 379/379 affected + command-router tests pass (+14 new regression
tests covering every round-3 finding, both directions); tsc + eslint
(no-adhoc-markdown-parsing) + lint:regression-names clean. Built .cjs committed.

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

* fix(#2365): simplify to robust core — remove whack-a-mole heuristics (round 5)

Round-4 review confirmed the detector's two most complex features generate
findings in both directions no matter how they are tuned, because they need a
vendor dictionary + coreference the issue rules out in principle. Per the
operator's "ship the robust core" decision, both are removed and their gaps are
documented rather than chased further:

- Cross-clause binding DELETED (allowsCrossClause / participle rule). It caused
  a fail-open on finite continuations ("Integrate Stripe; use its OAuth
  endpoints" — missed) and a false positive on "-ing"-SPELLED nouns ("…, billing
  endpoint terminology…" — wrongly fired). Detection is now same-clause only.
- URL-path-as-evidence REVERTED. Treating every path-bearing URL as an endpoint
  fired on ordinary asset/link URLs ("…/theme.css", "…?next=/x", a docs/repo
  link) and recreated routine UI-phase false positives. An external URL is
  evidence only when it NAMES an API vocabulary word ("api.stripe.com/v1").

Two fail-open cases are now DOCUMENTED limitations, pinned by tests so a future
maintainer does not re-add the heuristics that caused the false positives above:
a service named only in a clause separate from its API noun, and a bare external
host that names no vocabulary word. Both are cheaply covered by the COVERAGE.md
declaration and rare in real phase prose ("integrate the X API").

Also fixed from the round-4 review:
- Qualification now survives markdown emphasis ("The **internal** Payments API"
  stays clean) while still not crossing a sentence/clause boundary.
- readPhaseScope fail-closes on a REAL read failure (EACCES/EIO) enumerating the
  phase directory or reading the roadmap fallback — not only per-plan-file
  failures; a missing directory/section remains a legitimate no-op. The
  declaration-override path surfaces scope_read_error so an incomplete-scope
  override stays visible.
- SERVICE_SURFACE_API_RE length-bound comment no longer overclaims.

Net: the detector is same-clause verb+noun + `<Service> API` surface, with
path/code/inline masking and a fail-closed posture. All five acceptance criteria
hold. 1573/1573 unit tests pass; tsc + eslint (no-adhoc-markdown-parsing) +
lint:regression-names clean. Built .cjs committed.

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

* fix(#2365): close roadmap-fallback fail-open + stale JSDoc (round-5 review)

The round-5 sanity review confirmed the detector simplification is sound (all
acceptance positives fire, all required negatives clean) and flagged one real
blocker plus a nit:

- Blocker: readPhaseScope's roadmap fallback could still silently pass an
  UNREADABLE roadmap. getRoadmapPhaseWithFallback gated on fs.existsSync(), which
  returns false on EACCES/EIO too — so an unreadable ROADMAP.md read as "absent",
  no exception reached isRealReadFailure, and the blocking gate certified empty
  scope. Fixed at the source: read the roadmap directly and honor the function's
  OWN documented contract — null only on ENOENT (genuinely absent), otherwise
  throw. Both existing callers already wrap it in try/catch expecting that throw,
  and readPhaseScope now fail-closes (blocks) via its roadmap catch. Verified by
  a new e2e test (unreadable roadmap fallback → block).

- Nit: the detectApiIntegration JSDoc still described the removed cross-clause
  participial binding and "every external hostname counts" — corrected to the
  actual same-clause-only behavior and the names-a-vocab-word URL rule.

Verified: full unit suite green; tsc + eslint + lint:regression-names clean.
Built .cjs committed (roadmap.cjs is gitignored/rebuilt, per repo convention).

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

* chore(#2365): backfill changeset PR number (#2397)

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

* chore(#2365): sync generated capability-registry + recapture install goldens

CI surfaced two generated-artifact staleness issues (all failing test shards +
lint-tests traced to these, not to a logic defect):

- gsd-core/bin/lib/capability-registry.cjs was stale: the initial fix edited the
  ai-integration `api-coverage-plan-pre.md` fragment (added the "No external API
  integration" declaration section) but did not regenerate the registry, which
  embeds an inline copy of that fragment. Regenerated via
  `gen-capability-registry.cjs --write` — the diff is exactly the fragment text
  sync. Fixes `lint:generated-sync` and the "committed registry is in sync" +
  "registry integration" tests.

- The 18 golden-install-parity fixtures were stale by exactly one hash line each
  — `gsd-core/references/api-coverage.md`, which this PR edits and which is a
  hashed installed artifact. Recaptured with `UPDATE_GOLDEN=1`; the diff is that
  single hash per runtime and nothing else. Fixes the `golden parity — *` tests.

No source or behavior change — generated artifacts only.

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

* test(#2365): flip representative-corpus manifest to assert the fixed behavior

The #2371 representative corpus (merged into next after this branch was cut) is a
known-bug tripwire: it asserts each fixture's currentBuggyOutput so the test
fails loudly the moment #2365 is fixed, at which point — per its own contract in
representative-corpus.test.cjs — the fixer removes currentBuggyOutput so the
assertion checks expectedDetected instead.

This is that moment. Removed currentBuggyOutput from the three detector fixtures
(nextjs-route-path, unrelated-verb-noun, threat-model-prose); the corpus now
asserts detected:false, which the fail-closed same-clause detector satisfies.
Notes updated to describe the fix rather than the bug. The #2366 matrix corpus
is left untouched — that tripwire belongs to its own PR (#2374).

Corpus test: 7/7 pass.

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

* test(#2365): skip chmod-000 fail-closed e2e tests on Windows

The three fail-closed gate tests induce an unreadable plan / directory / roadmap
with chmod 000, but Windows does not enforce POSIX mode bits — readFileSync
still succeeds, so the gate never reaches the read-error path and the assertion
fails on the windows-latest CI leg. The fail-closed LOGIC is platform-
independent (readError → block) and is fully exercised on the macOS/Linux legs;
only the method of inducing EACCES is POSIX-specific. Guard the three tests to
skip on win32 as well as root, mirroring golden-install-parity's win32 skip.

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

* test(#2365): address trek-e review — glossary, clock-seam, IO injection, bounds

Review response to PR #2397 (trek-e, CHANGES_REQUESTED). Fix logic unchanged;
this closes the test/process-hygiene findings.

Major:
- CONTEXT.md "Markdown Sectionizer" glossary now lists the two exports this fix
  relies on, `stripInlineCode` and `scanInlineCodeSpans` (glossary is a PR gate).
- Replaced the banned wall-clock assertion in the "hostile repeated-term line"
  test (Clock Seams rule — no elapsed-time asserts) with a deterministic
  signal-count assertion, which also directly verifies the term-dedup that keeps
  pairing linear (one signal for a 10k-pair line, not thousands).
- Rewrote the three fail-closed read-failure tests: instead of chmod 0o000
  (a no-op under root / on Windows, the pattern the repo's IO-failure convention
  avoids) they now exercise the newly-exported `readPhaseScope` in-process and
  inject the failure by monkeypatching fs.readFileSync/readdirSync to throw,
  restoring in finally. Deterministic and platform-independent (no skip needed),
  and they add the ENOENT-is-absence case that the chmod tests couldn't express.

Minor:
- Added limit / limit+1 boundary tests for SERVICE_SURFACE_API_RE's {1,40}
  service-name bound, QUALIFIER_LOOKBACK's 24-char window, and REASON_MAX_LEN
  (200) on the declaration reason.
- Added a fast-check property that fuzzes the tokenizer / clause splitter /
  masking (scanLineTokens, splitClauses, collectTermMatches) with adversarial
  tokens (slashes, backticks, URLs, clause punctuation) and asserts the detector
  is total (never throws), shape-stable, holds detected <=> signals, and is
  deterministic.

readPhaseScope is exported for the in-process tests. Verified: 125 detector +
19 gate tests pass; tsc + eslint + generated-sync (glossary/registry) +
lint-regression-test-names + lint-test-file-count clean.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 14:59:25 -04:00
Tom Boucher
88d6b392af fix(#2362): materialize third-party capability skills on opencode/kilo install (#2434)
* fix(#2362): materialize third-party capability skills on opencode/kilo install

installOpencodeFamilyArtifacts/installOpencodeFamilySkills never received
the capability registry, so a registered+surfaced+active third-party
capability skill was silently dropped by the OpenCode/Kilo combined-family
INSTALL path (registry said surfaced:true, disk had nothing). Thread
capabilityRegistry through and reuse the existing #2322 seam's exported
helpers (readInstalledCapabilitySkill, capabilityClusterStems,
CAPABILITY_SKILL_MARKER) to fill in third-party skills after the first-party
loop, with the same guarantees: registry-bound ownership, first-party-wins,
full/'*' sentinel support, and graceful degradation on a missing/corrupt
capability skill.

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

* test(#2362): cover the tiered (non-'*') profile candidateStems branch

Review found the new third-party capability-skill fill-in for OpenCode/Kilo
had zero coverage of the tiered-profile path — all 12 regression tests only
exercised the '*' full-profile sentinel. Adds a case that resolves a
`standard`-tier profile through a synthetic registry (mirroring the seam's
own __registryFor pattern) and asserts the resulting concrete Set still
materializes the registered capability skill, with its prune-parity marker,
alongside the tier's first-party skills.

Also adds the .changeset/ fragment this fix was still missing.

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

* chore(#2362): backfill changeset pr (#2434)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 12:52:43 -04:00
Tom Boucher
a7d83dc234 fix(#2390): warn on goal-shaped phase.add titles, correct auto-detect docs (#2425)
* fix(#2390): phase.add title warning + auto-detect doc fix

phase.add now returns a `warning` field when a description reads as
goal-shaped (>80 chars and/or multi-sentence) rather than title-shaped,
instead of silently writing the whole paragraph verbatim as the
`### Phase N:` header. The CLI still creates the phase as-is (the
strict two-layer slash-vs-CLI interface is unchanged); the warning
just surfaces the gap.

Also clarifies six doc sites (command argument hints, workflow
detection steps, and how-to/reference docs) that described the
phase-number argument as "auto-detecting" the next unplanned phase --
that detection is an orchestrating-workflow/LLM step reading
ROADMAP.md (concretely: `query roadmap.analyze`'s `next_phase`
field), not a `gsd-tools.cjs` CLI feature.

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

* chore(#2390): regenerate fixtures + lint gate-prep

* fix(#2390): repair failing tests after gate verification

* chore(#2390): add changeset (#2425)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 07:53:04 -04:00
Tom Boucher
8d2f8bcb23 fix(#2388): gate shared requirement completion on sibling plans, revert on gaps (#2424)
* fix(#2388): gate shared-ID requirement marking and revert on gaps_found

Adds requirements.ready-ids (execute-plan.md's update_requirements step)
so a requirement ID declared by multiple plans in a phase only marks
Complete once every declaring plan has produced a SUMMARY.md, and
requirements.revert-phase (execute-phase.md's gaps_found branch) so a
gaps_found verdict reverts the phase's own prematurely-Complete IDs
before the gap report renders. Single-plan IDs still mark immediately.

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

* chore(#2388): regenerate fixtures + lint gate-prep

* fix(#2388): repair failing tests after gate verification

* chore(#2388): add changeset (#2424)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 07:52:17 -04:00
Tom Boucher
13d181aedf fix(#2349): exclude status: superseded plans from phase completion counts (#2404)
Adds a status: superseded plan-frontmatter marker that scanPhasePlans excludes from both plan and summary counts, so a phase with a deliberately-unexecuted plan no longer reads incomplete forever (the plan-level analogue of #1514). Includes all-superseded completion handling and a bounded, symlink-safe frontmatter read. Fixes #2349.
2026-07-18 08:08:03 -04:00