* fix(#1772): read full multi-line command in graphify-update hook Gate 2
The PostToolUse hook joined tool_name + newline + tool_input.command and
extracted the command with sed -n '2p' — line 2 only. Agent runtimes
(Claude Code's Bash tool among them) routinely emit HEAD-advancing commits
as multi-line scripts ('cd /path', then 'git add', then 'git commit …'), so
line 2 is the 'cd', Gate 2's *"git commit"* match failed, and the rebuild
silently no-op'd on real commits despite graphify.auto_update: true.
Capture line 2 through EOF (sed -n '2,$p') so the case glob sees the full
multi-line command string. Single-line behavior is unchanged (the match
only widens); non-HEAD-advancing multi-line commands still no-op cleanly.
Regression tests cover multi-line commit/merge/pull dispatch plus a
multi-line no-op no-regression guard.
* docs(#1772): add changeset fragment for graphify-update multi-line fix
* test(#1772): regenerate golden-install-parity fixtures for hook change
gsd-graphify-update.sh ships to 9 graphify-aware runtimes; widening the
sed range (2p -> 2,$p) shifts its shipped hash. Recapture the 9 affected
fixtures via UPDATE_GOLDEN=1 — each changes exactly one line (the hook hash).
* fix(#1591): phase.complete recognizes checkbox-list phases in the isLastPhase fallback
When the active milestone's phase checklist is written as `- [ ] Phase N:`
checkbox items inside a <details> block (the @Azd325 structure) and the next
phase has no directory yet, the disk-based next-phase resolver finds nothing
and phase.complete falls back to the roadmap-enumeration guard at the
isLastPhase site. That guard's phasePattern was heading-only
(/#{2,4}\s*Phase…/), so it never matched checklist items → is_last_phase=true
and next_phase=null on a mid-milestone phase, and STATE.md was wrongly marked
'Milestone complete' with total_phases decremented.
Broaden the marker alternation to match BOTH heading-style (### Phase N:) and
checkbox-list items (- [ ] Phase N: / - [x] Phase N:); the number/name
captures are unchanged. extractCurrentMilestone already surfaces the
<details>-wrapped checklist correctly, so no parser change is needed. The
heading-only sibling patterns elsewhere in phase.cts are left untouched
(scope discipline — only the reproduced isLastPhase fallback is changed).
Regression: a phase complete 36 on a <details>-wrapped v2.0 checklist
(Phases 36-38, only 36 has a dir) returns is_last_phase=false, next_phase=37,
and does NOT flip STATE.md to 'Milestone complete'.
* docs(#1591): add changeset fragment for phase.complete checkbox-list fix
* test(#1752): add total_phases-preservation regression for the #1591 follow-up
#1752 is the scoped follow-up to #1591 — same <details>-wrapped-checkbox
defect, with the additional emphasis on the total_phases decrement cascade.
The #1591 fix (is_last_phase=false) already resolves it: with all 8 phase
dirs on disk, phase.complete 36 on a v2.0 <details> checklist leaves
total_phases at 8 (not decremented to 7) and does not flip STATE.md to
'Milestone complete'. Verified manually before adding the test.
Add the #1752 regression case (8 phase dirs, curated total_phases: 8) to the
phase complete command block in tests/phase.test.cjs, and update the changeset
to reference both issues (#1591, #1752) since this is one user-facing change
resolving both.
* fix(#1761): skip conflated progress in state json read-path when milestone unbounded
ADR-1769 Phase 7 (#1794) closed the state sync WRITE path — when a milestone
version is asserted in frontmatter but the ROADMAP has no versioned heading
for it, sync leaves Progress untouched. But the state json READ path rebuilds
progress via buildStateFrontmatter, whose roadmapPhaseCount loop counts phase
headings across the WHOLE document when extractCurrentMilestone can't bound
the milestone. state json therefore reported a conflated total_phases (sum of
sibling milestones) + a derived percent — exactly the value the sync guard
was added to prevent. (Repro from the issue: total_phases 8 = 4+4, percent 13.)
Mirror the cmdStateSync guard inside buildStateFrontmatter: when the asserted
milestone cannot be bounded to a versioned ROADMAP heading (the same
versionedHeading test the sync path uses), fall back to the on-disk
phase-dir count for total_phases and skip percent. Bounded milestones
(versioned ROADMAP, or no milestone asserted) are unchanged. The signal rides
on the existing _diskScanCache (new milestoneBounded field) so neither
extractCurrentMilestone's return contract nor its other callers change.
Regression: extend tests/bug-1761-state-sync-wrong-progress.test.cjs with the
read-path case (unbounded → no percent, no conflated total_phases) and a
bounded control (versioned ROADMAP → unchanged percent + total_phases).
* docs(#1761): add changeset fragment for state json read-path fix
* refactor(#1679): ADR-1239 Phase B — collapse program + command chains [AC2 slice 4]
Phase 2 AC2 slice 4. Collapses two more duplicated runtime->string chains in
bin/install.js's post-install next-step message:
- program (14 branches): an EXACT duplicate of runtimeLabel -> getRuntimeLabel.
- command (14 branches): the per-runtime /gsd-new-project invocation syntax
(gemini '/gsd:', codex '$', cursor skill-mention, kimi '/skill:', default
'/gsd-new-project') -> new getRuntimeNewProjectCommand(runtime) helper.
- src/runtime-name-policy.cts: RUNTIME_NEW_PROJECT_COMMANDS table +
getRuntimeNewProjectCommand(runtime) (sibling to runtimeFlags/getRuntimeLabel).
- bin/install.js: import getRuntimeNewProjectCommand; replace the program +
command chains with single lookups.
- tests/runtime-label-policy.test.cjs: 2 new tests for
getRuntimeNewProjectCommand (4 overrides + default for the other 12).
runtime === count: 53 -> 25 (-28). Cumulative Phase 2 this session: 129 -> 25
(-104). golden-install-parity 16/16 (program/command are stdout-only so not
parity-covered, but program matches RUNTIME_LABELS exactly and command values
are preserved verbatim in the table). AC2 data-collapse now essentially
exhausted; remaining 25 branches are the ADR-1235 agent-loop tail + per-runtime
semantic behavior.
* chore(changeset): add Changed fragment for program+command collapse (#1679)
* refactor(#1679): ADR-1239 Phase B — collapse is<Runtime> flag blocks into runtimeFlags [AC2 slice 3]
Phase 2 AC2 slice 3. Collapses the four duplicated 'const isX = runtime === x'
declaration blocks in bin/install.js (uninstall / writeManifest / install / a
fourth helper — 48 of the 101 remaining runtime=== branches) into a single
runtimeFlags(runtime) helper in src/runtime-name-policy.cts, sibling to
getDirName / getRuntimeLabel / getGlobalConfigHomeFragment.
The purest add-a-host tax: a new runtime meant remembering to add ~12 flag lines
to each of four functions. Now it is one entry in RUNTIME_FLAG_IDS.
- src/runtime-name-policy.cts: RUNTIME_FLAG_IDS + runtimeFlags(runtime) -> frozen
map of is<Runtime> booleans (single runtime=== source, via loop).
- bin/install.js: import runtimeFlags; replace the 4 declaration blocks with one
destructure each. ZERO usage-site churn (flag names preserved; install.js's
eslint block has no no-unused-vars rule so destructure-all is clean).
- tests/runtime-flags.test.cjs: 4 tests (each runtime sets exactly its flag,
claude/unknown/empty -> all false, all 15 flags present + frozen, drift guard).
runtime === count: 101 -> 53 (-48). golden-install-parity 16/16 byte-identical
(behavior-identical collapse). AC2 data-collapse now substantially complete;
ADR-1235 agent-loop tail + per-runtime semantic residue remain (separate).
* chore(changeset): add Changed fragment for runtimeFlags collapse (#1679)
* feat(#1681): ADR-1239 Phase C-2 — gsd-mcp-server bin entry + lifecycle test [slice 3b]
Phase 4 slice 3b (closes#1681). The companion MCP server bin entry so any
MCP-consuming host connects via 'npx gsd-mcp-server' (or its bin on PATH) and
gets GSD command (point 1) + state IO (point 5) with no bespoke plugin.
- gsd-core/bin/gsd-mcp-server.cjs: #!/usr/bin/env node shim requiring
./lib/mcp-server.cjs + runServer({stdin, stdout}); non-zero exit on fatal
error (justified n/no-process-exit disable). Mirrors gsd-tools.cjs.
- package.json: add 'gsd-mcp-server' bin entry.
- tests/gsd-mcp-server-bin.test.cjs: 3 process-lifecycle tests — initialize +
tools/list round-trip + clean exit, malformed-line -> parse error + server
keeps running, empty stdin -> clean exit. Synchronous spawnSync (bounded;
server exits on stdin EOF, no orphan).
Phase 4 trust-gate (#1806) + loader wiring (#1808) + server module (#1809) +
this bin/lifecycle slice = all of #1681's deliverables. Concrete host binding ->
Phase 5 (#1682). npm-integrity + eslint + security + inventory all clean.
* docs(#1681)+chore(changeset): how-to for the companion MCP server + Added fragment
docs/how-to/connect-gsd-mcp-server.md — Diataxis how-to guide for connecting
any MCP-capable host to gsd-mcp-server: goal-oriented flow (add config → restart
→ verify), real-world per-host conditionals, troubleshooting, and a trimmed
reference table. Explanation/reference linked out (ADR-1239, capability-trust-
model) per Diataxis boundary rules rather than mixed in.
.changeset/humble-seals-rest.md — type: Added (first user-reachable surface of
the epic: a new bin command). The how-to doc satisfies the docs-required gate.
* fix(#1681): move gsd-mcp-server shim to top-level bin/ (out of the runtime-copied tree)
The shim at gsd-core/bin/gsd-mcp-server.cjs was inside the tree the installer
copies into every runtime config dir, so it leaked into all 16 runtimes and
broke golden-install-parity. The MCP server is a PACKAGE bin the host spawns
(npx gsd-mcp-server), not a per-runtime artifact — so it belongs at top-level
bin/ alongside install.js (which is also never copied into a runtime config).
- gsd-core/bin/gsd-mcp-server.cjs -> bin/gsd-mcp-server.js (require path now
../gsd-core/bin/lib/mcp-server.cjs).
- package.json: bin entry -> bin/gsd-mcp-server.js.
- tests/gsd-mcp-server-bin.test.cjs: SHIM path updated.
- eslint.config.mjs: add bin/gsd-mcp-server.js to the bin/install.js block
(drops the n/no-process-exit disable — the n plugin isn't loaded for that
block, so the disable referenced an undefined rule).
golden-install-parity 16/16 restored; lifecycle + unit tests green; eslint 0;
lint:ci all ok.
* feat(#1680): ADR-1239 Phase C-1 — model adapter seam (passive + active) [AC3]
Phase 3 slice 3 (AC3). Two model-layer adapters selected by the negotiated
modelMode axis (host-integration.cts):
- passive: formalizes today's tier routing from src/model-resolver.cts —
resolveModel delegates straight to resolveModelForTier (byte-for-behavior).
This is the CLI runtimes (claude/gemini/codex/opencode/cursor/...): GSD injects
prompts / a per-agent model field.
- active: a host-supplied sendRequest seam (VS Code vscode.lm / pi providers) —
GSD calls the model through the host. Ships as a fail-closed seam (throws until
a provider is bound); Phase 5 wires a concrete provider.
createModelAdapter({modelMode}, {sendRequest?}) — factory gating throws on
invalid mode. Proactive CI gates applied: ADR-457 eslint ignores entry +
INVENTORY-MANIFEST cli_modules entry + comment-wording audited for the
injection-scan substring trap.
* chore(changeset): add Changed fragment for model adapter seam (#1680)
* feat(#1680): ADR-1239 Phase C-1 — imperative embedding adapter (composes loadRegistry) [AC2]
Phase 3 slice 2 (AC2). The engine-as-library path: createImperativeAdapter
composes loadRegistry({includeInstalled:true}) — first-party-wins + consent +
fail-closed gates, identical trust semantics to the CLI — and binds the engine
surface behind the SAME HostIntegrationInterface the declarative adapter (AC1)
satisfies, plus a registry accessor for the composed capability set.
- src/adapter-imperative.cts: createImperativeAdapter({runtime}, {loadOptions})
→ ImperativeAdapter (kind:'imperative' + .registry + install/uninstall
delegating to install-engine). Thin: delegates the loop, does not reimplement.
- tests/adapter-imperative.test.cjs: kind (16 runtimes), registry composition
(loadRegistry called with includeInstalled:true), loadOptions pass-through,
install/uninstall delegation, fail-closed construction.
- eslint.config.mjs + docs/INVENTORY-MANIFEST.json: ADR-457 ignores entry +
cli_modules entry for the new emitted .cjs (the two drift gates that bit AC1,
applied proactively here).
Concrete host binding (OpenCode/VS Code/pi) deferred to Phase 5 (#1682).
* test: remove dead readStateMd helper from bug-1760 test
readStateMd was defined but never called (writeStateMd is the only state-md
helper this test uses). Clears the lone no-unused-vars warning so the repo
lints fully clean (0 problems). No behavior change — test still passes 2/2.
* chore(changeset): add Changed fragment for imperative embedding adapter (#1680)
* fix(adapter-imperative): reword comment to avoid injection-scan substring match
The prompt-injection scan regex 'act\s+as\s+(?:a|an|the)' was matching the
'act as the' substring inside 'contract as the declarative adapter' (contrACT
AS THE). Reword 'contract as' -> 'shape as' — no 'act' substring, identical
meaning. Clears the 'lib source files are clean' + 'codebase prompt injection
scan' security-gate failures.
* feat(#1680): ADR-1239 Phase C-1 — declarative embedding adapter + minimal HostIntegrationInterface [AC1]
Phase 3 slice 1 (AC1). Names + bounds today's projection path behind the common
HostIntegrationInterface that both declarative + imperative adapters will satisfy.
- src/embedding-adapter.cts: minimal HostIntegrationInterface (kind + runtime +
install/uninstall) + ADAPTER_KINDS. The full 6-point binding surface
(command/dispatch/model/hooks/state/artifact) is DEFERRED until the imperative
adapter (AC2) fixes the shape — ADR-1239 lists the wire-shape as an open
question; freezing it now risks rework across Phases 3-6.
- src/adapter-declarative.cts: createDeclarativeAdapter({runtime}) factory.
Delegates in-process to install-engine installRuntimeArtifacts /
uninstallRuntimeArtifacts (the SAME engine functions bin/install.js uses), so
output is byte-identical to today's install (gated by golden-install-parity).
Module-ref call style = monkeypatch-friendly for tests. Lossy by design:
projects files, does not drive the loop (that's the imperative adapter, AC2).
- tests/adapter-declarative-equivalence.test.cjs: kind classification (all 16
runtimes), install/uninstall delegation with exact args (the byte-identity
link), fail-closed construction (missing/invalid runtime throws).
Purely additive — no install.js/install-engine changes. Unblocks AC2 (imperative
adapter) + AC3/AC4 (model/hook/state seams) as follow-up slices.
* chore(changeset): add Changed fragment for declarative embedding adapter (#1680)
* fix(lint): ignore tsc-emitted embedding-adapter/adapter-declarative .cjs (ADR-457)
The new src/embedding-adapter.cts + src/adapter-declarative.cts modules' emitted
gsd-core/bin/lib/*.cjs artifacts must join the ADR-457 ignores list (lint the
src/*.cts source, not the emitted .cjs). Without this, the .cjs is linted under
js.recommended where @typescript-eslint/no-require-imports is undefined, so the
verbatim-copied eslint-disable directive surfaces as 'Definition for rule not
found' — failing the lint-tests CI job.
Also restores the clean line-level disable in adapter-declarative.cts (valid in
the .cts source context where the rule IS defined).
* fix(docs): add adapter-declarative + embedding-adapter to INVENTORY-MANIFEST cli_modules
The new ADR-1239 Phase C-1 modules' built .cjs artifacts must be registered in
docs/INVENTORY-MANIFEST.json's cli_modules array or the
'docs/INVENTORY-MANIFEST.json matches the filesystem' drift test fails on CI.
Mirrors the existing sorted entries.
* refactor(#1679): ADR-1239 Phase B — collapse runtimeLabel chains into getRuntimeLabel
Collapses the two duplicated runtimeLabel assignment chains in bin/install.js
(uninstall() and install()) into a single getRuntimeLabel(runtime) lookup in
src/runtime-name-policy.cts — a curated short-form label table, sibling to the
registry-derived getDirName precedent.
This is slice 1 of AC2 (regional residue-collapse in install.js) under
ADR-1239 Phase B / #1679. The install/uninstall console label was the add-a-host
tax poster child: a new runtime meant adding a label line to BOTH chains, and
they had drifted out of sync:
- kimi: install 'Kimi' / uninstall 'Kimi CLI' -> canonical 'Kimi CLI'
- cline: install 'Cline' / uninstall (omitted) -> canonical 'Cline'
Each canonical value matches the majority chain AND the descriptor title.
Behavior:
- 14 of 16 runtime labels unchanged in both sites (zero observable change).
- 2 unifications (kimi-install, cline-uninstall) move toward consistency.
- Unknown/empty runtime id fails closed to 'Claude Code'.
- Raw-id lookup only (no alias expansion); callers pass canonicalized ids.
Voice: these SHORT UI labels are intentionally distinct from the descriptor
title (the long product name) which serves docs/registry display, not the
console. A future slice may relocate this to a runtime.label descriptor field.
Verification:
- TDD: tests/runtime-label-policy.test.cjs (golden map + drift guard + fallbacks)
- 16-runtime golden install parity: byte-identical (labels are stdout-only)
- 162-test neighbor cluster green; eslint + test-file-count + regression-names clean
- runtime === count in install.js: 129 -> 115 (-14, the uninstalled label chain)
* chore(changeset): add Changed fragment for runtimeLabel collapse (#1679)
PR #1800 touches bin/ → changeset-required gate. Mirrors the sibling
ADR-1239 Phase B slice (eager-elks-frolic): type Changed + docs-exempt
marker (internal refactor, no user-facing doc surface).
Extract readModifyWriteStateMd's post-sync preservation block into a pure,
field-classification-table-driven applyStatePreservation in the STATE.md
Transition Module. progress / status / stopped_at now join current_phase_name
as table-governed (getFieldClassification), so a preservation-policy change is
a one-row table edit instead of a per-call-site patch.
This realizes the consolidation ADR-1769 / CONTEXT.md already claimed shipped
('Absorbs readModifyWriteStateMd post-sync preservation block') and routes the
#1264 preservation policy through the single field-classification table — the
bug class is now structurally guarded by the table, not just the call-site
shouldResync flag.
Behavior is byte-identical to the pre-amendment inline block (Hyrum-safe — the
15 readModifyWriteStateMd callers' observable preservation is unchanged):
- state/frontmatter/transition + bug regression suite: 847 pass
- phase/milestone/verify (other RMW consumers): 582 pass
- codex (gpt-5.5/high) adversarial review: CLEAN (58,564-case equiv sweep)
ADR-1769 amendment appended documenting #1796.
Closes#1796
Migrate cmdStateSync, cmdStatePrune, cmdStateUpdate (state.cts) onto the
STATE.md Transition Module substrate and close the maintenance bug pair
#1760/#1761 (ADR-1769, epic #1769). Completes the substrate: all 10
lifecycle/maintenance transitions now route through transitionCore.
- Add {kind: 'sync'|'prune'|'update'} to StateTransitionIntent with syncCore,
pruneCore, updateCore in src/state-transition.cts.
- updateCore: body-only single-field update (strip/reassemble), mirroring the
pre-migration cmdStateUpdate contract.
- pruneCore: pure content→content section pruning (Decisions / Recently
Completed / resolved Blockers / Performance Metrics rows at or below cutoff),
byte-identical tokenizeHeadings splicing. Adapter owns currentPhase, dry-run,
and STATE-ARCHIVE.md.
- syncCore: body writes (Total Plans in Phase, Progress bar, Last Activity)
given disk-derived numbers. Adapter owns the disk scan + roadmap scope.
- #1760: cmdStatePrune now derives currentPhase from 'Current Phase' OR 'Phase'
(the canonical template emits 'Phase: X of Y'), so prune engages on
template-conformant STATE.md instead of bailing 'Only 0 phases'.
- #1761: cmdStateSync skips the Progress write (percent=null) when a milestone
version is set in frontmatter but the ROADMAP has no versioned heading for it
(milestone cannot be bounded). Projects without a milestone version are
unaffected. Leaves progress untouched rather than silently writing fallback-
derived wrong values.
- Regression: bug-1760 (prune engages on template field) + bug-1761 (sync
leaves progress untouched when unbounded). ADR-1769 marked Accepted.
All 82 transition + 515 regression tests pass.
Closes#1793
Migrate cmdStatePatch (state.cts) onto the STATE.md Transition Module substrate
and close the curated-field clobber bug class #1743/#1695 (ADR-1769, epic #1769).
- Add {kind: 'patch'} to StateTransitionIntent, with patchCore in
src/state-transition.cts. Applies each caller-supplied {field:value} pair via
stateReplaceField over the full content (body + frontmatter), tracking
updated vs. failed. data.updated/data.failed mirror the CLI output shape.
- Collapse cmdStatePatch to a transitionCore dispatch. Field-name validation
(security) and the resync-progress decision stay in the adapter.
- #1695/#1743 fix: extend the #1230 delta heuristic in readModifyWriteStateMd to
the curated current_phase_name, table-driven via
getFieldClassification('current_phase_name').preservation === 'preserve-always'.
When a write does NOT change the body Phase: source line, the curated
frontmatter value wins over syncStateFrontmatter's body re-derivation (which
harvests a wrong parenthetical aside — #1695). begin/planned/complete-phase
rewrite their body Phase line, so the delta does not fire for them and
current_phase_name still advances.
- Regression: bug-1695-state-patch-clobbers-phase-name.test.cjs — unrelated
patch preserves curated current_phase_name; patching the Phase source advances.
All 70 transition + 493 regression tests pass (state/phase/milestone + #905/#397/#3242 lineages).
Closes#1791
Migrate the STATE.md write path inside cmdMilestoneComplete (milestone.cts)
onto the STATE.md Transition Module substrate (ADR-1769, epic #1769).
- Add {kind: 'milestoneComplete'} to StateTransitionIntent, with
milestoneCompleteCore in src/state-transition.cts (consulting the
field-classification table). Owns the closure write: Status
('<version> milestone complete'), Last Activity, Last Activity Description,
a Current Position reset to 'Awaiting next milestone', and an Operator Next
Steps reset pointing at the next-milestone command.
- Collapse the inline STATE.md transform in cmdMilestoneComplete to a
transitionCore dispatch. The adapter retains writeStateMd (lock + steady-state
syncStateFrontmatter post-sync) and resolves the runtime-specific
next-milestone slash command, injecting it via intent.nextMilestoneCommand.
- The two section resets carry their pre-seam allow-adhoc-markdown waivers
(regex semantics pinned by existing tests; pending collectSection #1372).
- realClock.today() is byte-identical to milestone.cts's local today
(both new Date().toISOString().split('T')[0]).
- Characterization tests pin field updates, both section resets (replace +
insert paths), and frontmatter #1255 parity.
All 66 transition + milestone + state tests pass.
Closes#1789
Migrate cmdStatePlannedPhase and cmdStateMilestoneSwitch (state.cts) onto the
STATE.md Transition Module substrate (ADR-1769, epic #1769).
- Add {kind: 'plannedPhase'} and {kind: 'milestoneSwitch'} to
StateTransitionIntent, with plannedPhaseCore and milestoneSwitchCore in
src/state-transition.cts (consulting the field-classification table).
- plannedPhaseCore owns the template-aware Status/Last Activity updates, Total
Plans in Phase, Last Activity Description, and the Current Position section
(via the inlined mutateCurrentPositionForAdvance twin). The adapter keeps the
resync:false readModifyWriteStateMd wrapper (#500 RC1).
- milestoneSwitchCore owns the new-milestone reset: rebuilt frontmatter
(milestone/name/status='planning'/zeroed progress) + Current Position body
reset. The adapter keeps acquireStateLock + platformWriteSync (NOT
readModifyWriteStateMd — milestoneSwitch rebuilds frontmatter directly).
- Collapse both callbacks to transitionCore dispatches. The now-dead
updateCurrentPositionFields helper and KNOWN_STATUS_PATTERNS import are
removed (their behavior lives in the transition module's
mutateCurrentPositionForAdvance).
- Characterization tests pin the template-aware preserve-authored invariant,
Total Plans, Last Activity narrative, Current Position update, and the full
milestone reset (frontmatter + position body + gsd_state_version preserve +
Accumulated Context preserve).
All 58 transition + 177 state + phase + bug-2630/bug-905 regression tests pass.
Closes#1786
Migrate the STATE.md write path inside cmdPhaseComplete (phase.cts) onto the
STATE.md Transition Module substrate (ADR-1769, epic #1769).
- Add {kind: 'completePhase'} to StateTransitionIntent + completePhaseCore in
src/state-transition.cts. Pure body field mutations (Current Phase shape/name,
Status, Current Plan, Last Activity + Description, Completed/Total Phases +
Progress percent), consulting the field-classification table for touched keys.
Roadmap progress injected via a new optional deps.roadmapProvider.
- Collapse the ~90-line inline STATE.md transform in cmdPhaseComplete to a
transitionCore dispatch. The adapter retains updatePerformanceMetricsSection
(section table) + syncStateFrontmatter (disk-scan post-sync) and the
multi-file atomic transaction (STATE is committed with ROADMAP/REQUIREMENTS,
so readModifyWriteStateMd is not used here).
- deriveProgressFromRoadmap/clampPercent/stateReplaceFieldWithFallback move from
the phase.cts call site into the pure core (no circular dep: phase-lifecycle
and state-document don't import state.cts).
- Characterization tests in tests/state-transition.test.cjs pin the field
updates, roadmap progress derivation, #1255 frontmatter parity, and the
'Phase:' fallback. All 44 transition + 193 phase tests pass.
No user-visible behavior change. cmdPhaseComplete CLI output and 'phase complete'
behavior unchanged.
Closes#1784
* refactor(#1771): ADR-1769 Phase 1 — STATE.md Transition Module substrate + beginPhase
Lands the Phase 1 substrate per ADR-1769:
- src/state-transition.cts (new Module):
- Field-classification table (FieldClass enum + FIELD_CLASSIFICATION rows)
- STATE_MD_SECTIONS constants block
- Pure transitionCore(content, intent, deps) dispatch
- beginPhase intent implementation (first-time + #3127 resume paths)
- src/state.cts:cmdStateBeginPhase — collapses ~190 lines to a thin
dispatch onto transitionCore via readModifyWriteStateMd. The lock,
no-op write guard, and #1230 post-sync delta heuristic stay in the
RMW seam; the body-mutation policy moves to transitionCore.
- tests/state-transition.test.cjs (24 tests):
- Substrate invariants (table enum, section constants)
- Characterization: 6 first-time body field updates
- Characterization: 5 #3127 idempotency-guard resume behaviors
- Characterization: 3 Current Position section mutations
- Characterization: Current focus body text line (#1104)
- Property (RULESET.TESTS.property-based-testing): beginPhase status
propagation + FIELD_CLASSIFICATION own-property contract
- Resume Current Position mutation (preserves Plan/Phase/Status)
No external behavior change. Full state.test.cjs regression (177 tests)
plus bug-3127/#3242/#905/#948 pass. Property tests surfaced two
pre-existing quirks (state-document.cjs greedy \s* on whitespace-only
field values; Object.prototype method leakage on FIELD_CLASSIFICATION
lookups for strings like 'toString') — documented in test comments;
fix-out-of-scope for Phase 1.
Closes#1771
* refactor(#1771): ADR-1769 Phase 1 codex review corrections
Addresses 3 blocking findings from codex gpt-5.5/high review:
1. FIELD_CLASSIFICATION shape (state-transition.cts):
- Was flat FieldClass enum (collapsed source + preservation)
- Now two-column {source, preservation} rows per ADR-1769 §4
- Added missing fields verified via Memtrace against
buildStateFrontmatter (state.cts:1633-1653): gsd_state_version,
last_updated, last_activity_desc, progress.{total_phases,
completed_phases, total_plans, completed_plans, percent}
- Field 2-7 preservation dispatch can now consult the table
2. Prototype-pollution hardening (state-transition.cts):
- Table is now Object.freeze(Object.assign(Object.create(null), {...}))
- getFieldClassification() helper uses Object.hasOwn; returns null
for inherited prototype methods (toString/valueOf/__proto__)
- Old code: FIELD_CLASSIFICATION['toString'] returned the function
3. STATE_MD_SECTIONS aligned to canonical template:
- Verified against gsd-core/templates/state.md via Memtrace
- Was: 8 entries including non-template sections (## Session,
## Decisions, ## Operator Next Steps, ## Session Log,
## Roadmap Evolution)
- Now: 6 canonical top-level sections (## Project Reference,
## Current Position, ## Performance Metrics, ## Accumulated
Context, ## Deferred Items, ## Session Continuity)
Also: beginPhase now consults getFieldClassification() per touched
field (codex finding: 'table not consulted by transitionCore').
Unknown fields raise immediately — adding a field without a table
row is caught at runtime.
Repo-hygiene catches from gsd-test (not node --test, which missed
these):
- gsd-core/bin/lib/state-transition.cjs added to eslint.config.mjs
ignore list (ADR-457 tsc-generated)
- docs/INVENTORY-MANIFEST.json regenerated via
node scripts/gen-inventory-manifest.cjs --write
Property test for Object.prototype leakage tightened to verify
getFieldClassification() returns null for toString/valueOf/__proto__.
Ref #1771
* fix(#1771): cast Object.create(null) to satisfy @typescript-eslint/no-unsafe-assignment
ESLint CI failed on src/state-transition.cts:72:14 — Object.create(null)
returns `any`, which leaked through Object.assign to the typed
`FIELD_CLASSIFICATION` declaration. Adding an explicit cast to
`Record<string, FieldClassification>` eliminates the unsafe-assignment
while preserving the null-prototype protection codex review recommended.
gsd-test: 21888/21888 PASS.
* fix(#1771): add 'see #1771' to allow-test-rule exemption per ADR-456
CI lint-allow-test-rule-refs failed: 'New allow-test-rule exemption
without an issue ref — add `see #NNN` per ADR-456'. Updated comment
on tests/state-transition.test.cjs to reference the Phase 1 issue.
* fix(#1771): remove unnecessary allow-test-rule exemption
The exemption was added speculatively. The test file does not use
readFileSync + .includes()/.match()/.startsWith() on source content —
it calls transitionCore() with string literals and verifies results
via stateExtractField() and array .includes() on the updated[] array.
No exemption needed.
* feat(#1517): support custom reviewer instances for /gsd:review
Add a bounded review.reviewer_instances config surface so one model-capable
adapter (e.g. opencode) can run as several independent reviewer identities in a
single /gsd:review pass. Instances participate only via review.default_reviewers,
expand before built-in slugs, are available iff their cli is detected, and a
non-matching entry is a hard error (typo must be loud). >=2 same-cli instances
emit a shared-adapter caveat in REVIEWS.md. Default path with no instances is
byte-for-byte unchanged.
Single-source instance->cli resolution lives in resolveReviewerSelection /
normalizeReviewerInstances (parity-locked in
tests/review-reviewer-instances.test.cjs). cli validated against
KNOWN_REVIEWER_SLUGS only (never arbitrary shell); model/agent opaque, never
shell-interpolated.
Closes#1517
* chore(#1517): backfill changeset pr:1766
---------
Co-authored-by: review-bot <review-bot@gsd>
ADR-1235 step 1: route the trivial-converter runtime group (cursor, windsurf, augment, trae, codebuddy) off the inline install() agent loop onto the descriptor-driven installRuntimeArtifacts path. Establishes the converter-context foundation (pre-converter cross-cutting + no agent-stamp). Agent install output is byte-identical for all 16 runtimes (golden-parity, global + verified local). cline deliberately excluded (local rules-only). Closes#1763.
* fix(#1693): don't double-quote $CLAUDE_PROJECT_DIR-anchored hook paths on Windows
The installer's #2979 legacy-node rewrite ran every managed node hook path
through JSON.stringify on Windows. For local installs the path already carries
a "$CLAUDE_PROJECT_DIR"-anchored quoted prefix, so stringifying produced
"\"$CLAUDE_PROJECT_DIR\"/...". Node then received an argument starting with a
literal " , treated it as relative, and failed MODULE_NOT_FOUND — breaking
every node managed hook at once (a self-locking PreToolUse-guard deadlock).
projectLegacySettingsHookCommand now emits an already-anchored token verbatim
and only JSON.stringify-quotes bare absolute paths (which may contain spaces).
Scoped to win32 so non-Windows token-shape behavior is unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(#1693): add changeset
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ADR-1239 Phase B (parent #1679). getDirName was a hand-maintained 15-branch
if-chain mapping each runtime to its local content-rewrite dot-dir. Relocate
those values into a documented runtime.localConfigDir descriptor field; derive
getDirName from registry.runtimes[id].runtime.localConfigDir (fallback .claude).
- 16 capability.json gain runtime.localConfigDir (byte-identical values)
- capability-validator.cjs requires it (non-empty dot-dir); registry regenerated
- docs/reference/capability-manifest.md documents the field + the three
divergent values (copilot=.github, antigravity=.agents, kimi=.kimi-code)
- drift-guard test: golden value map + key-set equality both ways
Byte-identical install output for all 16 runtimes (golden-parity harness #1730).
Closes#1756
Co-authored-by: review-bot <review-bot@gsd>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* feat(#1754): CLI version-skew detection — warn when a global install shadows project-local GSD
Addresses #1754 (approved-enhancement). Detects when the running gsd-tools.cjs
is outside the project root while a project-local install exists — the shadowing
scenario from #1748 where a stale global canary CLI (retired @gsd-build/sdk)
silently overrides project-local GSD.
Implementation (Node CLI entry-point, not shell snippet — avoids bloating 93
workflow files past their size caps):
- src/cli-skew-check.cts: pure function checkCliSkew({resolvedPath, projectRoot,
projectLocalExists}) → string|null. Compares paths via path.relative; returns
a warning when the resolved CLI is outside the project root AND a project-local
install exists. Includes @gsd-build/sdk removal hint when the path matches.
No I/O (pure), no gsd-sdk literal (avoids bug-2801 lint).
- gsd-core/bin/gsd-tools.cjs: wired at startup via the existing findProjectRoot
resolver. Non-blocking (try/catch; advisory stderr warning, never gates).
- eslint.config.mjs: registers the new ADR-457 generated artifact in the ignores.
- tests: 6-case suite (skew/no-skew/legacy/normalization); all green.
- Golden fixtures regenerated (UPDATE_GOLDEN=1) for the new compiled artifact.
- docs/how-to/update-gsd.md: Diátaxis reference note for the skew warning.
Full suite: 3354 pass, 0 regressions (1 pre-existing local AGENTS.md failure).
lint:ci green.
Closes#1754
* chore(#1754): backfill changeset pr placeholder
* chore(#1754): regenerate INVENTORY-MANIFEST for the new cli-skew-check source module
---------
Co-authored-by: review-bot <review-bot@gsd>
Post-merge coverage-audit follow-ups to epic #1702 (found by an independent
gpt-5.5/high audit + adr-phase-coverage cross-reference). None are CRITICAL —
the 9-rule enforcement shipped and works; these close completeness/integrity
gaps between ADR-1703's promises and the as-built reality.
1. drift-guard bin/install.js scope (ADR-1703 L114-119): the drift guard
covered src/runtime-homes.cts only; the ADR named bin/install.js too. Phase
6's glob expansion made bin/install.js a covered surface. Extended
tests/portability-vocab-drift.test.cjs with TWO sound checks: (a) any
bin/install.js top-level function that directly returns path.*() must be in
PATH_RETURNING_FNS (tight, 0 FP — the body-contains heuristic is unsound
here, ~33 FPs); (b) a curated two-way existence lock on the installer path
helpers (catches a rename making a vocab entry stale; keeps the curation in
sync with PATH_RETURNING_FNS). The residual new-resolver boundary (temp-var
shape) is documented.
2. ci-test-scope wiring (the Phase 6 portability selection rule was
ineffective): eslint-rules/ was not in the product-code prefix list, so an
eslint-rules-only change set code_changed=false and CLEARED the matched
tests (reproduced: targeted_tests=[]). Added eslint-rules/ to the prefix
list and the P1-P4 RuleTester suites to the selection rule (it previously
listed only P5/P6). Verified: code_changed=true, 11 tests selected.
3. ADR-1703 acceptance note amended to record the two further as-built
divergences: the disable-ban shipped as an out-of-band test (not the
specified local/no-portability-disable meta-rule — the test runs outside
ESLint so it cannot be self-disabled, at least as strong); and the
drift-guard bin/install.js scope resolution above.
Epic #1702 all eight phase boxes now checked. No runtime change; no-changelog
(contributor tooling + docs).
Closes#1749
Co-authored-by: review-bot <review-bot@gsd>
* feat(#1740): require-fs-op-fallback production AST rule + Windows transient-lock retry (Phase 6)
ADR-1703 Phase 6 of the cross-platform portability epic (#1702). Adds the
second production-code portability AST rule + the ADR-mandated glob expansion
to bin/install.js and scripts/build-hooks.js.
- eslint-rules/require-fs-op-fallback.cjs: flags an unguarded fs.rename /
fs.renameSync (the atomic-publish primitive named first in
DEFECT.WINDOWS-FS-OPS.symptom) that is NOT inside a try/catch whose handler
references a transient errno ('EPERM'/'EBUSY'/'EACCES' or a *RETRY_ERRNOS
set) AND NOT behind a Windows platform guard. A catch that silently swallows
or cleans-up-and-rethrows without an errno check does NOT satisfy the
defect's 'never silently swallow' clause. copyFile/unlink are deliberately
not flagged (they are the fallback primitives per the defect's own
fix-forward). Scope narrowed to rename per Phase 5's precision discipline;
documented on #1740.
- src/shell-command-projection.cts: export retryRenameSync(from, to) — the
drop-in bounded-retry helper over the existing atomicRenameWithRetry.
- 27 bare fs.renameSync sites across 11 modules routed through retryRenameSync
(capability-lifecycle/lock/source, installer-migrations, milestone, phase,
planning-workspace, roadmap-upgrade, runtime-hooks-surface, state,
workstream). Idempotent on POSIX; resilient to AV/indexer transient locks
on Windows.
- eslint.config.mjs: register rule at error on src/**/*.cts; new focused
portability-rules block covering bin/install.js + scripts/build-hooks.js
(ADR-1703 L124-126 glob expansion — both files are compliant: zero
rename violations).
- tests: 15-case RuleTester suite; portability-rule-disable-ban extended
(PROTECTED_RULES + scans bin/install.js/build-hooks.js with shebang
handling); ci-test-scope portability-lint selection rule.
- CONTEXT.md DEFECT.WINDOWS-FS-OPS predicate rewritten to point at the rule;
docs/contributing/cross-platform-portability-rules.md reference + how-to.
Closes#1740
* chore(#1740): backfill changeset pr:1742
* fix(#1740): tighten require-fs-op-fallback precision (codex review HIGH-1/HIGH-2)
Addresses two false-negative findings from the codex (gpt-5.5/high)
adversarial review of PR #1742:
HIGH-1 — a catch that REFERENCES a transient errno but only rethrows (no
retry/fallback) was marked compliant. The DEFECT.WINDOWS-FS-OPS fix-forward
requires retry, not just recognition. Fix: catchHandlerHasRetrySignal now
requires a loop `continue` backedge OR a `return <call>` delegation; a bare
rethrow is flagged. The misleading `/* retry logic */` valid test is replaced
with a real retry loop, and the rethrow-only shape is added as invalid.
HIGH-2 — the nested-try ancestor walk treated an OUTER errno-catch as
protecting the rename even when an INNER catch intercepted/swallowed the error
(the outer catch is unreachable). Fix: isInsideTransientErrnoTryCatch now stops
at the NEAREST enclosing TryStatement WITH A CATCH HANDLER whose block contains
the rename (try-finally is skipped — it doesn't catch); outer catches are no
longer consulted. The unsound nested-try valid test is converted to invalid,
and a try-finally-skipped valid case is added.
Verified: 17 RuleTester cases pass; zero new production violations (the 27
fixed sites use retryRenameSync; the real retry loops — atomicRenameWithRetry,
capability-ledger/consent, build-hooks — remain compliant via continue/errno);
lint:ci green; disable-ban + vocab-drift green.
---------
Co-authored-by: review-bot <review-bot@gsd>
* feat(#1733): normalize-path-in-content production AST rule (Phase 5)
ADR-1703 Phase 5 — the first production-code rule. local/normalize-path-in-content
(src/**/*.cts, @typescript-eslint/parser): flags a path-returning fn result
(path.basename excluded — returns a separator-less filename) interpolated into an
@-reference / config-dir markdown body without .replace(/\\/g,'/') normalization,
per RULESET.CONTENT-PATH-NORMALIZATION / DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.
Build-and-assess found the canonical defect site (computePathPrefix) already
compliant and only 1 src/ hit — a false positive (path.basename in a status
message) — eliminated by narrowing (exclude basename; require a real @-ref/
config-dir marker, not bare .md). 0 src/ violations: clean forward-prevention.
The out-of-band disable-ban now scans src/**/*.cts too (typescript-estree) so the
production rule also cannot be eslint-disabled. Registered (error) + PROTECTED_RULES;
CONTEXT.md predicates + how-to doc updated.
- RuleTester suite (26 cases)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(#1733): add changeset for Windows agent-skills path-leak fix
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix: harden mutation-matrix.cjs stdin read against EAGAIN on non-blocking pipe
scripts/mutation-matrix.cjs read piped stdin via readFileSync(process.stdin.fd).
On macOS libuv marks the stdin pipe fd non-blocking, so a synchronous read can
throw EAGAIN before the writer fills the pipe — intermittently, under heavy CI
shard load — aborting the script (status 2) and flaking mutation-matrix-ratchet.
Replace with readStdinSync(): an fs.readSync loop that retries on EAGAIN (1ms
synchronous Atomics.wait yield), stops on 0-byte/EOF, and rethrows other errors.
Deterministic regression test injects EAGAIN via an fs.readSync monkeypatch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ci: re-run golden-install-parity on src/lib + installer changes (close drift guard)
golden-install-parity hashes every installed bin/lib/*.cjs per runtime, so it
must re-run whenever the built lib could change. ci-test-scope selected it for
neither src/** nor installer changes, so a source-only edit (e.g. #1691's
milestone.cts/roadmap.cts) recompiled bin/lib and silently drifted the golden
fixtures past the scoped lane. Add golden-install-parity.test.cjs to both the
'TS runtime sources' and 'installer and package layout' selection rules, with
behavioral regression tests for each.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: review-bot <review-bot@gsd>
* refactor(#1734): extract install engine from bin/install.js (ADR-1239 Phase B deep move)
Relocate the runtime-artifact install cluster out of the 12,490-line
bin/install.js into a dedicated src/install-engine.cts -> install-engine.cjs:
installRuntimeArtifacts, uninstallRuntimeArtifacts, installOpencodeFamilySkills,
and their cluster helpers (_copyStaged, snapshot/restore, legacy migration,
GSD-entry pruning, preserve/restoreUserArtifacts, OpenCode-family converters,
USER_OWNED_ARTIFACTS).
- bin/install.js imports the engine and re-exports the moved symbols for
back-compat; getCommitAttribution STAYS in install.js (impure config I/O +
argv explicitConfigDir global) and is injected via a resolveAttribution param.
- 17 test files migrated to import the moved symbols from the engine.
- Bookkeeping: eslint built-artifact ignore, .gitignore, INVENTORY manifest+row,
CONTEXT.md Install Engine Module glossary seam.
Behaviour-preserving: install output is byte-identical for all 16 runtimes
(golden-parity harness #1730) — the only delta is the new install-engine.cjs
file shipping in the installed gsd-core/bin/lib/ tree.
Closes#1734
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore(#1734): backfill changeset PR number (#1735)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: review-bot <review-bot@gsd>
* fix(#1580): exclude 0/999 sentinels from milestone-complete guard and roadmap analyze
Closed#1445 added the `^999` backlog-sentinel exclusion to the progress
denominators but missed two other resolvers, leaving two user-facing failures
live on a milestone whose only directory-less ROADMAP heading is a backlog
sentinel:
(A) `milestone complete` was blocked by the unstarted-phase guard in
src/milestone.cts — it flagged `### Phase 999: Backlog` as an unstarted
phase and refused to close a fully-shipped milestone without --force.
(B) `roadmap analyze` (src/roadmap.cts) counted the sentinel in phase_count
and routed `next_phase` straight into Phase 999.
Both now skip Phase 0 (pre-milestone) and Phase 999 (backlog) sentinels,
mirroring the engine-wide convention (phase-id getMilestoneFromPhaseId,
roadmap-command-router SENTINELS, the #1445 progress filters). Symptom (C)
(state.cts total_phases) was already fixed inline by #1445/#1514 and is out
of scope here.
Regression coverage folded into tests/fix-1445-*.test.cjs (scenarios C and D);
updated tests/bug-978 fixture to use a real unstarted phase (Phase 2) instead
of a 999 sentinel, since the sentinel is now correctly excluded from that guard.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(#1580): add changeset for 0/999 sentinel exclusion fix
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ADR-1239 Phase B (parent #1679). Safety net for the upcoming engine deep-move:
captures the COMPLETE emitted install output of all 16 runtimes as a golden
baseline so the move PR can prove byte-identical parity.
tests/golden-install-parity.test.cjs spawns the real installer per runtime into
a temp HOME, normalizes the temp path to <HOME>, excludes the two volatile
metadata files (gsd-file-manifest.json, gsd-install-state.json — the only
run-to-run variance after normalization, empirically), SHA-256s every remaining
file, and asserts the manifest matches tests/fixtures/golden-install-parity/<rt>.json
(8,957 hashes total). UPDATE_GOLDEN=1 regenerates; mismatches list
added/removed/changed paths. Non-vacuous (corrupting a hash fails the run).
Closes#1730
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* refactor(#1727): derive NON_CLAUDE_RUNTIMES from the capability registry
ADR-1239 Phase B (parent #1679). NON_CLAUDE_RUNTIMES was a hand-maintained
15-element literal whose own doc-comment said "keep in sync with bin/install.js
and getDirName()" — a parallel source of truth that can drift from the
capability registry. Derive it instead:
Object.keys(capabilityRegistry.runtimes).filter(id => id !== 'claude').sort()
The exported value is byte-identical to the old literal (the registry's
runtimes key set minus claude is exactly the 15 entries), so there is no
observable behavior change; the list can no longer drift from the registry.
capability-registry.cjs is a committed, dependency-free data module (no cycle).
Drift-guard test: golden-oracle deepEqual (non-circular) + a role-based
cross-check from the registry metadata + every member must have a non-'.claude'
getDirName branch (a registry runtime missing a getDirName branch now fails CI).
Closes#1727
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore(#1727): backfill changeset PR number (#1728)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(#1727): put docs-exempt marker on its own line so parse.cjs extracts it
DOCS_EXEMPT_RE is line-anchored (^...$ + m flag); the marker only counts on
its own line. It was appended to the end of the body text, so it was never
extracted and docs-lint failed in CI (fail_docs_missing). Verified via direct
parse.cjs extraction (docsExempt now non-empty, marker stripped from body).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* feat(#1724): complete install write-confinement (copyWithPathReplacement, installCodexConfig)
ADR-1239 Phase B (parent #1679). PR #1706 (2a) confined the layout-driven
plan path and _copyStaged's inline guard; this completes the destSubpath
write-confinement acceptance criterion for the two remaining write sites
and canonicalizes _copyStaged.
- copyWithPathReplacement: new required confinementRoot param; a fail-closed
gate (assertDestWithinConfigHome + hasExistingSymlinkBetween) runs BEFORE
the rmSync/mkdirSync; root threaded through recursion + all 4 call sites
(stageRoot for pristine staging, targetDir for the 3 install sites); writes
go through the validated absolute path. Exported for behavioral testing.
- installCodexConfig: confines config.toml, agents/, and per-agent
agents/<name>.toml (name from agent frontmatter) via the canonical gate +
symlink-escape guard (parity with the other two functions).
- _copyStaged: fail-closed when configDir omitted (all callers pass it);
delegates strict-subpath to the canonical gate, keeps its symlink guard,
writes through the validated absolute path.
Reuses the existing assertDestWithinConfigHome (handles absolute dests via
path.resolve) and hasExistingSymlinkBetween — no new module. Behavioral
regression tests (escape/dest==root/fail-closed/symlink/name-injection),
red-first proven; cross-platform symlink tests use t.skip not bare return.
Closes#1724
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore(#1724): backfill changeset PR number (#1725)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* feat(#1708): typed documentation-sourced #853 dispatch-flatten
Graduate the #853 orchestrator-backgrounding decision from a scattered RUNTIME==='codex' prose check to a typed, documentation-sourced engine decision. Adds a backgroundDispatch dispatch sub-axis (sourced per host: codex+cursor documented true, 9 documented false, 5 undocumented), shouldFlattenDispatch(dispatch) (inline UNLESS background && backgroundDispatch, fail-closed), and a gsd_run query dispatch-should-flatten the plan/execute workflows call. Cursor is newly background-eligible per its docs (inline->background) — a documentation-justified behavior change. No RUNTIME-name residue for this decision.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(#1708): backgroundDispatch citations in matrix + CONTEXT note
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(#1708): address review findings on typed dispatch-flatten
Code/adversarial review: convert the manager.md/autonomous.md Compound Action preamble from hardcoded 'On Codex' to FLATTEN-based branching (the handlers already use the query; the preamble contradicted them and was wrong for cursor); make shouldFlattenDispatch null-safe + type-honest (accepts raw 'undocumented' registry values); make backgroundDispatch a required descriptor field (matching its siblings, all 16 carry it); strengthen the config.runtime behavioral test; update the bug-853 prose-pin test + comment. Security review clean; Codex confirmed no fail-open.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(#1708): backfill backgroundDispatch in role:runtime test fixtures
Making backgroundDispatch a required descriptor field broke role:runtime fixtures in capability-manifest-version/capability-registry/host-integration-descriptors tests that build a dispatch object without it (caught by full gsd-test, not scoped npm test). Backfill backgroundDispatch:false into the well-formed fixtures; the deliberately-malformed 'required-field' test fixture is left malformed by design.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(#1708): update fix-1521 dispatch-gating assertion to the FLATTEN gate
fix-1521 pinned the codex-specific run_in_background prose that #1708 graduated to the typed dispatch-should-flatten/FLATTEN gate. Update its assertions to verify FLATTEN=false gating (not a runtime name) + that the old RUNTIME===codex gate is gone. Caught by full gsd-test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(#1708): add changeset for typed dispatch-flatten
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore(#1708): remove stray temp PR-body file
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(#1708): add issue ref to bug-853 allow-test-rule annotations
ADR-456 requires every allow-test-rule exemption to carry a see #NNN reference; the source-text-is-the-product annotations added when migrating the prose assertions lacked it (lint-tests CI gate). Add (see #1708).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* feat(#1679): confine install writes within configHome
ADR-1239 Phase B write-confinement: a pure assertDestWithinConfigHome(configDir, destSubpath) rejects a destSubpath that escapes configHome (path traversal / NUL byte) at plan-build time on BOTH the install and uninstall plan paths; surface.applySurface and installOpencodeFamilySkills route through it, and _copyStaged carries a defense-in-depth containment check. Security-load-bearing for the Phase C third-party-descriptor loader.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(#1704): add changeset for destSubpath write-confinement
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(#1704): fix windows path-portability in confinement test
The N1 'accepts a true child subpath' assertion compared against path.join (no drive resolution) while the helper uses path.resolve — on Windows that mismatches the C: drive prefix. Compute the expected via path.resolve to mirror the helper. Windows-CI-only failure (local gsd-test is Mac+Linux).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* feat(#1684): add negotiated host-integration interface module
ADR-1239 Phase A: a pure, additive, no-I/O module exposing PROTOCOL_VERSION, the 8-axis HOST_INTEGRATION_AXES closed vocabulary, the UNDOCUMENTED fail-closed sentinel, negotiateHostCapabilities (effective subset of host-declared and engine-known), a typed degradation ladder, and host-capability profiles.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(#1684): validate and document host-integration axes (16 runtimes)
Extend validateRuntimeBody to validate the 8 hostIntegration axes (closed enums + undocumented sentinel + dispatch struct + reserved-key guards) and the widened runtime vocabulary; author a documentation-sourced hostIntegration block in all 16 runtime descriptors; regenerate the registry. Every per-CLI value is documented (cited) or the explicit undocumented sentinel.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(#1684): add host-integration capability matrix and adr amendment
New per-CLI, per-axis citation reference (value/source/evidence for all 16 CLIs); ADR-1239 Phase-A-implemented amendment; CONTEXT.md glossary seam entry.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(#1684): harden dispatch negotiation edge cases
Code-review hardening: treat NaN/Infinity maxDepth as missing (fail-closed, +warning); reset nested/background when namedDispatch collapses to false (struct consistency); SAFE_DEFAULTS dispatch floor to read-only; warn on non-finite protocolVersion; symmetric undocumented warnings for dispatch fields. Pure module — no consumers; behaviour fail-closed throughout.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore(#1684): register host-integration.cjs in lint-ignore and inventory
New tsc-generated bin/lib artifact: add to the eslint ignore list (ADR-457 — lint the .cts source), regenerate docs/INVENTORY-MANIFEST.json, and add the docs/INVENTORY.md CLI-modules row. Fixes the 3 gsd-test failures (551-eslint-bin-lib-coverage x2 + inventory-manifest-sync).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(#1684): add changeset fragment for host-integration interface
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(#1684): add how-to for sourcing a host's integration axes
Diataxis how-to guide for adding/updating a host's runtime.hostIntegration axes from authoritative docs, the undocumented-sentinel rule, validation, and extending the closed vocabulary. Completes the Step-5 doc quadrants (reference + explanation + how-to). Indexed in docs/README.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* docs(#1650): fix stale opencode install-path claim in core settings
* feat(#1688): warn on stale model bake for static-frontmatter runtimes
* chore(#1688): backfill changeset pr field with real PR number
* test(#1688): make resolveAgentDir assertions use path.join for windows
* docs(#1688): codify windows path-literal-in-assert anti-pattern + align test
Delivers the property coverage promised in #1511's test scope but not landed
(follow-up #1676, epic #1507 / ADR-1508). Adds
tests/enh-1676-path-prefix-collapse-idempotency.property.test.cjs covering:
(A) $HOME-collapse invariant for _computePathPrefix — global-under-home
projects to $HOME/<suffix>/ (exact equality, not substring, so short
homes like /root or /a do not false-positive); opencode is the
documented exception (absolute form, never $HOME).
(B) backslash->posix invariance (#1615 Windows path-leak fix).
(C) path-rewrite idempotency for _applyRuntimeRewrites across the
path-rewriting runtimes (f(f(c)) === f(c); attribution held at
undefined to isolate the path axis). Non-vacuous: a sanity assertion
proves the first pass actually rewrites the seed ~/.claude/ refs.
Pure test addition — no production code changed. Uses the shared
fast-check-setup (seed=42, numRuns=200). Closes#1676
bin/install.js held byte-identical duplicate definitions of the augment
converter family (convertSlashCommandsToAugmentSkillMentions,
convertClaudeToAugmentMarkdown, getAugmentSkillAdapterHeader,
convertClaudeCommandToAugmentSkill, convertClaudeAgentToAugmentAgent) that
already exist canonically in src/runtime-artifact-conversion.cts (generated
to gsd-core/bin/lib/runtime-artifact-conversion.cjs). Deferred Phase 1->2
cleanup tracked in #1675 (epic #1507 / ADR-1508).
Deleted the five local copies; install.js now binds the three PUBLIC
converters from runtimeArtifactConversion (same pattern as getDirName /
processAttribution in #1510). The two private helpers live only in the
conversion module now. module.exports preserved (re-exported).
Behavior-preserving: four converters byte-identical; the fifth
(convertClaudeAgentToAugmentAgent) differed only by an inert let->const
(variable never reassigned). Extends the DEFECT.GENERATIVE-FIX
reference-identity parity guard in enh-1511 to assert single-sourcing.
Closes#1675
* fix(#1520): randomize mktemp temp paths on BSD/macOS (XXXXXX must be path-final)
BSD/macOS mktemp only substitutes the XXXXXX template when it is the final
path component. Templates like `...-XXXXXX.json` / `gsd-pr-body.XXXXXX.md`
return a LITERAL `XXXXXX` path (no randomization) on macOS, so concurrent
workflow runs collide on the same temp manifest/body file — one run can
overwrite or consume another's. Reproduced on macOS: the second call to the
suffixed template fails `mkstemp: File exists`.
Fix: use a suffixless `XXXXXX` template (so it IS the final component), then
rename to add the intended extension — portable across BSD + GNU userlands,
no GNU-only `--suffix` flag. Empty-file-then-write semantics are preserved at
every site.
Affected workflow temp files:
- execute-phase.md: gsd-worktree-wave-*.json (wave worktree manifest)
- quick.md: gsd-quick-worktree-*.json
- spec-phase.md: edge-probe-reqs-*.json
- ship.md: gsd-pr-body-*.md
- profile-user.md: gsd-profile-answers-*.json, gsd-profile-analysis-*.json
The execute-phase.md edit uses a compact intermediate var + trailing comment
to stay under the ADR-857 phase-6 size ceiling (93166); regenerated the
workflow size baseline accordingly. Validated on macOS: 20 concurrent calls
yield 20 unique randomized paths.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(#1520): add changeset fragment (Fixed)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(#1520): add fail-first workflow-prose guard for mktemp XXXXXX suffix
Repo-wide scan of gsd-core/workflows/**/*.md that fails on any mktemp
template whose XXXXXX run is followed by a filename suffix (the BSD/macOS
non-randomizing form). Fails on the six pre-fix instances and passes on
the fix, and locks the copy-paste-prone idiom out of future workflows.
Mirrors the bug-637 hardcoded-$HOME workflow guard.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(#1520): rename regression test to fix- prefix (regression-test-names lint)
New tests/bug-NNNN-*.test.cjs files are banned by the lint-regression-test-names
ratchet; use the fix- prefix (matches the fix-1445 precedent).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(#1520): add issue ref to allow-test-rule exemption (ADR-456 lint)
lint-allow-test-rule-refs requires every new `allow-test-rule:` comment to
carry a #NNN reference (don't allowlist). Add (#1520) to the source-text
exemption.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(#1520): abort touched mktemp chains on failure (|| exit 1)
Per review: the VAR=$(mktemp …) && mv … && VAR=… chains dropped the issue's
suggested failure guard. If mktemp fails, $VAR is empty and the subsequent
mv/write lands on an unintended relative path. Add `|| exit 1` to all six
touched chains so a mktemp failure aborts the snippet. Regenerated the
workflow size baseline for the slightly longer lines.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(#1520): rebase onto next — regen size baseline + describe rename
Resolve the workflow-size-baseline.json conflict from next advancing by
regenerating from the current workflow sizes. Also rename the test describe
from `bug #1520` to `#1520` (the file uses the fix- prefix) per review nit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(#1520): harmonize profile-user mktemp to ${TMPDIR:-/tmp} (review nit)
The two profile-user.md temp sites this PR already rewrites kept a hardcoded
/tmp while the four sibling workflows use ${TMPDIR:-/tmp}. Harmonize for
consistency and macOS-correctness (some sandboxes have no writable /tmp).
Regenerated the workflow size baseline.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(#1520): regen size baseline after rebase onto next
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
* feat(#323): fish-shell support in post-install PATH suggestion
Two additive changes to the post-install PATH-suggestion seam, both scoped
to existing functions.
A. Projection: add a fish entry to the persist-mode shell-action list in
projectPathActionProjection() (src/shell-command-projection.cts). fish has
no `export`/`$PATH`-list syntax, so the existing zsh/bash `export PATH=...`
commands are inert when pasted. The new entry emits the fish-native
`fish_add_path '<dir>'` (fish 3.2+, persists via the universal-variable
store, de-duplicating). The directory is single-quoted with the same POSIX
literal escaping as the zsh/bash siblings; verified round-tripping through
real fish 3.7.0 for paths containing quotes, spaces, `$`, `*`, backticks
and unicode.
B. Detection: add homePathCoveredByFishConfig() in bin/install.js, called
from maybeSuggestPathExport() alongside homePathCoveredByRc(). fish does
not use sh-style `export PATH=` rc files, so a fish user whose
fish_user_paths already covers the global bin would otherwise get a
false-positive "not on your PATH" warning on every install. Two
side-effect-free detection routes (no fish subprocess):
1. The universal-variable store (~/.config/fish/fish_variables). fish
serializes this with `full_escape`: every byte outside [A-Za-z0-9/_]
becomes `\xHH` (space -> \x20, `-` -> \x2d, `.` -> \x2e, `$` -> \x24,
unicode -> \uXXXX) and list elements are joined by the literal 4-char
token `\x1e` (NOT a raw 0x1e byte). The detector splits on `\x1e`,
decodes the escapes, then compares each as an absolute literal — a
decoded `$` is part of the directory name, not an unexpanded variable.
Verified against real fish 3.7.0 output.
2. config.fish (`fish_add_path`, `set -gx PATH`, `set -Ux fish_user_paths`)
— plain shell tokens: HOME forms ($HOME/${HOME}/~) are expanded and a
token still holding `$` (e.g. `$PATH`, `$fish_user_paths`) is skipped.
Honours $XDG_CONFIG_HOME and always also checks ~/.config/fish.
No behaviour change for bash/zsh/PowerShell/cmd/Git-Bash users: their entries
and command strings are unchanged; the fish entry is additive and the fish
detector only narrows the set of cases that warn.
Tests: update the projection length assertion (2 -> 3) and fish escaping in
bug-3441; add fish detection + suppression cases in install-path-detection
(uvar store with real fish escaping, dot/hyphen/space/$-literal decode
regressions, config.fish routes, commented-out, relative-segment guard,
unreadable-file fault injection, suppression and emission via
maybeSuggestPathExport).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(changeset): add Changed fragment for #323 fish PATH support (#727)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(#323): address review — action-only fish docs, decoder property test, win32 guard
Addresses @trek-e's review on #727:
- docs (blocker): keep the how-to action-only (Diátaxis). Drop the
`# fish — persists via …` comment and the internal-mechanism clause
naming fish_variables/config.fish; leave one command + the exec-fish
directive.
- tests (minor): extract decodeFishUniversalValue to a pure, exported
module function and add fast-check round-trip properties
(decode(fishEscape(p)) === p over arbitrary unicode, abs-path variant,
totality). Consolidated into install-path-detection.test.cjs to respect
the install test-file-count ratchet.
- tests (follow-up): port #721's win32 negative-projection test (no fish
action on win32; persist projection is PowerShell/cmd.exe/Git Bash).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(#323): address review — drop unused 'after' import, clarify escaping comment
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>