* 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>
* fix(#1577): isolate WebFetch/WebSearch ingress + opt-in injection blocking
Split A of #1573 (security-critical). Scans WebFetch/WebSearch output (the
largest untrusted channel) in gsd-read-injection-scanner; shared
untrusted-input-boundary reference @-included by the 8 ingest agents
(randomized per-wrap delimiters, in-prompt self-scan guard, task-anchoring);
opt-in security.injection_blocking (default advisory — non-breaking).
arXiv: 2506.05739 (PPA), 2507.15219 (PromptArmor), 2504.20472 (Referencing), 2503.00061 (defense-in-depth).
* fix(#1577): address review — honest blocking docs, config key, ADR, property test, revert localized
- A1: rewrote the opt-in-blocking doc + Security changeset honestly — the PostToolUse hook is a
circuit-breaker (halts the agent's next step), NOT a redactor; it does not scrub content already
in the transcript. The prompt-level data/instruction boundary is the primary control.
- A2: registered security.injection_blocking in the config schema + defaults manifests (default
false) + an e2e config-roundtrip test; the dotted setter writes the nested shape the hook reads.
- A3: reverted the 4 hand-edited localized security-model.md (canonical EN only, per convention).
- A5: ADR-1577 (untrusted-input boundary + opt-in blocking; redaction-vs-circuit-breaker rationale).
- A6: property test — scanner never crashes / only emits valid JSON on unicode/large/malformed input.
- Also: inventory (untrusted-input-boundary.md) + agent-size baseline (8 ingest agents) +
drift-guard matcher update (Read -> Read|WebFetch|WebSearch). A7 (content<20 early-exit) left as
the noted pre-existing follow-up.
* fix(#1577): allowlist untrusted-input-boundary.md in injection-scan CI gate
The new reference quotes injection phrases ('ignore previous instructions',
'you are now…') as examples agents must NOT comply with, tripping the repo's
own prompt-injection-scan.sh diff gate (the standalone 'security' CI job, red
on HEAD). Allowlist it alongside the other security docs (security-model.md,
TEST-EXAMPLES.md) that legitimately demonstrate injection patterns. The JS
scanner test doesn't scan references/, so only the shell gate needed it.
Verified: scan --diff origin/next -> 0 findings; scanner JS test 15/15.
* fix(#1577): cover AC #2's gsd-ui-researcher + gsd-assumptions-analyzer
trek-e Major 1: the @-included set dropped two AC #2 agents. Restore them so
no named web-ingress agent is uncovered, keeping the two justified additions
(gsd-ai-researcher, gsd-domain-researcher). Final set = AC's 8 + 2 = 10.
- gsd-ui-researcher carries the full WebSearch/WebFetch + MCP-fetch toolset.
- gsd-assumptions-analyzer reads 5-15 codebase source files (external/source-
document ingress per the boundary), though it has no web tools.
INGEST_AGENTS in the isolation test now asserts all 10; size baselines
regenerated (+60 bytes each, both well under the DEFAULT cap); changeset
reworded 8 -> 10.
Verified: untrusted-input-isolation 14/14; agent-size-budget 39/39.
* docs(#1577): document security.injection_blocking + boundary seam
trek-e Major 2 + Minor:
- docs/CONFIGURATION.md: add the top-level security.injection_blocking key to
the Full Schema and a Security Settings subsection, distinguishing it from
the workflow.security_* namespace; honest circuit-breaker-not-redactor
framing matching ADR-1577 / security-model.
- CONTEXT.md: add the 'Untrusted-input boundary' seam glossary entry.
Verified: lint:docs ok; config-field-docs + contributor-standards green.
* test(#1577): make read-injection property test git-text, not binary
trek-e nit (and more): the file embedded a raw U+FFFF AND a raw NUL byte as
degenerate-edge inputs. The NUL is what actually made git classify it binary
(git binary = NUL in first 8K). Replace both with text-safe escapes that keep
the identical runtime values: '\\x00' and String.fromCodePoint(0xFFFF). File
now diffs/blames line-by-line.
Verified: property test 2/2; no NUL/raw-noncharacter bytes remain.
* docs(#1577): align untrusted boundary docs
Name all 10 ingress agents in INVENTORY/security-model and allowlist the intentional read-injection property corpus for the prompt-injection scanner.
* docs(#1577): align ADR ingest agent count
Update ADR-1577 from 8 to 10 ingest agents so it matches the actual boundary include set and the rest of the docs.
---------
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
* fix(#1571): resolve schema-drift phase by token, not substring
verify schema-drift <phase> resolved the phase directory with a naive
entry.name.includes(phaseArg) test, so a non-existent phase could
silently match a different phase whose directory name merely contained
the requested token (e.g. "1" matched "11-expansion"), running the drift
gate against the wrong phase. Use the canonical phaseTokenMatches +
normalizePhaseName, matching find-phase, verify phase-completeness, and
this file's own unstarted-phase check.
Regression coverage folded into tests/schema-drift.test.cjs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(#1571): add changeset for schema-drift token-match 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>
#1668 (phase complete did not persist the By-Phase row on a CRLF STATE.md) no longer
reproduces on next: #1655's restructure of updatePerformanceMetricsSection (By-Phase table
upsert now runs BEFORE the velocity manipulation) resolved it as a side effect. Verified
clean on next (CRLF STATE.md -> phase complete -> row upserted, placeholder removed,
velocity derived). Restore the #1658 test's full row-upsert + placeholder assertions that
#1667 had relaxed (they now pass), locking the fix.
* fix(#1516): expose adaptive model_profile in /gsd-new-project AI Models prompt
Both onboarding paths (Step 2a auto-mode + Step 5 interactive) enumerated only
4 profiles (Balanced/Quality/Budget/Inherit), omitting 'adaptive' even though the
model catalog (model-catalog.json profiles) and docs/CONFIGURATION.md register 5.
Mirrors the proven /gsd:settings two-question split (#3784): Q1 routes between
Adaptive/Standard-tier/Inherit; Q2 (conditional on Q1=Standard) picks
Quality/Balanced/Budget — keeping every AskUserQuestion within the 4-option cap.
Both config-new-project example payloads now list adaptive. Regression cases
folded into the owning tests/new-project-mvp-prompt.test.cjs (per the
lint-regression-test-names ban on new top-level bug-NNNN files): each AI Models
prompt makes adaptive reachable, all 5 profiles reachable, 4-option cap honored,
both example enums include adaptive, brace balance. Workflow size baseline bumped
(new-project.md 62324 -> 66138 bytes; still well under the XL hard cap).
* chore(#1516): backfill changeset pr ref to 1654
* fix(#1659): dedup By-Phase rows across padded/unpadded phase numbers
phaseRowPattern matched the phase number literally (escapeRegex(String(phaseNum))),
so a seeded zero-padded row '| 05 |' was not matched by 'phase complete 5' (pattern '| 5 |'),
producing a duplicate row that double-counted the phase. Canonicalize a numeric phase to
its integer form (Number('05')===Number('5')===5) and match with a 0* prefix so 5/05/005
all collapse to the same row in either direction. Regression folded into state.test.cjs:
seeded '| 05 |' + 'phase complete 5' yields exactly one phase-5 row. Non-numeric phase IDs
retain the literal escapeRegex match.
* chore(#1659): backfill changeset pr ref to 1663
* fix(#1659): add verification fixture to padded-dedup test under #1522 gate
* fix(#1582): derive phase-complete velocity from By-Phase table (idempotent)
updatePerformanceMetricsSection blind-added summaryCount onto the prior velocity
total on every phase complete, so re-running phase complete on an already-complete
phase incremented the total each time (the sibling of #4, which fixed the Completed
Phases counter the same way). The velocity total is now derived as the sum of the
By-Phase table's Plans column AFTER the row upsert — re-completing a phase upserts
the same row, so the sum is stable; a hand-edited inflated total self-heals downward
to the true sum on the next completion. When the By-Phase table is absent the total
is left unchanged (no crash). Strengthens the misnamed 'idempotent' test (its comment
explicitly declined to assert velocity idempotency — the latent gap) and adds a
self-heal regression; corrects the #320 behavior-lock velocity assertion which had
encoded the blind-add (3 = 1+2 double-count) — the derived value is 2.
* chore(#1582): backfill changeset pr ref to 1655
* fix(#1582): velocity sum tolerates indented By-Phase rows (codex review)
Adversarial review (codex, gpt-5.5/high) flagged that byPhaseTablePattern's
data-row capture allows leading whitespace ([ \t]*\|), but the derive sum was
anchored at ^\| and would skip indented hand-edited/legacy rows — capturing them
in the table but silently undercounting. Align the sum regex (^\s*\|) with the
table capture's tolerance. Adds an indented-row regression. Two other codex
findings are pre-existing and out of scope: padded/unpadded phase dedup
(phaseRowPattern, identical in old code — derive yields the same value as the old
blind-add) and CRLF tables (the shared byPhaseTablePattern header requires bare
\n, so the upsert was already broken on CRLF; the fix changes stale-vs-
double-count, does not worsen it).
* fix(#1582): add verification fixtures to velocity tests under #1522 gate
Post-rebase onto next+#1548, the #1582 velocity tests (self-heal, indented-row) use
phase complete, which now fail-closes under #1522's canonical verification gate without a
passed *-VERIFICATION.md. Add writePassedVerification(tmpDir,'02-next','02') to both.
Under #1522's verification gate (#1548), phase complete fail-closes without a passed
VERIFICATION.md, so the #1658 test's fixture needed one. The By-Phase *row* upsert on CRLF
has a separate downstream bug (phase complete updates velocity + status but doesn't persist
the table row on CRLF, while it does on LF; byPhaseTablePattern matches CRLF — verified
directly), tracked separately. Relax the assertion to phase-complete-succeeds + velocity-
updates, proving CRLF STATE.md is processed end-to-end.
* fix(#1658): make byPhaseTablePattern CRLF-tolerant on STATE.md tables
byPhaseTablePattern required a bare \n after the header and separator rows, so a
STATE.md with CRLF (\r\n) line endings (Windows, or hand-edited) had its By-Phase
table treated as absent: phase complete never upserted the row (and the velocity-from-
table derivation went stale). Make the header/separator terminators and the closing
lookahead CRLF-tolerant ([ \t]*\r?\n, (?=\r?\n|$)). Backward-compatible with LF.
Regression folded into tests/state.test.cjs: phase complete on a CRLF STATE.md upserts
the row and removes the placeholder. CONTRIBUTING's QA matrix lists Mixed CRLF/LF as a
required parser case.
* chore(#1658): backfill changeset pr ref to 1662
* fix(#1657): recover malformed (non-object) ~/.gsd/defaults.json in finishInstall
JSON.parse of defaults.json succeeds for valid-JSON-but-non-object values (null, [],
42, "str"), which then bypassed the parse catch: null threw a TypeError on property
access (swallowed by the outer try/catch), and array/number/string had resolve_model_ids
set on a non-object whose JSON.stringify round-trip kept the broken shape. The non-Claude
finishInstall step now resets any non-object (null, non-object, or array) parse result to
{} before reading/writing, so the file is repaired and resolve_model_ids defaults normally.
Regression folded into the owning tests/bug-410-install-defaults-test-mode-guard.test.cjs
(parameterized over null/[]/42/"str").
* chore(#1657): backfill changeset pr ref to 1661
* fix(#1660): fail-closed frontmatter set of object-list fields instead of silent no-op
cmdFrontmatterSet reported {updated:true} even when spliceFrontmatter returned the
content unchanged, which happened whenever the new value's extractFrontmatter projection
equalled the original's — notably for object-list fields like must_haves, whose
{path,provides} items flatten to scalar strings under the lossy parser. Detect a no-op
(newContent === content) for a dict-valued field and surface an error directing the user
to edit the file directly, instead of silently accepting a no-op set. Scalars and scalar
arrays round-trip faithfully, so idempotent sets of those are intentionally NOT flagged
(two precision regression tests lock this). Folded into frontmatter-cli.test.cjs.
* chore(#1660): backfill changeset pr ref to 1664
* refactor(#1660): extract noOpObjectListSetError as pure tested helper (Stryker coverage)
cmdFrontmatterSet is not in Stryker's property/unit test set, so the inline no-op
detection added survivors that dropped the frontmatter module below its 62% mutation
threshold. Extract the detection into a pure exported helper noOpObjectListSetError and
unit-test every branch directly (changed content, scalar, scalar-array, null, dict
no-op). cmdFrontmatterSet now calls the helper. Same pattern as the #1572 spliceFrontmatter
coverage fix.
* fix(#1572): preserve must_haves object-lists across frontmatter set/merge
spliceFrontmatter round-tripped the WHOLE frontmatter through extractFrontmatter
(a scalar-only parser) then reconstructFrontmatter (a lossy serializer), so any
must_haves object-list — artifacts {path, provides}, prohibitions {statement,
status} — was flattened to scalar strings and re-emitted as a malformed inline
array whenever an UNRELATED field changed, silently dropping every provides:/
status: value. The write now preserves the original raw text for any top-level
key whose value is structurally unchanged between the original parse and the new
object (generalizing the existing whole-document no-op guard to per-key
fidelity), and regenerates only the key that actually changed. The key set is
still defined by newObj (the cmdSet/cmdMerge flow always passes the full merged
object). spliceFrontmatter's only callers are cmdFrontmatterSet/Merge — the
STATE.md read-modify-write family calls reconstructFrontmatter directly and is
unaffected. Regression cases folded into tests/frontmatter-cli.test.cjs:
artifacts/prohibitions object-lists survive set and merge; idempotent on repeat
sets. Asserted via parseMustHavesBlock (the structure-preserving parser).
* chore(#1572): backfill changeset pr ref to 1656
* fix(#1572): fail-closed when set/merge would emit [object Object] (codex review)
Adversarial review (codex, gpt-5.5/high) flagged that directly setting a must_haves
object-list (a CHANGED key) still routed through the lossy reconstructFrontmatter,
emitting literal "[object Object]" and destroying the data. The reported case
(mutating an UNRELATED field) was already fixed by per-key raw-text preservation,
but the changed-object-list path was still silently lossy. Add fail-closed: when a
regenerated key's text contains the "[object Object]" sentinel, spliceFrontmatter
throws — cmdFrontmatterSet/Merge error out WITHOUT writing, directing the user to
edit the file directly. The no-frontmatter (generate-from-scratch) path is guarded
the same way. Adds a test that a refused set leaves the file unchanged and the
original object-list intact. Codex finding #2 (a contrived flattened-projection
no-op) is a deeper limitation noted in the PR — non-destructive, and the fail-closed
message already directs users to edit object-list blocks directly.
* test(#1572): add spliceFrontmatter per-key preservation + fail-closed unit coverage
Stryker mutates gsd-core/bin/lib/frontmatter.cjs against tests/frontmatter.{property,unit}.test.cjs
(MinScore 62). The #1572 regression cases live in frontmatter-cli.test.cjs, which is NOT in
Stryker's test set, so the new functions (sliceTopLevelFrontmatterSegments, the per-key
preserve/regenerate/drop/append loop, regenerateFrontmatterKey's [object Object] fail-closed)
had surviving mutants that dropped the module below threshold. Add unit-level coverage in
frontmatter.unit.test.cjs exercising every new branch directly via spliceFrontmatter:
unchanged object-list preserved (provides survives) when a scalar sibling changes; changed
scalar regenerates only that key; orphan keys dropped; new keys appended; indented nested
block stays attached to its parent key; whole-document no-op returns input verbatim; both
fail-closed paths (changed object-list + no-frontmatter) throw.
* fix(#1639): parseDecisions handles titled-colon bullet form
bulletColonRe anchors on ':**' (colon immediately before close-bold) and bulletEmDashRe
requires an em-dash, so the titled-colon form '- **D-NN: Title.** body' (title between the
colon and the closing **) matched neither and was dropped by the parse-miss guard. When all
decisions used the titled convention, parseDecisions returned 0 and check.decision-coverage-
plan passed vacuously — the same false-coverage failure mode as #1343/#1364/#1365. Add a
third per-form regex bulletTitledColonRe, checked LAST (strict superset of bulletColonRe,
so it only catches bullets the other two miss — minimal blast radius); id + [tags]
trackability honored. Regression folded into decisions.test.cjs: titled-colon parses,
coexists with colon/em-dash, tags, all-titled-13 no longer vacuously 0.
* fix(#1639): tighten titled-colon title to [^:*]* so malformed pre-colon-run bullets still reject
The first cut's title run [^*]* was too permissive: it matched a genuinely-malformed
bullet with a colon in the pre-separator freeform run (e.g. 'D-07 ratio 3:1:**') by
treating the 3:1 colon as the separator, regressing the #1343 parse-miss guard tests.
Tighten the title to [^:*]* (no colon, no star) so the separator colon remains the only
colon permitted before ** — matching bulletColonRe's existing [^:*]* discipline. Valid
titled forms (colon-free titles) still parse; the malformed colon-in-freeform case still
falls through to the parse-miss guard.
* chore(#1639): backfill changeset pr ref to 1665
* fix(#1569): preserve explicit resolve_model_ids in non-Claude installs
The non-Claude finishInstall step keyed its resolve_model_ids:"omit" write on
!== "omit", so an explicit true opt-in (resolveModelInternal returns full model
IDs) was silently clobbered on every install/upgrade across all 14 non-Claude
runtimes, making generated agent manifests inherit the active chat model instead
of pinning the resolved model. Now only absent/falsy is defaulted to "omit"; an
explicit true (and an existing "omit") is preserved. Regression test
parameterizes across codex/opencode/gemini and covers the absent/false/idempotent/
claude/malformed boundaries.
* chore(#1569): backfill changeset pr ref to 1653
* fix(#1569): default non-canonical resolve_model_ids values to omit (codex review)
Adversarial review (codex, gpt-5.5/high) flagged that the original allowlist-by-
enumeration condition (undefined/null/false -> omit) preserved malformed values
(0, "", "yes", {}) instead of defaulting them to omit, letting them leak Claude
aliases a non-Claude runtime cannot resolve. Switch to an allowlist condition
(existing !== true && existing !== 'omit') so only an explicit canonical true
opt-in and an existing omit are preserved; everything else defaults to the safe
non-Claude omit. Adds a parameterized test over [0, "", "yes", {}].