Merge branch 'next' into fix/1531-core-lock-liveness

This commit is contained in:
Tom Boucher
2026-06-22 11:56:04 -04:00
committed by GitHub
131 changed files with 4859 additions and 446 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 1537
---
**Non-Claude runtime installs now resolve their own runtime and never attempt Claude-only worktree isolation** — on any non-Claude install (Cursor, Gemini, Qwen, etc.) a runtime-neutral `.planning/config.json` previously resolved `runtime=claude` and enabled git worktree isolation, which only Claude Code's `isolation="worktree"` can honor — risking main-checkout edits while the workflow believed agents were isolated. Every non-Claude install now resolves its own runtime identity, defaults `workflow.use_worktrees` to `false`, fails closed if worktrees are forced on, and runs plan/execute inline in the manager/autonomous flows since only Codex can background-nest the pipeline's subagents. (#1521)

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 1536
---
adr-parser now classifies 9 previously-dropped punctuated ADR headers (Trade-offs, Non-Goals, Won't Do, Follow-up, How We'll Know, etc.) into their intended buckets instead of leaving them unmapped.

View File

@@ -0,0 +1,5 @@
---
type: Changed
pr: 1421
---
**`/gsd-review` now asks external reviewers to verify plan claims against the source** — the reviewer prompt requires opening the referenced files, citing `file:line` evidence + mechanism, and tracing asserted behavior, with a graceful-degradation clause for reviewers that have no file access. This turns every capable agentic reviewer into a real second source instead of a plan-text paraphraser. (#1318)

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 1534
---
Add prototype-pollution guard to the workstream/root config merge (_deepMergeConfig) so a config.json with a __proto__/constructor/prototype key can no longer spoof unset config flags.

View File

@@ -0,0 +1,6 @@
---
type: Fixed
pr: 667
---
**`/gsd:pr-branch` now handles sub-repos defined in config** — when `planning.sub_repos` is set, the command scans each sub-repo for uncommitted changes and offers to create a branch, commit, push, and open a companion PR per sub-repo. Previously, sub-repos were silently ignored because all git commands ran against the shell's current directory instead of the intended repo path. All sub-repo git operations now use `git -C <repo>` so no shell-state assumptions are made.

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 1519
---
**Codex installs no longer run with unsafe Claude-style worktree isolation** — a Codex install with a runtime-neutral `.planning/config.json` was resolving its runtime as Claude and enabling git worktree isolation, which Codex's `spawn_agent` cannot honor; the Codex fail-closed guard was also silently dead because runtime/worktree config was read JSON-quoted and broke shell equality checks. Codex-emitted workflows now resolve `runtime=codex`, default `workflow.use_worktrees` to `false`, and fail closed when worktrees are forced on. (#1515)

View File

@@ -0,0 +1,5 @@
---
type: Added
pr: 722
---
**`/gsd-capture --list-seeds` audits parked seeds** — a new read-only listing of `.planning/seeds/` showing each seed's ID, status, scope, and trigger, with an optional status filter (e.g. `--list-seeds dormant`). Backed by the `gsd-tools list-seeds` command. Previously seeds could only be created or auto-surfaced at `/gsd-new-milestone`, with no way to browse them on demand (#441).

View File

@@ -0,0 +1,5 @@
---
type: Changed
pr: 1518
---
**verify-phase test-tier prohibition fail-first can now prove the RED is caused by the violation's _content_** — the `node-test` machine-proof (#1279) confirmed a known-bad subject drives the negative test RED, but could not tell a genuine content-violation from a deceptive test that reds merely because `GSD_PROHIB_SUBJECT` is set. An optional fifth flat scalar `check_clean_fixture` (→ `CheckDescriptor.cleanFixture`) threads a KNOWN-CLEAN control subject through `projectProhibitions` + `descriptorFromProjection`; when present the prover also runs the check against it and requires GREEN, so fail-first is proven only when the check is RED on the violation **and** GREEN on the clean subject (content-dependent). It is opt-in and additive: absent a clean fixture the prover behaves exactly as it did post-#1314 (no control, documented residual), preserving the zero-authoring compose path; the lint-rule kind needs no analog. (#1346)

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 1574
---
**OpenCode and other AGENTS-native runtimes now get a root `AGENTS.md` from `/gsd:new-project`** — the workflow hardcoded a codex-only branch that sent every other runtime to `.claude/CLAUDE.md`, a location OpenCode never loads. A shared `getProjectInstructionFile(runtime)` policy (claude→`.claude/CLAUDE.md`, codex/opencode/kilo/kimi→`AGENTS.md`, copilot→`.github/copilot-instructions.md`, antigravity/gemini→`GEMINI.md`) is now the single source of truth consumed by both the new-project workflow and the generate-claude-md path, with a parity test guarding drift.

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 1539
---
`roadmap upgrade` now rejects an unsupported or malformed `--convention` value (including the `--convention=` form) instead of silently running the milestone-prefixed migration, and no longer hard-exits inside the command-routing hub.

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 1543
---
A failed `roadmap upgrade --apply` now actually rolls back .planning/ even when it is gitignored (commit_docs:false), instead of reporting a successful rollback while leaving the workspace half-migrated. Rollback is surgical and no longer runs a whole-repo git reset --hard.

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 1409
---
**Codex runtime no longer crashes on startup** — every `gsd-tools` command previously aborted with `Cannot find module '../../../package.json'` on Codex, whose runtime root has no `package.json`, because a module in the loader chain did a top-level require of it. The version emitted into Hermes skill frontmatter is now sourced lazily from the installed `gsd-core/VERSION` (validated semver), so `gsd-tools` loads on every runtime and never emits `version: undefined`. (#1383)

View File

@@ -0,0 +1,5 @@
---
type: Added
pr: 1448
---
Added a validated `gsd-tools worktree record-agent` writer verb that appends a per-agent entry to the wave cleanup manifest, validating every field at write time with the same rules the `cleanup-wave` reader enforces (write-strict `--agent-id`) and failing loudly with a recovery hint instead of silently appending an under-populated entry. The execute-phase orchestrator now records each spawned worktree through this verb. (#1448)

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 1552
---
roadmap analyze no longer reports phantom missing_phase_details for milestone-prefixed (M-NN) phase IDs

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 1541
---
Atomic file writes now retry a transient rename lock on Windows (a reader holding the target open) instead of falling back to a non-atomic write that could let a concurrent reader observe a truncated STATE.md/ROADMAP.md.

View File

@@ -1,7 +1,7 @@
{
"name": "gsd-core",
"displayName": "GSD Core",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"description": "GSD Core is a meta-prompting, context engineering, and spec-driven development system for AI coding agents.",
"author": {
"name": "open-gsd",

View File

@@ -101,7 +101,7 @@ Cross-seam principle (ADR-1411, epic #1411): context resolution — config loadi
Diagnostic-output convention for the Resolution Provenance principle (ADR-1411 P3, #1416). Config-interpreting read verbs expose `Resolution<T> { value, configured, reason, warnings }` (`src/resolution.cts`); agent-skills is the first adopter, where `value = { block, skills_count }` and `source`/`degraded` remain config-provenance extras outside the envelope. Other read verbs expose at least `warnings[]` (e.g. capability-state `{ runtimeConfigDir, capabilities, warnings? }`) without `configured`/`reason`, which are meaningful only for config-interpreting verbs. Mutation verbs expose `warnings[]` (advisory) PLUS `errors[]` (operation-not-applied), e.g. capability-writer `{ capabilities, warnings, errors }`. The shared seam across all shapes is `warnings: string[]`; a single generic `Resolution<T>` across read+write verbs was rejected by the deletion test (`configured`/`reason` are meaningless for capability verbs; `errors[]` cannot fold into `warnings[]`) — ADR-1411 P3 amendment. Recurrence prevention is delivered by P4's CI guard (a configured input resolving empty must carry a `reason`), not by a shared envelope. A CI guard (`scripts/lint-resolution-provenance.cjs`, wired into `lint:ci`) enforces that every registered config-interpreting read verb keeps a `configured_empty`/`not_configured` contract test; the registry in that script is the registration point for future verbs (ADR-1411 P4 / #1417).
### Worktree Safety Policy Module
CJS Module owning worktree lifecycle safety policy for the GSD orchestration layer. Interface: `resolveWorktreeContext(cwd, deps) → WorktreeContext` (linked-worktree root mapping), `parseWorktreePorcelain(output) → WorktreeEntry[]` (porcelain parser, skips detached HEAD), `planWorktreePrune(repoRoot, opts, deps) → PrunePlan` (metadata-prune plan, never destructive by default), `executeWorktreePrunePlan(plan, deps) → PruneResult` (executes prune; degrades gracefully on git timeout), `listLinkedWorktreePaths(repoRoot, deps) → LinkedPathsResult`, `inspectWorktreeHealth(repoRoot, opts, deps) → HealthResult` (orphan + stale detection), `snapshotWorktreeInventory(repoRoot, opts, deps) → InventoryResult`, `planWorktreeWaveCleanup(repoRoot, manifest) → CleanupPlan` (manifest-scoped, fail-closed), `executeWorktreeWaveCleanupPlan(plan, deps) → CleanupResult`. Source of truth: `gsd-core/bin/lib/worktree-safety.cjs`. Timeout path: all git subprocess calls are bounded; callers receive `ok:false, reason:'git_timed_out'` rather than a thrown exception. Test anchor: `tests/worktree-safety.test.cjs`. The `core.cjs` re-export spine was retired in epic #1267: this module absorbed the two thin compositional wrappers that squatted in Core — `resolveWorktreeRoot(cwd, deps)` (a projection over `resolveWorktreeContext`) and `pruneOrphanedWorktrees(...)` (sequences `planWorktreePrune` + `executeWorktreePrunePlan` with a timeout warning) — so callers reach this single worktree-lifecycle seam directly. `gitWorktreeInfoInternal` did NOT move here — worktree-info detection belongs to the Git Query Module.
CJS Module owning worktree lifecycle safety policy for the GSD orchestration layer. Interface: `resolveWorktreeContext(cwd, deps) → WorktreeContext` (linked-worktree root mapping), `parseWorktreePorcelain(output) → WorktreeEntry[]` (porcelain parser, skips detached HEAD), `planWorktreePrune(repoRoot, opts, deps) → PrunePlan` (metadata-prune plan, never destructive by default), `executeWorktreePrunePlan(plan, deps) → PruneResult` (executes prune; degrades gracefully on git timeout), `listLinkedWorktreePaths(repoRoot, deps) → LinkedPathsResult`, `inspectWorktreeHealth(repoRoot, opts, deps) → HealthResult` (orphan + stale detection), `snapshotWorktreeInventory(repoRoot, opts, deps) → InventoryResult`, `planWorktreeWaveCleanup(repoRoot, manifest) → CleanupPlan` (manifest-scoped, fail-closed), `executeWorktreeWaveCleanupPlan(plan, deps) → CleanupResult`, `planWorktreeRecordAgent(manifestRaw, fields) → RecordAgentPlan` (write-strict per-agent manifest append; validates each field at write time via the same `normalizeCleanupManifestEntry` rules the reader enforces; fail-closed on a missing/garbled field or a duplicate `(worktree_path, branch)` the reader would dedup away), `cmdWorktreeRecordAgent(cwd, args, deps) → RecordAgentCmdResult` (thin deps-injectable IO wrapper for the `worktree record-agent` verb). Source of truth: `gsd-core/bin/lib/worktree-safety.cjs`. Timeout path: all git subprocess calls are bounded; callers receive `ok:false, reason:'git_timed_out'` rather than a thrown exception. Test anchor: `tests/worktree-safety.test.cjs`. The `core.cjs` re-export spine was retired in epic #1267: this module absorbed the two thin compositional wrappers that squatted in Core — `resolveWorktreeRoot(cwd, deps)` (a projection over `resolveWorktreeContext`) and `pruneOrphanedWorktrees(...)` (sequences `planWorktreePrune` + `executeWorktreePrunePlan` with a timeout warning) — so callers reach this single worktree-lifecycle seam directly. `gitWorktreeInfoInternal` did NOT move here — worktree-info detection belongs to the Git Query Module.
### Worktree Lifecycle Module
Workflow contract seam covering agent worktree lifecycle orchestration rules. The `worktree_branch_check` block lives in one canonical fragment (`gsd-core/references/worktree-branch-check.md`) that `execute-phase.md`, `quick.md`, `diagnose-issues.md`, and `execute-plan.md` embed at dispatch. Key invariants: `worktree_branch_check` is **verify-only and fail-closed** — the orchestrator owns worktree lifecycle and base recovery, so the sub-agent holds no state-correction primitives; HEAD attachment verified via `git symbolic-ref`; positive allow-list `^worktree-agent-*` enforced; `git update-ref` on protected refs is prohibited; on base mismatch the sub-agent halts with `exit 42` and surfaces to the orchestrator (#48); the orchestrator runs a cwd-drift guard at `execute_waves` entry that resolves the worktree root and refuses drift into an agent worktree (#48); cleanup is manifest-scoped (`WAVE_WORKTREE_MANIFEST`) not global-discovery-based; worktree spawning is sequential (one `run_in_background` at a time to avoid `config.lock` contention). Test anchor: `tests/worktree.test.cjs`.
@@ -155,7 +155,10 @@ Module owning which skills and agents are written to runtime config directories
Module owning the per-runtime mapping from artifact kind to filesystem placement. ADR-3660 defines the typed `kinds` per runtime (`commands`, `agents`, `skills`) with destination subpath, prefix, and stage adapter (with per-runtime converters in `bin/install.js`: `convertClaudeCommandToClaudeSkill`, `…CodexSkill`, `…CopilotSkill`, `…AntigravitySkill`). Owns the per-runtime `nested` skill-bundle decision (#69): a `skillsKind` flag in `src/runtime-artifact-layout.cts` drives whether a runtime receives the nested router layout (6 `gsd-ns-*` routers + concrete skills under `<router>/skills/<name>/`) or the flat `skills/gsd-<stem>/` layout; the evidence/doc-link matrix is recorded in a comment above `resolveRuntimeArtifactLayout`. Phase 1 applies this seam to the Runtime Surface Module (`surface.cjs:applySurface`); as of #813, `applySurface` applies the same per-runtime skill-body path rewrites as `installRuntimeArtifacts` for `skills` kinds — re-surfacing no longer overwrites installed SKILL.md bodies with converter-default `~/.claude` paths. Per ADR-1508 / #1511 the former `getInstallExports`/`loadInstallExports` relay (a `GSD_TEST_MODE`-guarded `require('bin/install.js')` by which `surface.cjs` reached `computePathPrefix`/`applyRuntimeContentRewritesInPlace`) was DELETED from this module; content rewriting now lives in the Runtime Artifact Conversion Module and `surface.cjs:applySurface` calls its `rewriteStagedSkillBodies` directly. The resolved `scope` is still carried on the `Layout` object so `applySurface` derives the same `pathPrefix` (global `$HOME` form vs. absolute) as a fresh install. Phase 2 is planned to migrate install/uninstall in `bin/install.js` so all lifecycle sites iterate one shared layout table instead of re-encoding runtime layout logic. This design is intended to remove the #3659 class of omissions. Migrations remain under the Installer Migration Module (ADR-0008). See ADR-3660.
### Runtime Artifact Conversion Module
Sibling Module to Runtime Artifact Layout Module. Owns projection from canonical Claude-authored command/agent/skill markdown into runtime-specific artifact bodies, including converter selection, frontmatter/body normalization, runtime path rewrites, and staged artifact generation. Runtime Artifact Layout remains responsible for filesystem placement (`kind`, destination subpath, prefix, nesting); Runtime Artifact Conversion owns the content Implementation behind that placement seam so install, uninstall/surface parity, and future plugin/package projections stop reaching back through `bin/install.js` for converter functions or `GSD_TEST_MODE`-guarded installer exports. Chosen direction: sibling Module, not an expanded Layout Module, to preserve ADR-3660's narrow placement responsibility while deepening artifact content locality. First slice: relocate only the layout-reached conversion family (`convertClaudeCommandTo*Skill`, converted command-file emitters, `buildKimiAgentArtifacts`) plus the minimal helper closure they need; do not leave helper dependencies in `bin/install.js` because that would preserve the same shallow seam under a new filename. Installer integration decision: `bin/install.js` imports the conversion Module at top level and re-exports the moved names for compatibility; the conversion Module must not import `bin/install.js` or Runtime Artifact Layout, so the dependency direction becomes installer/layout Adapters -> conversion Module, never conversion -> installer. First-slice Interface decision: export the existing compatibility names only; do not introduce a grouped `convertRuntimeArtifact` Interface until after relocation proves byte-for-byte behavior. SHIPPED (ADR-1508): the converter family relocated in #1510 Phase 1 (`getDirName`→runtime-name-policy, `processAttribution` here); #1511 Phase 2 moved the content-rewrite engine here in full — `_applyRuntimeRewrites` (per-runtime switch, injected attribution), the staged-content walkers `applyRuntimeContentRewritesInPlace`/`applyRuntimeContentRewritesForCommandsInPlace`, `computePathPrefix` (private; `_computePathPrefix` for tests), and the deep public seam `rewriteStagedSkillBodies`/`rewriteStagedCommandBodies({runtime,configDir,scope,homedir?,platform?,resolveAttribution?})`. `bin/install.js` binds these back (single owner, exports preserved); `getCommitAttribution` stays in `bin/install.js` (impure install-time config I/O) and is injected. The `getInstallExports` relay in Runtime Artifact Layout Module was deleted; the dependency direction installer/layout → conversion (never upward) is now enforced. Source: `gsd-core/bin/lib/runtime-artifact-conversion.cjs` (generated from `src/runtime-artifact-conversion.cts`).
Sibling Module to Runtime Artifact Layout Module. Owns projection from canonical Claude-authored command/agent/skill markdown into runtime-specific artifact bodies, including converter selection, frontmatter/body normalization, runtime path rewrites, and staged artifact generation. Runtime Artifact Layout remains responsible for filesystem placement (`kind`, destination subpath, prefix, nesting); Runtime Artifact Conversion owns the content Implementation behind that placement seam so install, uninstall/surface parity, and future plugin/package projections stop reaching back through `bin/install.js` for converter functions or `GSD_TEST_MODE`-guarded installer exports. Chosen direction: sibling Module, not an expanded Layout Module, to preserve ADR-3660's narrow placement responsibility while deepening artifact content locality. First slice: relocate only the layout-reached conversion family (`convertClaudeCommandTo*Skill`, converted command-file emitters, `buildKimiAgentArtifacts`) plus the minimal helper closure they need; do not leave helper dependencies in `bin/install.js` because that would preserve the same shallow seam under a new filename. Installer integration decision: `bin/install.js` imports the conversion Module at top level and re-exports the moved names for compatibility; the conversion Module must not import `bin/install.js` or Runtime Artifact Layout, so the dependency direction becomes installer/layout Adapters -> conversion Module, never conversion -> installer. First-slice Interface decision: export the existing compatibility names only; do not introduce a grouped `convertRuntimeArtifact` Interface until after relocation proves byte-for-byte behavior. SHIPPED (ADR-1508): the converter family relocated in #1510 Phase 1 (`getDirName`→runtime-name-policy, `processAttribution` here); #1511 Phase 2 moved the content-rewrite engine here in full — `_applyRuntimeRewrites` (per-runtime switch, injected attribution), the staged-content walkers `applyRuntimeContentRewritesInPlace`/`applyRuntimeContentRewritesForCommandsInPlace`, `computePathPrefix` (private; `_computePathPrefix` for tests), and the deep public seam `rewriteStagedSkillBodies`/`rewriteStagedCommandBodies({runtime,configDir,scope,homedir?,platform?,resolveAttribution?})`. `bin/install.js` binds these back (single owner, exports preserved); `getCommitAttribution` stays in `bin/install.js` (impure install-time config I/O) and is injected. The `getInstallExports` relay in Runtime Artifact Layout Module was deleted; the dependency direction installer/layout → conversion (never upward) is now enforced. Exception: opencode and kilo path-prefix rewriting is a deliberate `bin/install.js`-owned pre-conversion step (`applyOpencodeFamilyPathPrefix`) per #784, not a violation of the single-owner rule. Source: `gsd-core/bin/lib/runtime-artifact-conversion.cjs` (generated from `src/runtime-artifact-conversion.cts`). Also exports `resolveVersionFrom(libDir)` — a lazy, defensive GSD-version resolver (installed-tree `gsd-core/VERSION` first, then the source/npm `package.json` three dirs up, both validated against the repo's shared semver-prefix shape, degrading to `''` on failure) that replaced a module-load-time `require('../../../package.json')` which crashed on runtimes whose root carries no `package.json` (e.g. Codex) (#1383).
### Runtime Artifact Install Plan Module
Module owning install-time staging and content-rewrite selection for a pre-resolved Runtime Artifact Layout. Interface: `createRuntimeArtifactInstallPlan({ layout, resolvedProfile, homedir?, platform?, resolveAttribution?, deps? }) -> { ok:true, plan:{ items, cleanupDirs } } | { ok:false, kind:'stage_failed'|'rewrite_failed', message, cleanupDirs, failedKind? }`. It iterates `layout.kinds` in order, calls each kind's `stage(resolvedProfile)`, delegates `commands` to Runtime Artifact Conversion `rewriteStagedCommandBodies`, delegates `skills` and `kimi-agents` to `rewriteStagedSkillBodies`, leaves non-rewritten kinds unchanged, and projects copy items as `{ kind, sourceDir, destDir }`. It deliberately does not prune, copy, run legacy migrations, print output, or execute cleanup; those remain Installer Module adapter responsibilities until later slices wire the plan into `bin/install.js`. Source: `gsd-core/bin/lib/runtime-artifact-install-plan.cjs` (generated from `src/runtime-artifact-install-plan.cts`). See Runtime Artifact Layout Module and Runtime Artifact Conversion Module.
### Command Roster Module
Tiny read-only helper Module owning discovery of canonical `commands/gsd/*.md` command stems for artifact conversion and runtime projection. It is a sibling dependency of Runtime Artifact Conversion Module, not part of conversion itself: conversion consumes a roster to safely rewrite `gsd:` / `/gsd-` references, while roster discovery owns filesystem/catalog knowledge. First slice: extract existing `readGsdCommandNames` behavior behind this Module instead of moving it into Runtime Artifact Conversion Module or keeping it as installer-owned state.
@@ -424,7 +427,7 @@ A legal deferred state of an Execute step (`external_job_waiting`): the executor
`WORKTREE.SEAM.current=Worktree Safety Policy Module`
`WORKTREE.SEAM.files=[gsd-core/bin/lib/worktree-safety.cjs]`
`WORKTREE.SEAM.interface=[resolveWorktreeContext, parseWorktreePorcelain, planWorktreePrune, executeWorktreePrunePlan]`
`WORKTREE.SEAM.interface=[resolveWorktreeContext, parseWorktreePorcelain, planWorktreePrune, executeWorktreePrunePlan, planWorktreeRecordAgent, cmdWorktreeRecordAgent]`
`WORKTREE.SEAM.default-prune-policy=metadata_prune_only (non-destructive)`
`WORKTREE.SEAM.decision-1=retain non-destructive default; destructive path only as explicit future opt-in scaffold`

View File

@@ -834,7 +834,7 @@ Defensive normalization at trust boundaries must validate both the value's type
- **CommonJS** (`.cjs`) — the project uses `require()`, not ESM `import`
- **No external dependencies in core** — `gsd-tools.cjs` and all lib files use only Node.js built-ins
- **Conventional commits** — `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `ci:`
- **Conventional commits** — `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `ci:`. The full grammar is `<type>(<scope>): <subject>` (enforced by `hooks/gsd-validate-commit.sh`; subject ≤72 chars, lowercase, imperative mood, no trailing period). When the work resolves a tracked issue, put the issue number in the scope: `fix(#1520): randomize mktemp temp paths on BSD/macOS`. The same convention applies to PR titles — release notes are grouped by the title's type prefix (`feat` → Feature, `fix` → Fix, everything else → Enhancement).
## File Structure

View File

@@ -363,6 +363,10 @@ const {
const {
resolveRuntimeArtifactLayout,
} = require(path.join(_gsdLibDir, 'runtime-artifact-layout.cjs'));
const {
createRuntimeArtifactInstallPlan,
createRuntimeArtifactUninstallPlan,
} = require(path.join(_gsdLibDir, 'runtime-artifact-install-plan.cjs'));
const {
planLegacyCleanup,
applyLegacyCleanup,
@@ -6698,6 +6702,7 @@ function migrateLegacyDevPreferencesToSkill(targetDir, saved, runtime, scope = '
// reference-identical to the conversion module (consistent with the walkers above).
// All call sites are below this line → no TDZ hazard.
const _applyRuntimeRewrites = runtimeArtifactConversion._applyRuntimeRewrites;
const _stampNonClaudeRuntimeDefaults = runtimeArtifactConversion._stampNonClaudeRuntimeDefaults;
/**
* Copy a staged directory's contents into destDir.
@@ -7007,36 +7012,25 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile) {
_runLegacyInstallMigrations(runtime, configDir, scope);
const layout = resolveRuntimeArtifactLayout(runtime, configDir, scope);
// Compute pathPrefix once for the rewrite step (same derivation as the
// top-level install() function).
const _resolvedTarget = path.resolve(configDir).replace(/\\/g, '/');
const _homeDir = os.homedir().replace(/\\/g, '/');
const pathPrefix = computePathPrefix({
isGlobal: scope === 'global',
isOpencode: runtime === 'opencode',
isWindowsHost: process.platform === 'win32',
resolvedTarget: _resolvedTarget,
homeDir: _homeDir,
const planResult = createRuntimeArtifactInstallPlan({
layout,
resolvedProfile,
homedir: () => os.homedir(),
platform: process.platform,
resolveAttribution: getCommitAttribution,
});
for (const kind of layout.kinds) {
const staged = kind.stage(resolvedProfile);
// stagedForCopy: the directory to copy from (may differ from staged if rewrites
// produce a temp copy — see applyRuntimeContentRewritesForCommandsInPlace).
let stagedForCopy = staged;
const isGlobal = scope === 'global';
if (kind.kind === 'skills' || kind.kind === 'kimi-agents') {
applyRuntimeContentRewritesInPlace(staged, runtime, pathPrefix, isGlobal, getCommitAttribution(runtime));
} else if (kind.kind === 'commands') {
// Returns a temp dir with rewritten content so source files are never mutated.
stagedForCopy = applyRuntimeContentRewritesForCommandsInPlace(staged, runtime, pathPrefix, isGlobal, getCommitAttribution(runtime));
const cleanupDirs = planResult.ok ? planResult.plan.cleanupDirs : planResult.cleanupDirs;
try {
if (!planResult.ok) {
throw new Error(planResult.message);
}
// applyRuntimeContentRewritesForCommandsInPlace() returns a fresh mkdtemp dir under
// os.tmpdir() (gsd-cmd-rewrites-*); remove it once copied so it does not accumulate (#856).
const tempToClean = stagedForCopy !== staged ? stagedForCopy : null;
try {
const dest = path.join(layout.configDir, kind.destSubpath);
const kindsByName = new Map(layout.kinds.map((kind) => [kind.kind, kind]));
for (const item of planResult.plan.items) {
const kind = kindsByName.get(item.kind);
if (!kind) throw new Error(`Install plan returned unknown artifact kind: ${item.kind}`);
const dest = item.destDir;
fs.mkdirSync(dest, { recursive: true });
if (kind.kind === 'skills' && fs.existsSync(dest)) {
// Pre-prune: snapshot user-owned content before _removeGsdEntries wipes it,
@@ -7063,7 +7057,7 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile) {
}
_removeGsdEntries(dest, kind);
_copyStaged(stagedForCopy, dest, kind);
_copyStaged(item.sourceDir, dest, kind);
// Restore user-owned dirs after the prune+copy
for (const [dirName, snap] of toPreserve) {
@@ -7073,13 +7067,13 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile) {
// For non-skills kinds (commands, agents): no user content to preserve;
// just prune stale gsd-* entries and copy new ones.
_removeGsdEntries(dest, kind);
_copyStaged(stagedForCopy, dest, kind);
}
} finally {
if (tempToClean) {
try { fs.rmSync(tempToClean, { recursive: true, force: true }); } catch { /* best-effort */ }
_copyStaged(item.sourceDir, dest, kind);
}
}
} finally {
for (const dir of cleanupDirs) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort */ }
}
}
// Hermes: after the install loop has written all gsd-<stem>/ dirs to
@@ -7196,9 +7190,14 @@ function uninstallRuntimeArtifacts(runtime, configDir, scope) {
const savedLegacyArtifacts = _runLegacyUninstallCleanup(runtime, configDir, scope);
const layout = resolveRuntimeArtifactLayout(runtime, configDir, scope);
for (const kind of layout.kinds) {
const dest = path.join(layout.configDir, kind.destSubpath);
_removeGsdEntries(dest, kind);
const plan = createRuntimeArtifactUninstallPlan(layout);
const kindsByName = new Map(layout.kinds.map((kind) => [kind.kind, kind]));
for (const item of plan.items) {
const kind = kindsByName.get(item.kind);
if (!kind) {
throw new Error(`Runtime artifact uninstall plan referenced unknown kind: ${item.kind}`);
}
_removeGsdEntries(item.destDir, kind);
}
// Hermes: after removing gsd-* skill dirs from skills/gsd/, also remove
@@ -7289,6 +7288,15 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand
}
content = processAttribution(content, getCommitAttribution(runtime));
// #1521: stamp the workflow runtime-resolution block so every non-Claude
// install resolves its own runtime identity and defaults use_worktrees=false.
// copyWithPathReplacement is the emit path for gsd-core/workflows/*.md;
// _applyRuntimeRewrites is NOT invoked here, so this is what makes the fix
// live in real installs (it is a no-op for files without those lines).
if (runtime !== 'claude') {
content = _stampNonClaudeRuntimeDefaults(content, runtime);
}
// #3683 — normalize /gsd:<cmd> → /gsd-<cmd> in any body passing through
// copyWithPathReplacement for runtimes that register commands under the
// hyphen form; normalizeAgentBodyForRuntime self-gates on
@@ -12040,7 +12048,10 @@ module.exports = {
// #1191 — exported so tests exercise the REAL readSettings, not a replica
readSettings,
stripJsonComments,
...runtimeArtifactConversion,
// Compatibility relays retained after auditing the former broad
// runtimeArtifactConversion spread (#1559).
processAttribution,
applyRuntimeContentRewritesForCommandsInPlace,
};
// Main logic — only run when not loaded as a module for testing

View File

@@ -1,7 +1,7 @@
{
"id": "ai-integration",
"role": "feature",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "AI design contract",
"description": "AI-SPEC design contract workflow for phases that build AI systems; owns the AI integration command, agents, and workflow.ai_integration_phase activation key.",
"tier": "full",

View File

@@ -1,7 +1,7 @@
{
"id": "antigravity",
"role": "runtime",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Antigravity",
"description": "Google Antigravity IDE — nested under ~/.gemini/antigravity; probed across 1.x and 2.x layouts; Gemini hook event dialect; nested skill layout; tier-1 support.",
"tier": "core",

View File

@@ -1,7 +1,7 @@
{
"id": "audit",
"role": "feature",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Audit",
"description": "Open-artifact audit and UAT-gap audit for milestone close gates; exposes `gsd-tools audit-uat` (cross-phase UAT outstanding items) and `gsd-tools audit-open` (structured open-artifact scan across debug, tasks, threads, todos, seeds, UAT, verification, context-questions).",
"tier": "full",

View File

@@ -1,7 +1,7 @@
{
"id": "augment",
"role": "runtime",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Augment Code",
"description": "Augment Code CLI — commands + nested-skill artifact layout; settings-json hook surface; Claude hook event dialect; tier-2 support.",
"tier": "core",

View File

@@ -1,7 +1,7 @@
{
"id": "claude",
"role": "runtime",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Claude Code",
"description": "Anthropic Claude Code — primary development runtime; tier-1 support with full hook surface and skills-based global install.",
"tier": "core",

View File

@@ -1,7 +1,7 @@
{
"id": "cline",
"role": "runtime",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Cline",
"description": "Cline (VS Code extension) — global-only nested-skill layout; cline-rules hook surface (.clinerules); no hook events emitted; tier-2 support.",
"tier": "core",

View File

@@ -1,7 +1,7 @@
{
"id": "code-review",
"role": "feature",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Code review",
"description": "Source-file code review and review-fix workflow support for completed execution work.",
"tier": "full",

View File

@@ -1,7 +1,7 @@
{
"id": "codebuddy",
"role": "runtime",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "CodeBuddy",
"description": "CodeBuddy (Tencent) — converted commands + skills artifact layout; settings-json hook surface; Claude hook event dialect; tier-2 support.",
"tier": "core",

View File

@@ -1,7 +1,7 @@
{
"id": "codex",
"role": "runtime",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "OpenAI Codex CLI",
"description": "OpenAI Codex CLI — shell-var command style; per-agent sandbox tiers; config.toml + hooks.json hook surface; tier-1 support.",
"tier": "core",

View File

@@ -1,7 +1,7 @@
{
"id": "copilot",
"role": "runtime",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "GitHub Copilot",
"description": "GitHub Copilot (VS Code) — markdown config format; copilot-inline hook surface; no hook events emitted; flat skill nesting (unconfirmed recursive loader); tier-2 support.",
"tier": "core",

View File

@@ -1,7 +1,7 @@
{
"id": "cursor",
"role": "runtime",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Cursor",
"description": "Cursor IDE — skills + converted commands artifact layout; hooks.json surface; Claude hook event dialect; recursive skill loader (flat nesting); tier-2 support.",
"tier": "core",

View File

@@ -1,7 +1,7 @@
{
"id": "drift",
"role": "feature",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Drift detection gates",
"description": "Post-execution drift detection gates that run after each wave completes. Provides two gates at execute:wave:post: a blocking schema drift gate (detects schema files changed without a database push) and a non-blocking codebase drift gate (detects structural additions not reflected in STRUCTURE.md).",
"tier": "full",

View File

@@ -1,7 +1,7 @@
{
"id": "gap-analysis",
"role": "feature",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Post-planning gap analysis",
"description": "Proactive, non-blocking post-planning coverage report. After all PLAN.md files are generated, cross-references every REQ-ID and D-ID from REQUIREMENTS.md and CONTEXT.md against plan bodies. Emits a Source | Item | Status table. Does not block phase advancement.",
"tier": "standard",

View File

@@ -1,7 +1,7 @@
{
"id": "gemini",
"role": "runtime",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Gemini CLI",
"description": "Google Gemini CLI — commands-only artifact layout (TOML); Gemini hook event dialect; settings-json hook surface; tier-2 support.",
"tier": "core",

View File

@@ -1,7 +1,7 @@
{
"id": "graphify",
"role": "feature",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Knowledge graph",
"description": "Build, query, and inspect the project knowledge graph in `.planning/graphs/`; exposes graphify CLI subcommands (build, query, status, diff) and the /gsd-graphify skill.",
"tier": "full",

View File

@@ -1,7 +1,7 @@
{
"id": "hermes",
"role": "runtime",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Hermes Agent",
"description": "Hermes Agent (NousResearch) — skills nest under skills/gsd/ category bucket; nested skill layout; settings-json hook surface; Claude hook event dialect; tier-2 support.",
"tier": "core",

View File

@@ -1,7 +1,7 @@
{
"id": "intel",
"role": "feature",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Codebase intelligence",
"description": "Code-intelligence store for codebase querying, diff, snapshot, and API-surface extraction; exposes `gsd-tools intel` subcommands (query, status, update, diff, snapshot, patch-meta, validate, extract-exports, api-surface) and backs `/gsd-map-codebase` and `gsd-intel-updater`.",
"tier": "full",

View File

@@ -1,7 +1,7 @@
{
"id": "kilo",
"role": "runtime",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Kilo Code",
"description": "Kilo Code — XDG-based config dir; global skills at ~/.kilo/skills (separate from XDG config); flat command/ + skills artifact layout; no lifecycle hook registration; tier-2 support.",
"tier": "core",

View File

@@ -1,7 +1,7 @@
{
"id": "kimi",
"role": "runtime",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Kimi CLI",
"description": "Kimi CLI (Moonshot AI) — generic agents root at ~/.config/agents; skills + kimi-agents artifact layout; no hook surface; no hook events; tier-2 support.",
"tier": "core",

View File

@@ -1,7 +1,7 @@
{
"id": "mempalace",
"role": "feature",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "MemPalace memory",
"description": "Cross-session, cross-project memory: deliberate recall before discuss/plan and verbatim capture + temporal-KG sync at phase boundaries, via the MemPalace MCP server and CLI.",
"tier": "full",

View File

@@ -1,7 +1,7 @@
{
"id": "nyquist",
"role": "feature",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Nyquist validation",
"description": "Validation coverage audit that maps executed work back to tests and manual-only evidence.",
"tier": "full",

View File

@@ -1,7 +1,7 @@
{
"id": "opencode",
"role": "runtime",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "OpenCode",
"description": "OpenCode — XDG-based config dir; flat command/ + skills artifact layout; settings-json config format; no lifecycle hook registration; tier-2 support.",
"tier": "core",

View File

@@ -1,7 +1,7 @@
{
"id": "pattern-mapper",
"role": "feature",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Pattern mapping",
"description": "Optional codebase-pattern mapping before planning; owns the pattern mapper agent and workflow.pattern_mapper activation key.",
"tier": "full",

View File

@@ -1,7 +1,7 @@
{
"id": "profile-pipeline",
"role": "feature",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Developer profiling pipeline",
"description": "Developer behavioral profiling from Claude Code session history; scans session JSONL files, extracts and samples user messages, and generates profile artifacts (USER-PROFILE.md, dev-preferences.md, CLAUDE.md sections). Exposes eight `gsd-tools` commands: scan-sessions, extract-messages, profile-sample (pipeline phase) and write-profile, profile-questionnaire, generate-dev-preferences, generate-claude-profile, generate-claude-md (output phase). Backs the /gsd-profile-user skill and gsd-user-profiler agent.",
"tier": "full",

View File

@@ -1,7 +1,7 @@
{
"id": "qwen",
"role": "runtime",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Qwen Code",
"description": "Qwen Code (Alibaba) — nested-skill artifact layout; settings-json hook surface; Claude hook event dialect; tier-2 support.",
"tier": "core",

View File

@@ -1,7 +1,7 @@
{
"id": "research",
"role": "feature",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Phase research",
"description": "Optional phase research before planning; owns the phase researcher agent and workflow.research activation key.",
"tier": "standard",

View File

@@ -1,7 +1,7 @@
{
"id": "schema-gate",
"role": "feature",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Schema push detection gate",
"description": "Detects ORM schema-relevant files in the phase scope during planning and injects a mandatory [BLOCKING] schema push task into the plan. Prevents false-positive verification where build/types pass because TypeScript types come from config, not the live database.",
"tier": "full",

View File

@@ -1,7 +1,7 @@
{
"id": "security",
"role": "feature",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Security enforcement",
"description": "Threat mitigation verification and ship-time security blocking for phases with security enforcement enabled.",
"tier": "full",

View File

@@ -1,7 +1,7 @@
{
"id": "tdd",
"role": "feature",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Test-driven development",
"description": "Injects TDD heuristics into the planner and enforces RED/GREEN gate compliance on type:tdd plans after execution. Owns workflow.tdd_mode; the --tdd CLI flag is the ephemeral override.",
"tier": "full",

View File

@@ -1,7 +1,7 @@
{
"id": "trae",
"role": "runtime",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Trae IDE",
"description": "Trae IDE — nested-skill artifact layout; no hook surface (profile-marker-only config); tier-2 support.",
"tier": "core",

View File

@@ -1,7 +1,7 @@
{
"id": "ui",
"role": "feature",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "UI design contracts",
"description": "UI-SPEC design contract + retrospective UI audit for frontend phases.",
"tier": "full",

View File

@@ -1,7 +1,7 @@
{
"id": "windsurf",
"role": "runtime",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Windsurf",
"description": "Windsurf (Codeium) — nested under ~/.codeium/windsurf; skills-only artifact layout; no hook surface; no hook events; tier-2 support.",
"tier": "core",

View File

@@ -1,7 +1,7 @@
---
name: gsd:capture
description: Capture ideas, tasks, notes, and seeds to their destination
argument-hint: "[--note | --backlog | --seed | --list] [text]"
argument-hint: "[--note | --backlog | --seed | --list | --list-seeds] [text]"
allowed-tools:
- Read
- Write
@@ -21,6 +21,7 @@ Mode routing:
- **--backlog**: Add an idea to the backlog parking lot (999.x numbering) → add-backlog workflow
- **--seed**: Capture a forward-looking idea with trigger conditions → plant-seed workflow
- **--list**: List pending todos and select one to work on → check-todos workflow
- **--list-seeds**: List/audit captured seeds (optional status filter) → list-seeds workflow
</objective>
<routing>
@@ -32,6 +33,7 @@ Mode routing:
| --backlog | ROADMAP.md backlog section (999.x) | add-backlog |
| --seed | .planning/seeds/SEED-NNN-slug.md | plant-seed |
| --list | Interactive todo browser + action router | check-todos |
| --list-seeds | Read-only seed list/audit (optional status filter) | list-seeds |
</routing>
@@ -41,6 +43,7 @@ Mode routing:
@~/.claude/gsd-core/workflows/add-backlog.md
@~/.claude/gsd-core/workflows/plant-seed.md
@~/.claude/gsd-core/workflows/check-todos.md
@~/.claude/gsd-core/workflows/list-seeds.md
@~/.claude/gsd-core/references/ui-brand.md
</execution_context>
@@ -51,6 +54,7 @@ Parse the first token of $ARGUMENTS:
- If it is `--note`: strip the flag, pass remainder to note workflow
- If it is `--backlog`: strip the flag, pass remainder to add-backlog workflow
- If it is `--seed`: strip the flag, pass remainder to plant-seed workflow
- If it is `--list-seeds`: strip the flag, pass remainder (optional status filter) to list-seeds workflow
- If it is `--list`: pass remainder (optional area filter) to check-todos workflow
- Otherwise: pass all of $ARGUMENTS to add-todo workflow
</context>

View File

@@ -477,6 +477,9 @@ node gsd-tools.cjs current-timestamp [full|date|filename]
# Count and list pending todos
node gsd-tools.cjs list-todos [area]
# List captured seeds (optionally filter by status: dormant|active|triggered)
node gsd-tools.cjs list-seeds [status]
# Check file/directory existence
node gsd-tools.cjs verify-path-exists <path>
@@ -546,6 +549,20 @@ node gsd-tools.cjs worktree set-baseref
**`worktree set-baseref`** applies a no-clobber write of `worktree.baseRef:"head"` to `.claude/settings.local.json`. If the file already contains an explicit `baseRef` value other than `"head"`, the existing value is preserved and `skipped:"explicit-other"` is returned. Malformed JSON causes an error rather than a silent overwrite. Both fresh installs and upgrades of GSD Core run this automatically when `workflow.use_worktrees` is enabled (the default); the command is also available for manual use — for example, to apply the setting when worktrees were toggled on after installation, or to re-apply it after a settings change.
### Wave-manifest recording
The execute-phase orchestrator records each spawned executor's worktree identity into a wave cleanup manifest so the matching `cleanup-wave` reader can later merge and remove exactly those worktrees.
```bash
# Append a validated per-agent entry to the wave cleanup manifest.
# Returns JSON: { ok, reason, entry, manifest_path } (exit 0), or
# { ok:false, reason, hint } with a non-zero exit on a rejected entry.
node gsd-tools.cjs worktree record-agent \
--manifest <path> --agent-id <id> --path <worktree> --branch <branch> --base <sha>
```
**`worktree record-agent`** appends one `{agent_id, worktree_path, branch, expected_base}` entry to an already-initialized manifest, validating every field **at write time using the same rules the `cleanup-wave` reader enforces** — `--branch` must match the disposable `^worktree-agent-[A-Za-z0-9._/-]+$` namespace, and `--path`/`--branch`/`--base` must be non-empty. `--agent-id` is required (write-strict), even though the reader treats it as optional. A missing or garbled field — or a duplicate `(worktree_path, branch)` the reader would dedup away — fails loudly with a recovery hint and a non-zero exit **without** writing, instead of appending an under-populated or silently-dropped entry. Whitespace-only `--path`/`--base` are rejected (values are trimmed). The on-disk manifest shape is unchanged (the reader re-derives `allowed_bases`); the orchestrator still initializes the empty `{orchestrator_root, worktrees: []}` shell inline before any agent is recorded.
---
## Graphify

View File

@@ -1370,6 +1370,8 @@ Execute a trivial task inline — no subagents, no planning overhead. For typo f
Cross-AI peer review of phase plans from external AI CLIs.
Reviewers are prompted to verify the plan's claims against the actual repository source — opening the referenced files and citing `file:line` evidence with the mechanism — rather than reviewing the plan text in isolation. A reviewer that has no file access flags what it cannot verify instead of asserting it, and `file:line`-grounded findings are weighted more heavily during consensus synthesis.
| Argument | Required | Description |
|----------|----------|-------------|
| `--phase N` | **Yes** | Phase number to review |
@@ -1485,10 +1487,11 @@ Capture ideas, tasks, notes, and seeds to their appropriate destination. Default
| `--backlog <description>` | Add to the backlog parking lot using 999.x numbering |
| `--seed [idea summary]` | Capture a forward-looking idea with trigger conditions |
| `--list` | List pending todos and select one to work on |
| `--list-seeds [status]` | List/audit captured seeds, optionally filtered by status (read-only) |
| `--global` | Use global scope (for note operations) |
**Backlog:** 999.x numbering keeps items outside the active phase sequence; phase directories are created immediately so `/gsd-discuss-phase` and `/gsd-plan-phase` work on them.
**Seeds:** Preserve full WHY, WHEN to surface, and breadcrumbs — consumed by `/gsd-new-milestone`.
**Seeds:** Preserve full WHY, WHEN to surface, and breadcrumbs — consumed by `/gsd-new-milestone`. Audit parked seeds anytime with `--list-seeds` (optionally `--list-seeds dormant`).
**Produces:** `.planning/todos/` (default), note files (--note), ROADMAP.md backlog section (--backlog), `.planning/seeds/SEED-NNN-slug.md` (--seed)
@@ -1500,6 +1503,8 @@ Capture ideas, tasks, notes, and seeds to their appropriate destination. Default
/gsd-capture --backlog "GraphQL API layer" # Add to backlog
/gsd-capture --seed "Add real-time collaboration when WebSocket infra is in place"
/gsd-capture --list # Browse and act on todos
/gsd-capture --list-seeds # Audit all captured seeds
/gsd-capture --list-seeds dormant # Filter seeds by status
```
---

View File

@@ -248,7 +248,7 @@ All workflow toggles follow the **absent = enabled** pattern. If a key is missin
| `workflow.max_discuss_passes` | number | `3` | Maximum number of question rounds in discuss-phase before the workflow stops asking. Useful in headless/auto mode to prevent infinite discussion loops. |
| `workflow.skip_discuss` | boolean | `false` | When `true`, `/gsd-autonomous` bypasses the discuss-phase entirely, writing minimal CONTEXT.md from the ROADMAP phase goal. Useful for projects where developer preferences are fully captured in PROJECT.md/REQUIREMENTS.md. Added in v1.28 |
| `workflow.text_mode` | boolean | `false` | Replaces AskUserQuestion TUI menus with plain-text numbered lists. Required for Claude Code remote sessions (`/rc` mode) where TUI menus don't render. Can also be set per-session with `--text` flag on discuss-phase. Added in v1.28 |
| `workflow.use_worktrees` | boolean | `true` | When `false`, disables git worktree isolation for parallel execution. Users who prefer sequential execution or whose environment does not support worktrees can disable this. Added in v1.31. **Branch-divergence note:** when your branch has diverged from `origin/HEAD`, GSD auto-degrades to sequential and prints a warning. See [`worktree.baseRef`](#worktree-settings) to restore parallel execution on a diverged branch. |
| `workflow.use_worktrees` | boolean | `true` | When `false`, disables git worktree isolation for parallel execution. Users who prefer sequential execution or whose environment does not support worktrees can disable this. Added in v1.31. **Branch-divergence note:** when your branch has diverged from `origin/HEAD`, GSD auto-degrades to sequential and prints a warning. See [`worktree.baseRef`](#worktree-settings) to restore parallel execution on a diverged branch. **Non-Claude note:** git worktree isolation uses Claude Code's `isolation="worktree"` agent primitive, which no other runtime honors. On any non-Claude install (Codex, Cursor, Gemini, Qwen, etc.) a runtime-neutral `.planning/config.json` resolves the runtime to that install's own id and defaults this key to `false`; forcing `use_worktrees: true` on a non-Claude install fails closed before any executor dispatch (#1515, #1521). |
| `workflow.worktree_skip_hooks` | boolean | `false` | When `true`, executor agents in worktree mode pass `--no-verify` (skipping pre-commit hooks) and post-wave hook validation runs against the merged result instead. Opt-in escape hatch for projects whose hooks cannot run in agent worktrees. Default `false` runs hooks on every commit (#2924). |
| `workflow.code_review` | boolean | `true` | Enable `/gsd-code-review` and `/gsd-code-review --fix` commands. When `false`, the commands exit with a configuration gate message. Added in v1.34 |
| `workflow.code_review_depth` | string | `standard` | Default review depth for `/gsd-code-review`: `quick` (pattern-matching only), `standard` (per-file analysis), or `deep` (cross-file with import graphs). Can be overridden per-run with `--depth=`. Added in v1.34 |

View File

@@ -1230,9 +1230,9 @@ When verification returns `human_needed`, items are persisted as a trackable HUM
### 43. Backlog Parking Lot
**Commands:** `/gsd-capture --backlog <description>`, `/gsd-review-backlog`, `/gsd-capture --seed <idea>`
**Commands:** `/gsd-capture --backlog <description>`, `/gsd-review-backlog`, `/gsd-capture --seed <idea>`, `/gsd-capture --list-seeds [status]`
**Purpose:** Capture ideas that aren't ready for active planning. Backlog items use 999.x numbering to stay outside the active phase sequence. Seeds are forward-looking ideas with trigger conditions that surface automatically at the right milestone.
**Purpose:** Capture ideas that aren't ready for active planning. Backlog items use 999.x numbering to stay outside the active phase sequence. Seeds are forward-looking ideas with trigger conditions that surface automatically at the right milestone. `--list-seeds` provides a read-only audit of all parked seeds (with optional status filter) without waiting for the next milestone.
**Requirements:**
- REQ-BACKLOG-01: Backlog items MUST use 999.x numbering to stay outside active phase sequence
@@ -1241,6 +1241,7 @@ When verification returns `human_needed`, items are persisted as a trackable HUM
- REQ-BACKLOG-04: Promoted items MUST be renumbered into the active milestone sequence
- REQ-SEED-01: Seeds MUST capture the full WHY and WHEN to surface conditions
- REQ-SEED-02: `/gsd-new-milestone` MUST scan seeds and present matches
- REQ-SEED-03: `/gsd-capture --list-seeds` MUST list seeds with status, scope, and trigger for audit, with optional status filtering
**Produces:**
| Artifact | Description |

View File

@@ -147,6 +147,7 @@
"ingest-docs.md",
"insert-phase.md",
"list-phase-assumptions.md",
"list-seeds.md",
"list-workspaces.md",
"manager.md",
"map-codebase.md",
@@ -368,6 +369,7 @@
"roadmap-upgrade.cjs",
"roadmap.cjs",
"runtime-artifact-conversion.cjs",
"runtime-artifact-install-plan.cjs",
"runtime-artifact-layout.cjs",
"runtime-config-adapter-registry.cjs",
"runtime-homes.cjs",

View File

@@ -215,6 +215,7 @@ Full roster at `gsd-core/workflows/*.md`. Workflows are thin orchestrators that
| `ingest-docs.md` | Scan a repo for mixed planning docs; classify, synthesize, and bootstrap or merge into `.planning/` with a conflicts report. | `/gsd-ingest-docs` |
| `insert-phase.md` | Insert a decimal phase for urgent work discovered mid-milestone. | `/gsd-phase --insert` |
| `list-phase-assumptions.md` | Surface Claude's assumptions about a phase before planning. | `/gsd-discuss-phase --assumptions` |
| `list-seeds.md` | List and audit captured seeds (read-only), with optional status filter. | `/gsd-capture --list-seeds` |
| `list-workspaces.md` | List all GSD workspaces found in `~/gsd-workspaces/` with their status. | `/gsd-workspace --list` |
| `manager.md` | Interactive milestone command center — dashboard, inline discuss, background plan/execute. | `/gsd-manager` |
| `map-codebase.md` | Orchestrate parallel codebase mapper agents to produce `.planning/codebase/` docs. | `/gsd-map-codebase` |
@@ -476,6 +477,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`.
| `roadmap-upgrade.cjs` | Migration tool for converting legacy `Phase N` entries to milestone-prefixed `Phase M-NN` convention; `computeMigrationPlan` + `applyMigration` with dry-run default and atomic rollback |
| `roadmap.cjs` | ROADMAP.md parsing, phase extraction, plan progress |
| `runtime-artifact-conversion.cjs` | Runtime artifact conversion module — projects Claude-authored commands, agents, and skills into runtime-specific artifact bodies while preserving installer compatibility exports |
| `runtime-artifact-install-plan.cjs` | Runtime artifact install plan module — stages pre-resolved layout kinds, applies runtime body rewrites, and returns copy-plan items plus cleanup obligations |
| `runtime-artifact-layout.cjs` | Runtime artifact layout module — resolves the artifact directory shapes (commands, agents, skills) for each supported runtime; single source of truth for per-runtime artifact placement (#3663) |
| `runtime-config-adapter-registry.cjs` | Explicit runtime config adapter registry — resolves per-runtime config-mutation install intent (install surface, shared-settings gate, finish-phase permission writer); see ADR-58. |
| `runtime-hooks-surface.cjs` | Runtime hooks surface module — standalone hook-surface writer functions extracted from bin/install.js (ADR-857 phase 5f-1); owns Cline/Cursor/Copilot/Codex hook artifact generation and reconciliation. |

View File

@@ -334,6 +334,15 @@ Seeds are forward-looking ideas with trigger conditions. Unlike backlog items, s
`/gsd-new-milestone` scans all seeds and presents matches. **Storage:** `.planning/seeds/SEED-NNN-slug.md`
Once you've parked a few, audit them on demand instead of waiting for the next milestone to surface them:
```bash
/gsd-capture --list-seeds # Review every parked seed
/gsd-capture --list-seeds dormant # Narrow to one status
```
This is read-only — it renders an audit table (ID, status, scope, trigger, title) and a per-status summary, and never modifies a seed. Filter by `dormant`, `active`, or `triggered` when you only want to see seeds in one state.
### Persistent Context Threads
Threads are lightweight cross-session knowledge stores for work that spans multiple sessions but doesn't belong to any specific phase.

View File

@@ -14,7 +14,7 @@ This is the **last upward dependency from the `.cts` source tree into the hand-a
## Decision
- Promote the `[Planned]` **Runtime Artifact Conversion Module** (`src/runtime-artifact-conversion.cts`) to the single owner of per-runtime **content rewriting**: the per-runtime converters (already relocated as ADR-3660's "first slice", #1099), **plus** the rewrite engine `_applyRuntimeRewrites`, the staged-content walkers, path-prefix derivation, and commit attribution. The **Runtime Artifact Layout Module** keeps owning **placement** only.
- Promote the `[Planned]` **Runtime Artifact Conversion Module** (`src/runtime-artifact-conversion.cts`) to the single owner of per-runtime **content rewriting**: the per-runtime converters (already relocated as ADR-3660's "first slice", #1099), **plus** the rewrite engine `_applyRuntimeRewrites`, the staged-content walkers, path-prefix derivation, and commit attribution. The **Runtime Artifact Layout Module** keeps owning **placement** only. **Exception:** opencode and kilo path-prefix rewriting remains a deliberate `bin/install.js`-owned pre-conversion step (see `applyOpencodeFamilyPathPrefix`); this is intentional per #784 and is not a violation of the single-owner rule.
- **Public seam** — two deep calls; the caller passes only what it has, the module derives the rest:
- `rewriteStagedSkillBodies(stagedDir, { runtime, configDir, scope }, env?)` — in-place walk (skills / kimi-agents).
- `rewriteStagedCommandBodies(stagedDir, { runtime, configDir, scope }, env?) → tempDir` — copy-to-temp (commands).

View File

@@ -114,9 +114,19 @@ This addendum ratifies three contract points:
Net effect on D4: the *guarantee* ("a `test`-tier prohibition is never a silent pass") was preserved at every step — fail-closed-now (#644), genuine-execution (#1259), and now **machine-proven fail-first (#1279)**. A `test`-tier prohibition reaches `green`/`passed` ONLY when the wired check both genuinely, non-vacuously passes AND is independently proven to fail on a violation; every miss/fail/un-provable hard-gates. The decision also lives in `src/prohibition-enforcement.cts` comments, `gsd-core/references/prohibition-probe.md`, `gsd-core/workflows/verify-phase.md`, and the #1279 changeset.
**Review corrections (#1314 maintainer review) — two soundness items:**
- **node-test fixture-existence guard (was fail-OPEN) — FIXED.** The node-test prover originally guarded only `if (!fixture)`. A missing/typo'd/stale `violationFixture` path made `GSD_PROHIB_SUBJECT` point at a non-existent file; an honest negative test then threw ENOENT *inside its callback* — a failing test named distinctly from the file — which `isNonVacuousNodeTestRed` accepted as proof, **forging a green from a setup crash** (asymmetric with the lint-rule path, which fail-CLOSES on `< 1` file result). Fixed by requiring `fs.existsSync(path.resolve(cwd, fixture))` before spawning (symmetric fail-closed; resolved against the producer's `cwd` to match the child's resolution). **Documented residual (#1346):** existence is necessary but not sufficient — a deceptive test that reds merely *because* `GSD_PROHIB_SUBJECT` is set (not because the subject's CONTENT violates) is still accepted; proving causation generically for an arbitrary author-supplied test is not possible, so it is recorded as a constraint, not implied-solved.
- **node-test fixture-existence guard (was fail-OPEN) — FIXED.** The node-test prover originally guarded only `if (!fixture)`. A missing/typo'd/stale `violationFixture` path made `GSD_PROHIB_SUBJECT` point at a non-existent file; an honest negative test then threw ENOENT *inside its callback* — a failing test named distinctly from the file — which `isNonVacuousNodeTestRed` accepted as proof, **forging a green from a setup crash** (asymmetric with the lint-rule path, which fail-CLOSES on `< 1` file result). Fixed by requiring `fs.existsSync(path.resolve(cwd, fixture))` before spawning (symmetric fail-closed; resolved against the producer's `cwd` to match the child's resolution). **Residual (#1346) — now MITIGATED by an optional control; see the 2026-06-21 addendum below:** existence is necessary but not sufficient — a deceptive test that reds merely *because* `GSD_PROHIB_SUBJECT` is set (not because the subject's CONTENT violates) was still accepted; a generic always-on proof is impossible, so #1346 adds an **opt-in clean-subject control** that proves content-dependence when the author supplies one (and the residual remains, documented, only for checks with no control fixture).
- **`violationFixture` projection source (#1278 ↔ #1279 now COMPOSE) — DELIVERED.** Initially `descriptorFromProjection` reconstructed only `{ kind, target, rule? }` and the projection carried no fixture, so a prohibition wired purely through the deterministic path always hard-gated. This PR threads a **fourth flat scalar `check_violation_fixture`** through `projectProhibitions` + `descriptorFromProjection` (rides both kinds; mirrors `CheckDescriptor.violationFixture`). A prohibition authored with all four scalars now **machine-proves fail-first and greens end-to-end through the projection alone** (zero hand-authoring) — the round-trip is pinned by a fast-check property + CHK-03(D) + an end-to-end COMPOSE capstone. Fail-closed is preserved: a descriptor with no `check_violation_fixture` (or a blank one) projects absent and hard-gates. The remaining work under #1346 is now just the node-test causation residual above.
## Addendum (2026-06-21, #1346) — node-test causation control: prove the RED is CONTENT-caused
The #1314 review left one tracked residual (above): the node-test prover confirms the violation fixture exists and that the negative test goes a non-vacuous RED, but could not prove the RED was caused by the subject's **content** rather than by `GSD_PROHIB_SUBJECT` merely being *set*. A deceptive content-independent test (`assert.ok(!process.env.GSD_PROHIB_SUBJECT)`) was still accepted. A general always-on proof is impossible for an arbitrary author-supplied test, so #1346 closes the gap with an **opt-in control** rather than a forced one.
This addendum ratifies one contract point:
- **(d) `CheckDescriptor.cleanFixture?` / `check_clean_fixture` — the causation control (the 5th flat scalar).** An OPTIONAL author-supplied path to a KNOWN-CLEAN control subject. When present, the node-test prover runs the SAME negative test a second time with `GSD_PROHIB_SUBJECT=<cleanFixture>` and requires it to stay a **non-vacuous GREEN**. Fail-first is then proven ONLY when the check is **RED on the violation AND GREEN on the clean subject** — i.e. the red is content-dependent. A deceptive test that reds whenever the env var is set reds on the clean subject too → the control fails → not proven (fail-closed). The scalar rides both kinds through `projectProhibitions` + `descriptorFromProjection` exactly as `check_violation_fixture` does (round-trip pinned by the fast-check property + an end-to-end COMPOSE capstone exercising both the honest and deceptive subjects).
**Why opt-in, not required:** making the control mandatory would regress the #1314 zero-authoring compose path — every existing node-test prohibition (which carries no clean fixture) would suddenly hard-gate. So **absent `cleanFixture` → no control runs and behavior is byte-identical to post-#1314**; the residual remains a documented permanent constraint *only* for checks whose author did not supply a clean control. An author opts into the stronger machine guarantee by supplying one. The lint-rule kind needs no analog: its "subject" *is* the linted file (no `GSD_PROHIB_SUBJECT` indirection), so the "reds because the env var is set" gap does not exist there. Net effect on D4 is unchanged — every miss/fail/un-provable still hard-gates; this only *tightens* what counts as proven. The mechanism lives in `src/prohibition-enforcement.cts` (`defaultProveFailFirst` node-test branch + the `runNodeTestWithSubject` helper) and `src/probe-core.cts` (`projectProhibitions`), compiled by `build:lib`.
## Addendum (2026-06-15): optional `check` descriptor on the prohibition item — D3 shape extension (#1278)
This ratifies the **deterministic SOURCE** for the test-tier `CheckDescriptor` that #1259 (PR #1273) left caller/verifier-supplied. #1259 shipped the PRODUCER (`check prohibition-enforcement`) that *runs* a wired check given a `{kind, target, rule?}` descriptor, but the descriptor itself was invented by the verify-phase LLM each run (the "locate" half). #1278 makes that locate half **deterministic**: an optional `check` descriptor is authored at spec-phase on the resolved `test`-tier prohibition, projected by `projectProhibitions`, and read back by verify-phase — so a wired, passing test closes the gap with **zero manual authoring**. This extends the **Decision 3 prohibition-item shape** (it adds optional keys to that item), so it is ratified here rather than rewriting D3 in place.

View File

@@ -112,6 +112,7 @@ export default tseslint.config(
'gsd-core/bin/lib/planning-workspace.cjs',
'gsd-core/bin/lib/command-roster.cjs',
'gsd-core/bin/lib/runtime-artifact-conversion.cjs',
'gsd-core/bin/lib/runtime-artifact-install-plan.cjs',
'gsd-core/bin/lib/runtime-artifact-layout.cjs',
'gsd-core/bin/lib/runtime-config-adapter-registry.cjs',
'gsd-core/bin/lib/runtime-hooks-surface.cjs',

View File

@@ -1,6 +1,6 @@
{
"name": "gsd-core",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"description": "GSD Core — a meta-prompting, context engineering, and spec-driven development system for AI coding agents. Loads gsd's operating context into every Gemini CLI session.",
"contextFileName": "GEMINI.md"
}

View File

@@ -25,6 +25,7 @@
* generate-slug <text> Convert text to URL-safe slug
* current-timestamp [format] Get timestamp (full|date|filename)
* list-todos [area] Count and enumerate pending todos
* list-seeds [status] List captured seeds (optional status filter)
* verify-path-exists <path> Check file/directory existence
* config-ensure-section Initialize .planning/config.json
* history-digest Aggregate all SUMMARY.md data
@@ -631,13 +632,13 @@ async function main() {
// discovery; previously it was a partial subset that didn't include
// phase / roadmap / milestone / progress / etc.
const TOP_LEVEL_USAGE = 'Usage: gsd-tools <command> [args] [--raw] [--pick <field>] [--cwd <path>] [--ws <name>] [--json-errors]\n' +
'Commands: agent, agent-skills, audit-open, audit-uat, check, check-commit, commit, commit-to-subrepo, ' +
'Commands: agent, agent-skills, audit-open, audit-uat, check, check-commit, commit, commit-to-subrepo, pr-subrepo, ' +
'config-ensure-section, config-get, config-new-project, config-path, config-set, migrate-config, ' +
'current-timestamp, detect-custom-files, docs-init, drift-guard, effort, extract-messages, find-phase, ' +
'from-gsd2, frontmatter, gap-analysis, generate-claude-md, generate-claude-profile, ' +
'generate-dev-preferences, generate-slug, graphify, history-digest, init, intel, ' +
'capability, classify-confidence, git, learnings, list-todos, loop, milestone, package-legitimacy, phase, phase-plan-index, phases, profile-questionnaire, ' +
'profile-sample, progress, prompt-budget, requirements, research-plan, research-store, resolve-granularity, resolve-model, roadmap, scaffold, state, ' +
'capability, classify-confidence, git, learnings, list-seeds, list-todos, loop, milestone, package-legitimacy, phase, phase-plan-index, phases, profile-questionnaire, ' +
'profile-sample, progress, project-instruction-file, prompt-budget, requirements, research-plan, research-store, resolve-granularity, resolve-model, roadmap, scaffold, state, ' +
'task, template, user-story, validate, verify, verify-path-exists, verify-summary, workstream, worktree\n\n' +
'Global flags:\n' +
' --raw Emit raw output without post-processing\n' +
@@ -688,6 +689,10 @@ async function main() {
'worktree', 'prompt-budget',
'research-store', 'research-plan', 'package-legitimacy', 'classify-confidence',
'user-story', // pure string validation — no .planning/ access needed
// #1529: pure runtime→filename projection via getProjectInstructionFile; no
// .planning/ access needed, and resolving project root would break workflow
// invocations that run before .planning/ exists (new-project Step 1).
'project-instruction-file',
]);
if (!SKIP_ROOT_RESOLUTION.has(command)) {
cwd = findProjectRoot(cwd);
@@ -959,6 +964,13 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
break;
}
case 'pr-subrepo': {
const message = args[1];
const { repo, branch } = parseNamedArgs(args, ['repo', 'branch']);
commands.cmdPrSubrepo(cwd, repo, branch, message, raw);
break;
}
case 'verify-summary': {
const summaryPath = args[1];
const countIndex = args.indexOf('--check-count');
@@ -1095,11 +1107,39 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
break;
}
case 'project-instruction-file': {
// #1529: pure runtime→filename projection. Backs the
// `gsd_run query project-instruction-file --runtime <r>` call in
// new-project.md so the bash workflow and profile-output.cjs share one
// source of truth (getProjectInstructionFile in runtime-name-policy.cjs).
// No SDK bridge — pure local lookup, runs before .planning/ exists.
const { getProjectInstructionFile } = require('./lib/runtime-name-policy.cjs');
// Parse --runtime <value> (space or = form); default to empty so the
// safe AGENTS.md cross-agent default applies.
const pifArgs = args.slice(1);
let pifRuntime = '';
for (let i = 0; i < pifArgs.length; i++) {
const a = pifArgs[i];
if (a === '--runtime' && pifArgs[i + 1] !== undefined) { pifRuntime = pifArgs[++i]; continue; }
if (a.startsWith('--runtime=')) { pifRuntime = a.slice('--runtime='.length); continue; }
// First positional that isn't a flag also works (lenient); otherwise ignore unknown flags.
if (!a.startsWith('-') && !pifRuntime) { pifRuntime = a; }
}
const filename = getProjectInstructionFile(pifRuntime);
process.stdout.write(filename + '\n');
break;
}
case 'list-todos': {
commands.cmdListTodos(cwd, args[1], raw);
break;
}
case 'list-seeds': {
commands.cmdListSeeds(cwd, args[1], raw);
break;
}
case 'verify-path-exists': {
commands.cmdVerifyPathExists(cwd, args[1], raw);
break;
@@ -2128,6 +2168,8 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
const worktreeSafety = require('./lib/worktree-safety.cjs');
if (subcommand === 'cleanup-wave') {
worktreeSafety.cmdWorktreeCleanupWave(cwd, args.slice(2));
} else if (subcommand === 'record-agent') {
worktreeSafety.cmdWorktreeRecordAgent(cwd, args.slice(2));
} else if (subcommand === 'reap-orphans') {
worktreeSafety.cmdWorktreeReapOrphans(cwd);
} else if (subcommand === 'base-check') {
@@ -2135,7 +2177,7 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
} else if (subcommand === 'set-baseref') {
require('./lib/worktree-base-ref.cjs').cmdWorktreeSetBaseRef(cwd, args.slice(2));
} else {
error('Unknown worktree subcommand. Available: cleanup-wave, reap-orphans, base-check, set-baseref', ERROR_REASON.SDK_UNKNOWN_COMMAND);
error('Unknown worktree subcommand. Available: cleanup-wave, record-agent, reap-orphans, base-check, set-baseref', ERROR_REASON.SDK_UNKNOWN_COMMAND);
}
break;
}

View File

@@ -10,7 +10,7 @@ const capabilities = {
"ai-integration": {
"id": "ai-integration",
"role": "feature",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "AI design contract",
"description": "AI-SPEC design contract workflow for phases that build AI systems; owns the AI integration command, agents, and workflow.ai_integration_phase activation key.",
"tier": "full",
@@ -63,7 +63,7 @@ const capabilities = {
"antigravity": {
"id": "antigravity",
"role": "runtime",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Antigravity",
"description": "Google Antigravity IDE — nested under ~/.gemini/antigravity; probed across 1.x and 2.x layouts; Gemini hook event dialect; nested skill layout; tier-1 support.",
"tier": "core",
@@ -123,7 +123,7 @@ const capabilities = {
"audit": {
"id": "audit",
"role": "feature",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Audit",
"description": "Open-artifact audit and UAT-gap audit for milestone close gates; exposes `gsd-tools audit-uat` (cross-phase UAT outstanding items) and `gsd-tools audit-open` (structured open-artifact scan across debug, tasks, threads, todos, seeds, UAT, verification, context-questions).",
"tier": "full",
@@ -160,7 +160,7 @@ const capabilities = {
"augment": {
"id": "augment",
"role": "runtime",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Augment Code",
"description": "Augment Code CLI — commands + nested-skill artifact layout; settings-json hook surface; Claude hook event dialect; tier-2 support.",
"tier": "core",
@@ -229,7 +229,7 @@ const capabilities = {
"claude": {
"id": "claude",
"role": "runtime",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Claude Code",
"description": "Anthropic Claude Code — primary development runtime; tier-1 support with full hook surface and skills-based global install.",
"tier": "core",
@@ -295,7 +295,7 @@ const capabilities = {
"cline": {
"id": "cline",
"role": "runtime",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Cline",
"description": "Cline (VS Code extension) — global-only nested-skill layout; cline-rules hook surface (.clinerules); no hook events emitted; tier-2 support.",
"tier": "core",
@@ -338,7 +338,7 @@ const capabilities = {
"code-review": {
"id": "code-review",
"role": "feature",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Code review",
"description": "Source-file code review and review-fix workflow support for completed execution work.",
"tier": "full",
@@ -399,7 +399,7 @@ const capabilities = {
"codebuddy": {
"id": "codebuddy",
"role": "runtime",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "CodeBuddy",
"description": "CodeBuddy (Tencent) — converted commands + skills artifact layout; settings-json hook surface; Claude hook event dialect; tier-2 support.",
"tier": "core",
@@ -468,7 +468,7 @@ const capabilities = {
"codex": {
"id": "codex",
"role": "runtime",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "OpenAI Codex CLI",
"description": "OpenAI Codex CLI — shell-var command style; per-agent sandbox tiers; config.toml + hooks.json hook surface; tier-1 support.",
"tier": "core",
@@ -521,7 +521,7 @@ const capabilities = {
"copilot": {
"id": "copilot",
"role": "runtime",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "GitHub Copilot",
"description": "GitHub Copilot (VS Code) — markdown config format; copilot-inline hook surface; no hook events emitted; flat skill nesting (unconfirmed recursive loader); tier-2 support.",
"tier": "core",
@@ -574,7 +574,7 @@ const capabilities = {
"cursor": {
"id": "cursor",
"role": "runtime",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Cursor",
"description": "Cursor IDE — skills + converted commands artifact layout; hooks.json surface; Claude hook event dialect; recursive skill loader (flat nesting); tier-2 support.",
"tier": "core",
@@ -643,7 +643,7 @@ const capabilities = {
"drift": {
"id": "drift",
"role": "feature",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Drift detection gates",
"description": "Post-execution drift detection gates that run after each wave completes. Provides two gates at execute:wave:post: a blocking schema drift gate (detects schema files changed without a database push) and a non-blocking codebase drift gate (detects structural additions not reflected in STRUCTURE.md).",
"tier": "full",
@@ -707,7 +707,7 @@ const capabilities = {
"gap-analysis": {
"id": "gap-analysis",
"role": "feature",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Post-planning gap analysis",
"description": "Proactive, non-blocking post-planning coverage report. After all PLAN.md files are generated, cross-references every REQ-ID and D-ID from REQUIREMENTS.md and CONTEXT.md against plan bodies. Emits a Source | Item | Status table. Does not block phase advancement.",
"tier": "standard",
@@ -748,7 +748,7 @@ const capabilities = {
"gemini": {
"id": "gemini",
"role": "runtime",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Gemini CLI",
"description": "Google Gemini CLI — commands-only artifact layout (TOML); Gemini hook event dialect; settings-json hook surface; tier-2 support.",
"tier": "core",
@@ -805,7 +805,7 @@ const capabilities = {
"graphify": {
"id": "graphify",
"role": "feature",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Knowledge graph",
"description": "Build, query, and inspect the project knowledge graph in `.planning/graphs/`; exposes graphify CLI subcommands (build, query, status, diff) and the /gsd-graphify skill.",
"tier": "full",
@@ -846,7 +846,7 @@ const capabilities = {
"hermes": {
"id": "hermes",
"role": "runtime",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Hermes Agent",
"description": "Hermes Agent (NousResearch) — skills nest under skills/gsd/ category bucket; nested skill layout; settings-json hook surface; Claude hook event dialect; tier-2 support.",
"tier": "core",
@@ -899,7 +899,7 @@ const capabilities = {
"intel": {
"id": "intel",
"role": "feature",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Codebase intelligence",
"description": "Code-intelligence store for codebase querying, diff, snapshot, and API-surface extraction; exposes `gsd-tools intel` subcommands (query, status, update, diff, snapshot, patch-meta, validate, extract-exports, api-surface) and backs `/gsd-map-codebase` and `gsd-intel-updater`.",
"tier": "full",
@@ -951,7 +951,7 @@ const capabilities = {
"kilo": {
"id": "kilo",
"role": "runtime",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Kilo Code",
"description": "Kilo Code — XDG-based config dir; global skills at ~/.kilo/skills (separate from XDG config); flat command/ + skills artifact layout; no lifecycle hook registration; tier-2 support.",
"tier": "core",
@@ -1026,7 +1026,7 @@ const capabilities = {
"kimi": {
"id": "kimi",
"role": "runtime",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Kimi CLI",
"description": "Kimi CLI (Moonshot AI) — generic agents root at ~/.config/agents; skills + kimi-agents artifact layout; no hook surface; no hook events; tier-2 support.",
"tier": "core",
@@ -1082,7 +1082,7 @@ const capabilities = {
"mempalace": {
"id": "mempalace",
"role": "feature",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "MemPalace memory",
"description": "Cross-session, cross-project memory: deliberate recall before discuss/plan and verbatim capture + temporal-KG sync at phase boundaries, via the MemPalace MCP server and CLI.",
"tier": "full",
@@ -1256,7 +1256,7 @@ const capabilities = {
"nyquist": {
"id": "nyquist",
"role": "feature",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Nyquist validation",
"description": "Validation coverage audit that maps executed work back to tests and manual-only evidence.",
"tier": "full",
@@ -1306,7 +1306,7 @@ const capabilities = {
"opencode": {
"id": "opencode",
"role": "runtime",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "OpenCode",
"description": "OpenCode — XDG-based config dir; flat command/ + skills artifact layout; settings-json config format; no lifecycle hook registration; tier-2 support.",
"tier": "core",
@@ -1376,7 +1376,7 @@ const capabilities = {
"pattern-mapper": {
"id": "pattern-mapper",
"role": "feature",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Pattern mapping",
"description": "Optional codebase-pattern mapping before planning; owns the pattern mapper agent and workflow.pattern_mapper activation key.",
"tier": "full",
@@ -1430,7 +1430,7 @@ const capabilities = {
"profile-pipeline": {
"id": "profile-pipeline",
"role": "feature",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Developer profiling pipeline",
"description": "Developer behavioral profiling from Claude Code session history; scans session JSONL files, extracts and samples user messages, and generates profile artifacts (USER-PROFILE.md, dev-preferences.md, CLAUDE.md sections). Exposes eight `gsd-tools` commands: scan-sessions, extract-messages, profile-sample (pipeline phase) and write-profile, profile-questionnaire, generate-dev-preferences, generate-claude-profile, generate-claude-md (output phase). Backs the /gsd-profile-user skill and gsd-user-profiler agent.",
"tier": "full",
@@ -1507,7 +1507,7 @@ const capabilities = {
"qwen": {
"id": "qwen",
"role": "runtime",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Qwen Code",
"description": "Qwen Code (Alibaba) — nested-skill artifact layout; settings-json hook surface; Claude hook event dialect; tier-2 support.",
"tier": "core",
@@ -1564,7 +1564,7 @@ const capabilities = {
"research": {
"id": "research",
"role": "feature",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Phase research",
"description": "Optional phase research before planning; owns the phase researcher agent and workflow.research activation key.",
"tier": "standard",
@@ -1616,7 +1616,7 @@ const capabilities = {
"schema-gate": {
"id": "schema-gate",
"role": "feature",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Schema push detection gate",
"description": "Detects ORM schema-relevant files in the phase scope during planning and injects a mandatory [BLOCKING] schema push task into the plan. Prevents false-positive verification where build/types pass because TypeScript types come from config, not the live database.",
"tier": "full",
@@ -1662,7 +1662,7 @@ const capabilities = {
"security": {
"id": "security",
"role": "feature",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Security enforcement",
"description": "Threat mitigation verification and ship-time security blocking for phases with security enforcement enabled.",
"tier": "full",
@@ -1761,7 +1761,7 @@ const capabilities = {
"tdd": {
"id": "tdd",
"role": "feature",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Test-driven development",
"description": "Injects TDD heuristics into the planner and enforces RED/GREEN gate compliance on type:tdd plans after execution. Owns workflow.tdd_mode; the --tdd CLI flag is the ephemeral override.",
"tier": "full",
@@ -1814,7 +1814,7 @@ const capabilities = {
"trae": {
"id": "trae",
"role": "runtime",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Trae IDE",
"description": "Trae IDE — nested-skill artifact layout; no hook surface (profile-marker-only config); tier-2 support.",
"tier": "core",
@@ -1866,7 +1866,7 @@ const capabilities = {
"ui": {
"id": "ui",
"role": "feature",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "UI design contracts",
"description": "UI-SPEC design contract + retrospective UI audit for frontend phases.",
"tier": "full",
@@ -1961,7 +1961,7 @@ const capabilities = {
"windsurf": {
"id": "windsurf",
"role": "runtime",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Windsurf",
"description": "Windsurf (Codeium) — nested under ~/.codeium/windsurf; skills-only artifact layout; no hook surface; no hook events; tier-2 support.",
"tier": "core",
@@ -2721,7 +2721,7 @@ const runtimes = {
"antigravity": {
"id": "antigravity",
"role": "runtime",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Antigravity",
"description": "Google Antigravity IDE — nested under ~/.gemini/antigravity; probed across 1.x and 2.x layouts; Gemini hook event dialect; nested skill layout; tier-1 support.",
"tier": "core",
@@ -2781,7 +2781,7 @@ const runtimes = {
"augment": {
"id": "augment",
"role": "runtime",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Augment Code",
"description": "Augment Code CLI — commands + nested-skill artifact layout; settings-json hook surface; Claude hook event dialect; tier-2 support.",
"tier": "core",
@@ -2850,7 +2850,7 @@ const runtimes = {
"claude": {
"id": "claude",
"role": "runtime",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Claude Code",
"description": "Anthropic Claude Code — primary development runtime; tier-1 support with full hook surface and skills-based global install.",
"tier": "core",
@@ -2916,7 +2916,7 @@ const runtimes = {
"cline": {
"id": "cline",
"role": "runtime",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Cline",
"description": "Cline (VS Code extension) — global-only nested-skill layout; cline-rules hook surface (.clinerules); no hook events emitted; tier-2 support.",
"tier": "core",
@@ -2959,7 +2959,7 @@ const runtimes = {
"codebuddy": {
"id": "codebuddy",
"role": "runtime",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "CodeBuddy",
"description": "CodeBuddy (Tencent) — converted commands + skills artifact layout; settings-json hook surface; Claude hook event dialect; tier-2 support.",
"tier": "core",
@@ -3028,7 +3028,7 @@ const runtimes = {
"codex": {
"id": "codex",
"role": "runtime",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "OpenAI Codex CLI",
"description": "OpenAI Codex CLI — shell-var command style; per-agent sandbox tiers; config.toml + hooks.json hook surface; tier-1 support.",
"tier": "core",
@@ -3081,7 +3081,7 @@ const runtimes = {
"copilot": {
"id": "copilot",
"role": "runtime",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "GitHub Copilot",
"description": "GitHub Copilot (VS Code) — markdown config format; copilot-inline hook surface; no hook events emitted; flat skill nesting (unconfirmed recursive loader); tier-2 support.",
"tier": "core",
@@ -3134,7 +3134,7 @@ const runtimes = {
"cursor": {
"id": "cursor",
"role": "runtime",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Cursor",
"description": "Cursor IDE — skills + converted commands artifact layout; hooks.json surface; Claude hook event dialect; recursive skill loader (flat nesting); tier-2 support.",
"tier": "core",
@@ -3203,7 +3203,7 @@ const runtimes = {
"gemini": {
"id": "gemini",
"role": "runtime",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Gemini CLI",
"description": "Google Gemini CLI — commands-only artifact layout (TOML); Gemini hook event dialect; settings-json hook surface; tier-2 support.",
"tier": "core",
@@ -3260,7 +3260,7 @@ const runtimes = {
"hermes": {
"id": "hermes",
"role": "runtime",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Hermes Agent",
"description": "Hermes Agent (NousResearch) — skills nest under skills/gsd/ category bucket; nested skill layout; settings-json hook surface; Claude hook event dialect; tier-2 support.",
"tier": "core",
@@ -3313,7 +3313,7 @@ const runtimes = {
"kilo": {
"id": "kilo",
"role": "runtime",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Kilo Code",
"description": "Kilo Code — XDG-based config dir; global skills at ~/.kilo/skills (separate from XDG config); flat command/ + skills artifact layout; no lifecycle hook registration; tier-2 support.",
"tier": "core",
@@ -3388,7 +3388,7 @@ const runtimes = {
"kimi": {
"id": "kimi",
"role": "runtime",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Kimi CLI",
"description": "Kimi CLI (Moonshot AI) — generic agents root at ~/.config/agents; skills + kimi-agents artifact layout; no hook surface; no hook events; tier-2 support.",
"tier": "core",
@@ -3444,7 +3444,7 @@ const runtimes = {
"opencode": {
"id": "opencode",
"role": "runtime",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "OpenCode",
"description": "OpenCode — XDG-based config dir; flat command/ + skills artifact layout; settings-json config format; no lifecycle hook registration; tier-2 support.",
"tier": "core",
@@ -3514,7 +3514,7 @@ const runtimes = {
"qwen": {
"id": "qwen",
"role": "runtime",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Qwen Code",
"description": "Qwen Code (Alibaba) — nested-skill artifact layout; settings-json hook surface; Claude hook event dialect; tier-2 support.",
"tier": "core",
@@ -3571,7 +3571,7 @@ const runtimes = {
"trae": {
"id": "trae",
"role": "runtime",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Trae IDE",
"description": "Trae IDE — nested-skill artifact layout; no hook surface (profile-marker-only config); tier-2 support.",
"tier": "core",
@@ -3623,7 +3623,7 @@ const runtimes = {
"windsurf": {
"id": "windsurf",
"role": "runtime",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"title": "Windsurf",
"description": "Windsurf (Codeium) — nested under ~/.codeium/windsurf; skills-only artifact layout; no hook surface; no hook events; tier-2 support.",
"tier": "core",

View File

@@ -0,0 +1,77 @@
'use strict';
/**
* Runtime Artifact Install Plan Module.
*
* Turns a pre-resolved runtime artifact layout into staged copy inputs. The
* installer adapter still owns pruning, copying, migrations, output, and final
* cleanup execution.
*/
// In .cts (CommonJS output) files, `require` is available as a global.
const _require = require;
const path = _require('node:path');
function errorMessage(err) {
if (err instanceof Error)
return err.message;
return String(err);
}
function addCleanupDir(cleanupDirs, stagedDir, rewrittenDir) {
const sourceDir = rewrittenDir ?? stagedDir;
if (sourceDir !== stagedDir)
cleanupDirs.push(sourceDir);
return sourceDir;
}
function createRuntimeArtifactInstallPlan(args) {
const { layout, resolvedProfile, homedir, platform, resolveAttribution, deps = {}, } = args;
const conversionExports = _require('./runtime-artifact-conversion.cjs');
const rewriteStagedSkillBodies = deps.rewriteStagedSkillBodies ?? conversionExports.rewriteStagedSkillBodies;
const rewriteStagedCommandBodies = deps.rewriteStagedCommandBodies ?? conversionExports.rewriteStagedCommandBodies;
const cleanupDirs = [];
const items = [];
const scope = layout.scope ?? 'global';
const rewriteOpts = {
runtime: layout.runtime,
configDir: layout.configDir,
scope,
homedir,
platform,
resolveAttribution,
};
for (const kind of layout.kinds) {
let stagedDir;
try {
stagedDir = kind.stage(resolvedProfile);
}
catch (err) {
return { ok: false, kind: 'stage_failed', message: errorMessage(err), cleanupDirs, failedKind: kind.kind };
}
let sourceDir = stagedDir;
try {
if (kind.kind === 'commands') {
const rewrittenDir = rewriteStagedCommandBodies(stagedDir, rewriteOpts);
sourceDir = addCleanupDir(cleanupDirs, stagedDir, rewrittenDir);
}
else if (kind.kind === 'skills' || kind.kind === 'kimi-agents') {
const rewrittenDir = rewriteStagedSkillBodies(stagedDir, rewriteOpts);
sourceDir = addCleanupDir(cleanupDirs, stagedDir, rewrittenDir);
}
}
catch (err) {
return { ok: false, kind: 'rewrite_failed', message: errorMessage(err), cleanupDirs, failedKind: kind.kind };
}
items.push({
kind: kind.kind,
sourceDir,
destDir: path.join(layout.configDir, kind.destSubpath),
});
}
return { ok: true, plan: { items, cleanupDirs } };
}
function createRuntimeArtifactUninstallPlan(layout) {
return {
items: layout.kinds.map((kind) => ({
kind: kind.kind,
destDir: path.join(layout.configDir, kind.destSubpath),
})),
};
}
module.exports = { createRuntimeArtifactInstallPlan, createRuntimeArtifactUninstallPlan };

View File

@@ -157,7 +157,7 @@ A `resolved`/`test`-tier prohibition MAY carry an **optional `check` descriptor*
the wired mechanical check, so verify-phase locates it deterministically instead of inventing
`{kind, target, rule}` each run. The descriptor is captured at spec-phase (soft / optional —
the author wires it when the negative test or lint rule already exists) and is represented as
**four flat scalar keys** on the `must_haves.prohibitions` item — never a nested `check: {}`
**five flat scalar keys** on the `must_haves.prohibitions` item — never a nested `check: {}`
object:
- `check_kind` — `node-test` | `lint-rule` (which producer mechanism runs the check).
@@ -165,16 +165,19 @@ object:
- `check_rule` — the `ruleId` to filter on, **lint-rule only** (absent for `node-test`).
- `check_violation_fixture` — path to a KNOWN-BAD subject the #1279 prover runs the check against to
machine-prove fail-first (rides BOTH kinds; for `node-test` it is injected via `GSD_PROHIB_SUBJECT`).
- `check_clean_fixture` — **optional** path to a KNOWN-CLEAN control subject (#1346). When present the
node-test prover also runs the check against it and requires GREEN, proving the violation's RED is
caused by the subject's *content* (not merely by `GSD_PROHIB_SUBJECT` being set). Absent → no control.
The flat-scalar shape is load-bearing: the shared `parseMustHavesBlock` is a flat parser and a
nested object would flatten/mangle the round-trip (ADR-550 2026-06-15 addendum; #644 "no parser
rewrite" precedent). `projectProhibitions` emits these keys **only for a well-formed descriptor**
(valid `check_kind` + non-empty `check_target`; `check_rule` only on the lint-rule path;
`check_violation_fixture` only when non-empty), and verify-phase reads them back via
`descriptorFromProjection` into the `CheckDescriptor` handed to `check prohibition-enforcement`. This
closes **both** the locate (#1278) and the machine-proof-fixture (#1346) halves with **zero manual
descriptor authoring**: a prohibition authored with all four scalars greens end-to-end through the
projection alone.
`check_violation_fixture` and `check_clean_fixture` only when non-empty), and verify-phase reads them
back via `descriptorFromProjection` into the `CheckDescriptor` handed to `check prohibition-enforcement`.
This closes the locate (#1278), the machine-proof-fixture (#1279), and the causation-control (#1346)
halves with **zero manual descriptor authoring**: a prohibition authored with the scalars greens
end-to-end through the projection alone.
**Fail-closed + backward-compat.** A partial descriptor (`lint-rule` missing `check_rule`), an
unknown `check_kind`, an **absent** descriptor, OR a descriptor with **no `check_violation_fixture`**
@@ -182,8 +185,11 @@ falls through to the producer's fail-closed paths (`located: false`, or located-
never a silent green. A prohibition with no descriptor parses and disposes byte-identically to today.
`failFirst` is **not** sourced from the descriptor and is **demoted** (machine-proven fail-first
DELIVERED in #1279 — no path greens on attestation alone, FF-08); the `dispositionForProhibition`
policy is unchanged. Residual (tracked **#1346**): the node-test proof confirms the fixture exists and
the check goes RED, but cannot generically prove the red was *caused by* the subject's content.
policy is unchanged. Causation (**#1346**): the node-test proof confirms the fixture exists and the
check goes RED; supplying `check_clean_fixture` adds an opt-in control that *also* requires GREEN on a
known-clean subject, proving the red is content-caused. With no clean fixture the control cannot run,
so that one residual case (a deceptive test reding merely because the env var is set) stays a
documented constraint — an author opts into the stronger proof by wiring a clean control subject.
## Output schema
@@ -191,7 +197,7 @@ The probe emits, per kept prohibition, an item of the form:
```
{ requirement_id, category, status, verification, resolution, reason, statement,
check_kind?, check_target?, check_rule? }
check_kind?, check_target?, check_rule?, check_violation_fixture?, check_clean_fixture? }
```
where `statement` is the must-NOT sentence and `category` is the values/safety/ethics class

View File

@@ -61,7 +61,7 @@ fi
When `--only` is set, also set `FROM_PHASE` to the same value so existing filter logic applies.
When `--interactive` is set, discuss runs inline with questions (not auto-answered). On runtimes where a backgrounded agent can spawn subagents, plan and execute are dispatched as background agents — keeping the main context lean (only discuss conversations accumulate) and enabling overlap. On Claude Code, where a backgrounded agent cannot nest subagents, plan and execute run inline to preserve worktree isolation and independent verification, so they run sequentially and their work accumulates in the main context. Either way, user input is preserved on all design decisions.
When `--interactive` is set, discuss runs inline with questions (not auto-answered). On Codex, where a backgrounded agent can still spawn subagents, plan and execute are dispatched as background agents — keeping the main context lean (only discuss conversations accumulate) and enabling overlap. On every other runtime (Claude Code and all other non-Codex runtimes), backgrounded agents cannot reliably nest subagents, so plan and execute run inline to preserve worktree isolation and independent verification, and phases run sequentially with their work accumulating in the main context. Either way, user input is preserved on all design decisions.
When `PLAN_STRATEGY=converge`, the planning step MUST invoke the plan-review convergence workflow instead of `gsd-plan-phase`. `--cross-ai` is an alias for `--converge`. Forward `CONVERGENCE_ARGS` exactly as parsed so reviewer flags and `--max-cycles N` retain the same meaning as they have on `/gsd:plan-review-convergence`.
@@ -111,7 +111,7 @@ Display startup banner:
If `ONLY_PHASE` is set, display: `Single phase mode: Phase ${ONLY_PHASE}`
Else if `FROM_PHASE` is set, display: `Starting from phase ${FROM_PHASE}`
If `TO_PHASE` is set, display: `Stopping after phase ${TO_PHASE}`
If `INTERACTIVE` is set, display: `Mode: Interactive (discuss inline, plan+execute in background)`
If `INTERACTIVE` is set, display: `Mode: Interactive (discuss inline, plan+execute inline — background on Codex only)`
If `PLAN_STRATEGY` is `converge`, display: `Planning: Plan-review convergence enabled`
</step>
@@ -357,27 +357,13 @@ UI_SPEC_FILE=$(ls "${PHASE_DIR}"/*-UI-SPEC.md 2>/dev/null | head -1)
**3b. Plan**
**If `INTERACTIVE` is set:** Background dispatch is only safe where a backgrounded agent can still spawn subagents. On Claude Code a backgrounded agent has no `Agent`/`Task` tool, so the plan-checker never runs and `workflow.plan_check` silently degrades to a self-check. Resolve the runtime first:
**If `INTERACTIVE` is set:** Background dispatch is only safe on a runtime where a backgrounded agent can still nest the pipeline's subagents (plan-checker / worktree executors / verifier). Among supported runtimes only **Codex** (`spawn_agent`) can do this; Claude Code's backgrounded agents have no `Agent`/`Task` tool, and every other runtime either prohibits nested subagents or disables them by default. So run **inline** everywhere except Codex, which is dispatched in the background. Resolve the runtime first:
```bash
RUNTIME=$(gsd_run query config-get runtime --default claude 2>/dev/null || echo "claude")
RUNTIME=$(gsd_run query config-get runtime --default claude --raw 2>/dev/null || echo "claude")
```
- **On Claude Code (`RUNTIME` is `claude`):** Run plan **inline** (do NOT background) so the plan-checker runs. The next phase's discuss does not overlap planning here — correctness over overlap.
- If `PLAN_STRATEGY=converge`:
```
Skill(skill="gsd-plan-review-convergence", args="${PHASE_NUM} ${CONVERGENCE_ARGS}")
```
- Otherwise (local planning):
```
Skill(skill="gsd-plan-phase", args="${PHASE_NUM}")
```
- **On other runtimes:** Dispatch plan as a background agent to keep the main context lean. While plan runs, the workflow can immediately start discussing the next phase (see step 4).
- **If `RUNTIME` is `codex`:** Dispatch plan as a background agent to keep the main context lean. While plan runs, the workflow can immediately start discussing the next phase (see step 4).
- If `PLAN_STRATEGY=converge`, print: `◆ Spawning background plan-convergence loop for phase ${PHASE_NUM}... (runs in a subagent — no output until it returns, ~1–5 min; expected, not a freeze)`
@@ -401,6 +387,20 @@ RUNTIME=$(gsd_run query config-get runtime --default claude 2>/dev/null || echo
Store the agent task_id. After discuss for the next phase completes (or if no next phase), wait for the plan agent to finish before proceeding to execute.
- **Otherwise (Claude Code or any other non-Codex runtime):** Run plan **inline** (do NOT background) so the plan-checker runs. The next phase's discuss does not overlap planning here — correctness over overlap.
- If `PLAN_STRATEGY=converge`:
```
Skill(skill="gsd-plan-review-convergence", args="${PHASE_NUM} ${CONVERGENCE_ARGS}")
```
- Otherwise (local planning):
```
Skill(skill="gsd-plan-phase", args="${PHASE_NUM}")
```
**If `INTERACTIVE` is NOT set (default):** Run plan inline.
If `PLAN_STRATEGY=converge`, run the convergence loop:
@@ -419,19 +419,13 @@ Verify plan produced output — re-run `init phase-op` and check `has_plans`. If
**3c. Execute**
**If `INTERACTIVE` is set:** Wait for the plan agent to complete (if not already) and verify plans exist. Background dispatch is only safe where a backgrounded agent can still spawn subagents. On Claude Code a backgrounded agent has no `Agent`/`Task` tool, so the per-plan worktree-isolated executors and the verifier never run (`workflow.use_worktrees` and `workflow.verifier` silently degrade). Resolve the runtime first:
**If `INTERACTIVE` is set:** Wait for the plan agent to complete (if not already) and verify plans exist. Background dispatch is only safe on a runtime where a backgrounded agent can still nest the pipeline's subagents (plan-checker / worktree executors / verifier). Among supported runtimes only **Codex** (`spawn_agent`) can do this; Claude Code's backgrounded agents have no `Agent`/`Task` tool, and every other runtime either prohibits nested subagents or disables them by default. So run **inline** everywhere except Codex, which is dispatched in the background. Resolve the runtime first:
```bash
RUNTIME=$(gsd_run query config-get runtime --default claude 2>/dev/null || echo "claude")
RUNTIME=$(gsd_run query config-get runtime --default claude --raw 2>/dev/null || echo "claude")
```
- **On Claude Code (`RUNTIME` is `claude`):** Run execute **inline** (do NOT background) so worktree isolation and verification run:
```
Skill(skill="gsd-execute-phase", args="${PHASE_NUM} --no-transition")
```
- **On other runtimes:** Dispatch execute as a background agent:
- **If `RUNTIME` is `codex`:** Dispatch execute as a background agent:
```
Agent(
@@ -443,6 +437,12 @@ Agent(
Store the agent task_id. The workflow can now start discussing the next phase while this phase executes in the background. Before starting post-execution routing for this phase, wait for the execute agent to complete.
- **Otherwise (Claude Code or any other non-Codex runtime):** Run execute **inline** (do NOT background) so worktree isolation and verification run:
```
Skill(skill="gsd-execute-phase", args="${PHASE_NUM} --no-transition")
```
**If `INTERACTIVE` is NOT set (default):** Run execute inline as before.
```
@@ -656,12 +656,12 @@ Check for blockers in the Blockers/Concerns section. If blockers are found, go t
If incomplete phases remain: proceed to next phase, loop back to execute_phase.
**Interactive mode overlap:** When `INTERACTIVE` is set, the iterate step enables pipeline parallelism **on runtimes where a backgrounded agent can spawn subagents** (on Claude Code, plan/execute run inline — see 3b/3c — so there is no overlap and phases run sequentially):
**Interactive mode overlap:** When `INTERACTIVE` is set, the iterate step enables pipeline parallelism **on Codex** (on every other runtime, plan/execute run inline — see 3b/3c — so there is no overlap and phases run sequentially):
1. After discuss completes for Phase N, dispatch plan+execute as background agents
2. Immediately start discuss for Phase N+1 (the next incomplete phase) while Phase N builds
3. Before starting plan for Phase N+1, wait for Phase N's execute agent to complete and handle its post-execution routing (verification, gap closure, etc.)
This means the user is always answering discuss questions (lightweight, interactive) while the heavy work (planning, code generation) runs in the background. The main context only accumulates discuss conversations — plan and execute contexts are isolated in their agents. (On Claude Code, plan and execute run inline, so they run sequentially and their work accumulates in the main context.)
This means the user is always answering discuss questions (lightweight, interactive) while the heavy work (planning, code generation) runs in the background. The main context only accumulates discuss conversations — plan and execute contexts are isolated in their agents. (On Claude Code and all other non-Codex runtimes, plan and execute run inline, so they run sequentially and their work accumulates in the main context.)
If all phases complete, proceed to lifecycle step.
@@ -873,9 +873,9 @@ When any phase operation fails or a blocker is detected, present 3 options via A
- [ ] `--to N` handle_blocker resume message preserves --to flag
- [ ] `--to N` skips lifecycle when not all milestone phases complete
- [ ] `--interactive` runs discuss inline via gsd-discuss-phase (asks questions, waits for user)
- [ ] `--interactive` dispatches plan and execute as background agents on runtimes that support nested background dispatch; runs them inline on Claude Code
- [ ] `--interactive` enables pipeline parallelism (discuss Phase N+1 while Phase N builds) on runtimes with background dispatch; phases run sequentially on Claude Code
- [ ] `--interactive` main context only accumulates discuss conversations on runtimes with background dispatch (on Claude Code, inline plan/execute also accumulate)
- [ ] `--interactive` dispatches plan and execute as background agents on Codex (the only runtime where a backgrounded agent can nest subagents); runs them inline on all other runtimes
- [ ] `--interactive` enables pipeline parallelism (discuss Phase N+1 while Phase N builds) on Codex; phases run sequentially on all other runtimes
- [ ] `--interactive` main context only accumulates discuss conversations on Codex (on all other runtimes, inline plan/execute also accumulate)
- [ ] `--interactive` waits for background agents before post-execution routing
- [ ] `--interactive` compatible with `--only`, `--from`, and `--to` flags
- [ ] `--converge` routes planning through `gsd-plan-review-convergence`

View File

@@ -59,7 +59,12 @@ gaps = [
```bash
_GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"; if [ -f "$GSD_TOOLS" ]; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif command -v gsd-tools >/dev/null 2>&1; then GSD_TOOLS="$(command -v gsd-tools)"; gsd_run() { "$GSD_TOOLS" "$@"; }; elif [ -f "$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and gsd-tools is not on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi; if [ -n "${CLAUDE_ENV_FILE:-}" ] && [ -n "${GSD_TOOLS:-}" ]; then printf "export PATH='%s':\"\$PATH\"\n" "${GSD_TOOLS%/*}" >> "$CLAUDE_ENV_FILE" 2>/dev/null || true; fi
USE_WORKTREES=$(gsd_run query config-get workflow.use_worktrees 2>/dev/null || echo "true")
USE_WORKTREES=$(gsd_run query config-get workflow.use_worktrees --raw 2>/dev/null || echo "true")
RUNTIME=$(gsd_run query config-get runtime --default claude --raw 2>/dev/null || echo "claude")
if [ "$RUNTIME" != "claude" ] && [ "$USE_WORKTREES" != "false" ]; then
echo "FATAL: git worktree isolation (isolation=\"worktree\") is unsupported on runtime '$RUNTIME' — it would run executor agents unisolated against the main checkout. Set workflow.use_worktrees=false." >&2
exit 1
fi
```
**Report diagnosis plan to user:**

View File

@@ -91,13 +91,13 @@ Parse JSON for: `executor_model`, `verifier_model`, `commit_docs`, `parallelizat
Read runtime/worktree config and fail closed before any executor dispatch:
```bash
RUNTIME=$(gsd_run query config-get runtime --default claude 2>/dev/null || echo "claude")
USE_WORKTREES=$(gsd_run query config-get workflow.use_worktrees 2>/dev/null || echo "true")
RUNTIME=$(gsd_run query config-get runtime --default claude --raw 2>/dev/null || echo "claude")
USE_WORKTREES=$(gsd_run query config-get workflow.use_worktrees --raw 2>/dev/null || echo "true")
EXECUTOR_STALL_INTERVAL_MINUTES=$(gsd_run query config-get executor.stall_detect_interval_minutes 2>/dev/null || echo "5")
EXECUTOR_STALL_THRESHOLD_MINUTES=$(gsd_run query config-get executor.stall_threshold_minutes 2>/dev/null || echo "10")
if [ "$RUNTIME" = "codex" ] && [ "$USE_WORKTREES" != "false" ]; then
echo "FATAL: Codex execute-phase worktree isolation is unsupported. Set workflow.use_worktrees=false or use a runtime with Agent isolation=\"worktree\" support." >&2
if [ "$RUNTIME" != "claude" ] && [ "$USE_WORKTREES" != "false" ]; then
echo "FATAL: git worktree isolation (isolation=\"worktree\") is unsupported on runtime '$RUNTIME' — it would run executor agents unisolated against the main checkout. Set workflow.use_worktrees=false." >&2
exit 1
fi
# Sweep orphaned locked worktrees from prior crashed sessions before spawning executors (#3707).
@@ -113,7 +113,7 @@ if [ "$RUNTIME" = "claude" ] && [ "$USE_WORKTREES" != "false" ]; then
fi
fi
```
Codex maps subagents to `spawn_agent`, which has no direct Codex mapping for Claude Code's `isolation="worktree"` parameter. Failing closed prevents main-checkout edits while the workflow believes agents are isolated.
`isolation="worktree"` is a Claude-Code-specific agent primitive; no other runtime can honor it (Codex maps subagents to `spawn_agent`, others prohibit or omit worktree binding). Failing closed prevents main-checkout edits while the workflow believes agents are isolated.
If the project uses git submodules, worktree isolation is unsafe **only when a plan touches a submodule path** — the executor commit protocol cannot correctly handle submodule commits inside isolated worktrees. The previous behavior unconditionally disabled worktree isolation whenever `.gitmodules` existed, which penalised every plan in a submodule project even when the plan was nowhere near a submodule. Compute submodule paths once and intersect them per-plan with the plan's declared `files_modified` frontmatter.
@@ -687,7 +687,7 @@ increases monotonically across waves. `{status}` is `complete` (success),
)
```
After each `Agent()` returns, parse executor-returned worktree metadata (`<worktree_metadata>`) before harness metadata, then atomically append `{agent_id, worktree_path, branch, expected_base}` to `WAVE_WORKTREE_MANIFEST`. Missing: stop and ask for recovery instead of scanning worktrees.
After each `Agent()` returns, parse executor-returned worktree metadata (`<worktree_metadata>`) before harness metadata, then record the `{agent_id, worktree_path, branch, expected_base}` entry with `gsd_run query worktree.record-agent --manifest "$WAVE_WORKTREE_MANIFEST" --agent-id … --path … --branch … --base …`. The verb validates every field at write time using the same rules the `cleanup-wave` reader enforces (write-strict `--agent-id`), failing loudly with a non-zero exit and recovery hint rather than appending an under-populated entry the reader would later drop silently. On a non-zero exit or any missing field: stop and ask for recovery instead of scanning worktrees.
> **Worktree recovery policy (#48 + #1292):** See `execute-phase/steps/worktree-recovery-policy.md` — FAIL-CLOSED rule for base/HEAD-namespace mismatches AND isolated-run fail-safe recovery.

View File

@@ -394,6 +394,16 @@ List pending todos and select one to work on.
Usage: `/gsd:capture --list`
Usage: `/gsd:capture --list api`
**`/gsd:capture --list-seeds [status]`**
List and audit captured seeds (read-only).
- Lists all seeds with ID, status, scope, trigger, and title
- Optional status filter (e.g., `/gsd:capture --list-seeds dormant`)
- Does not modify any seed — enrich with `/gsd:capture --seed --enrich SEED-NNN`
Usage: `/gsd:capture --list-seeds`
Usage: `/gsd:capture --list-seeds dormant`
### User Acceptance Testing
**`/gsd:verify-work [phase]`**

View File

@@ -0,0 +1,63 @@
<purpose>
List captured seeds for browsing and audit, with an optional status filter. Read-only — never mutates seeds.
</purpose>
<required_reading>
Read all files referenced by the invoking prompt's execution_context before starting.
</required_reading>
<process>
<step name="load_seeds">
Load seed context. An optional status filter (e.g. `dormant`, `active`, `triggered`) may follow `--list-seeds`.
```bash
_GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"; if [ -f "$GSD_TOOLS" ]; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif command -v gsd-tools >/dev/null 2>&1; then GSD_TOOLS="$(command -v gsd-tools)"; gsd_run() { "$GSD_TOOLS" "$@"; }; elif [ -f "$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and gsd-tools is not on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi; if [ -n "${CLAUDE_ENV_FILE:-}" ] && [ -n "${GSD_TOOLS:-}" ]; then printf "export PATH='%s':\"\$PATH\"\n" "${GSD_TOOLS%/*}" >> "$CLAUDE_ENV_FILE" 2>/dev/null || true; fi
SEEDS=$(gsd_run list-seeds "$STATUS_FILTER")
if [[ "$SEEDS" == @file:* ]]; then SEEDS=$(cat "${SEEDS#@file:}"); fi
```
Replace `$STATUS_FILTER` with the filter token from `$ARGUMENTS` if one was given, otherwise omit it.
Extract from the JSON: `count`, `seeds[]` (each has `seed_id`, `status`, `scope`, `trigger_when`, `planted`, `title`), and `summary` (a `{ status: count }` map).
</step>
<step name="empty_case">
If `count` is 0:
```
No seeds found.
Plant one with /gsd:capture --seed "<forward-looking idea>".
```
(If a status filter was given and nothing matched, say so: `No seeds with status "<filter>".`) Exit.
</step>
<step name="render_table">
Render the seeds as a table, sorted by `seed_id` (already sorted by the tool). Truncate `trigger_when` and `title` to keep the table readable.
```
Seeds
─────────────────────────────────────────────────────────────────────
ID Status Scope Trigger Title
SEED-001 dormant large when websockets land Real-time collaboration
SEED-006 triggered medium MILE-04 planning Remove legacy auth crates
─────────────────────────────────────────────────────────────────────
<count> seeds (<summary rendered as "N status" pairs, e.g. "1 dormant, 1 triggered">)
```
Then offer next actions as plain text (no mutation here):
```
- /gsd:capture --seed --enrich <ID> enrich a seed with trigger, why, and scope
- /gsd:capture --list-seeds <status> filter by status
```
</step>
</process>
<success_criteria>
- [ ] Seeds listed with ID, status, scope, trigger, and title
- [ ] Status filter applied when provided
- [ ] Empty / no-match case handled with guidance
- [ ] Summary line shows total and per-status counts
- [ ] No seed files were modified (read-only)
</success_criteria>

View File

@@ -1,6 +1,6 @@
<purpose>
Interactive command center for managing a milestone from a single terminal. Shows a dashboard of all phases with visual status, dispatches discuss inline and plan/execute as background agents, and loops back to the dashboard after each action. Enables parallel phase work from one terminal.
Interactive command center for managing a milestone from a single terminal. Shows a dashboard of all phases with visual status, dispatches discuss inline and runs plan/execute inline (backgrounded only on Codex), and loops back to the dashboard after each action. Enables parallel phase work from one terminal.
</purpose>
@@ -45,7 +45,7 @@ Display startup banner:
{milestone_version} — {milestone_name}
{phase_count} phases · {completed_count} complete
✓ Discuss → inline ◆ Plan/Execute → background
✓ Discuss → inline ◆ Plan/Execute → inline (background on Codex)
Dashboard auto-refreshes when background work is active.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
```
@@ -221,8 +221,8 @@ Go to exit step.
When the user selects a compound option, behavior depends on the runtime — the Plan Phase N / Execute Phase N handlers below resolve it via `gsd_run query config-get runtime`:
- **On Claude Code:** a backgrounded agent cannot nest the pipeline's subagents, so run the chosen plan/execute step(s) **inline** via their handlers below (in order), then run the inline discuss. There is no overlap.
- **On other runtimes:** **Spawn all background agents first** (plan/execute) — dispatch them in parallel using the Plan Phase N / Execute Phase N handlers below — then run the inline discuss; the background agents continue while you discuss.
- **On Codex:** **Spawn all background agents first** (plan/execute) — dispatch them in parallel using the Plan Phase N / Execute Phase N handlers below — then run the inline discuss; the background agents continue while you discuss.
- **Otherwise (Claude Code or any other non-Codex runtime):** a backgrounded agent cannot reliably nest the pipeline's subagents, so run the chosen plan/execute step(s) **inline** via their handlers below (in order), then run the inline discuss. There is no overlap.
Inline discuss:
@@ -244,27 +244,13 @@ After discuss completes, loop back to dashboard step.
### Plan Phase N
Planning runs autonomously. **First resolve the runtime.** On Claude Code a backgrounded agent has no `Agent`/`Task` tool, so it cannot spawn the plan-checker the pipeline relies on — backgrounding it there silently turns `workflow.plan_check` into a self-check. So run plan **inline** on Claude Code, and **background** it only on runtimes where a backgrounded agent can still nest subagents.
Planning runs autonomously. **First resolve the runtime.** Background dispatch is only safe on a runtime where a backgrounded agent can still nest the pipeline's subagents (plan-checker / worktree executors / verifier). Among supported runtimes only **Codex** (`spawn_agent`) can do this; Claude Code's backgrounded agents have no `Agent`/`Task` tool, and every other runtime either prohibits nested subagents or disables them by default. So run **inline** everywhere except Codex, which is dispatched in the background.
```bash
RUNTIME=$(gsd_run query config-get runtime --default claude 2>/dev/null || echo "claude")
RUNTIME=$(gsd_run query config-get runtime --default claude --raw 2>/dev/null || echo "claude")
```
**If `RUNTIME` is `claude` (Claude Code):** Run plan inline so the plan-checker and quality gates actually run — do NOT wrap it in `Agent(run_in_background=true, …)`:
```
Skill(skill="gsd-plan-phase", args="{N} --auto {manager_flags.plan}")
```
Display while it runs:
```
◆ Planning Phase {N}: {phase_name}... (runs inline so the plan-checker runs — the dashboard resumes when it returns, ~1–5 min; expected, not a freeze)
```
Then loop back to dashboard step.
**If `RUNTIME` is not `claude` (e.g. Codex):** Spawn a background agent that delegates to the Skill pipeline with any configured flags:
**If `RUNTIME` is `codex`:** Spawn a background agent that delegates to the Skill pipeline with any configured flags:
```
Agent(
@@ -286,7 +272,7 @@ Important: You are running in the background. Do NOT use AskUserQuestion — mak
)
```
> **ORCHESTRATOR RULE — NON-CLAUDE RUNTIME**: After calling Agent() above with `run_in_background=true`, do NOT do any planning work for this phase independently. Return to the dashboard immediately and wait for the background agent to report back. Only resume planning-related work when the subagent result is available.
> **ORCHESTRATOR RULE — CODEX RUNTIME**: After calling Agent() above with `run_in_background=true`, do NOT do any planning work for this phase independently. Return to the dashboard immediately and wait for the background agent to report back. Only resume planning-related work when the subagent result is available.
Display:
@@ -296,29 +282,29 @@ Display:
Loop back to dashboard step.
### Execute Phase N
Execution runs autonomously. **First resolve the runtime.** On Claude Code a backgrounded agent has no `Agent`/`Task` tool, so it cannot spawn the per-plan worktree-isolated executors or the verifier — backgrounding it there silently disables `workflow.use_worktrees` isolation and `workflow.verifier`. So run execute **inline** on Claude Code, and **background** it only on runtimes where a backgrounded agent can still nest subagents.
```bash
RUNTIME=$(gsd_run query config-get runtime --default claude 2>/dev/null || echo "claude")
```
**If `RUNTIME` is `claude` (Claude Code):** Run execute inline so worktree isolation and the verifier actually run — do NOT wrap it in `Agent(run_in_background=true, …)`:
**Otherwise (Claude Code or any other non-Codex runtime):** Run plan inline so the plan-checker and quality gates actually run — do NOT wrap it in `Agent(run_in_background=true, …)`:
```
Skill(skill="gsd-execute-phase", args="{N} {manager_flags.execute}")
Skill(skill="gsd-plan-phase", args="{N} --auto {manager_flags.plan}")
```
Display while it runs:
```
◆ Executing Phase {N}: {phase_name}... (runs inline so worktree isolation and verification run — the dashboard resumes when it returns; expected, not a freeze)
◆ Planning Phase {N}: {phase_name}... (runs inline so the plan-checker runs — the dashboard resumes when it returns, ~1–5 min; expected, not a freeze)
```
Then loop back to dashboard step.
**If `RUNTIME` is not `claude` (e.g. Codex):** Spawn a background agent that delegates to the Skill pipeline with any configured flags:
### Execute Phase N
Execution runs autonomously. **First resolve the runtime.** Background dispatch is only safe on a runtime where a backgrounded agent can still nest the pipeline's subagents (plan-checker / worktree executors / verifier). Among supported runtimes only **Codex** (`spawn_agent`) can do this; Claude Code's backgrounded agents have no `Agent`/`Task` tool, and every other runtime either prohibits nested subagents or disables them by default. So run **inline** everywhere except Codex, which is dispatched in the background.
```bash
RUNTIME=$(gsd_run query config-get runtime --default claude --raw 2>/dev/null || echo "claude")
```
**If `RUNTIME` is `codex`:** Spawn a background agent that delegates to the Skill pipeline with any configured flags:
```
Agent(
@@ -340,7 +326,7 @@ Important: You are running in the background. Do NOT use AskUserQuestion — mak
)
```
> **ORCHESTRATOR RULE — NON-CLAUDE RUNTIME**: After calling Agent() above with `run_in_background=true`, do NOT do any execution work for this phase independently. Return to the dashboard immediately and wait for the background agent to report back. Only resume execution-related work when the subagent result is available.
> **ORCHESTRATOR RULE — CODEX RUNTIME**: After calling Agent() above with `run_in_background=true`, do NOT do any execution work for this phase independently. Return to the dashboard immediately and wait for the background agent to report back. Only resume execution-related work when the subagent result is available.
Display:
@@ -350,6 +336,20 @@ Display:
Loop back to dashboard step.
**Otherwise (Claude Code or any other non-Codex runtime):** Run execute inline so worktree isolation and the verifier actually run — do NOT wrap it in `Agent(run_in_background=true, …)`:
```
Skill(skill="gsd-execute-phase", args="{N} {manager_flags.execute}")
```
Display while it runs:
```
◆ Executing Phase {N}: {phase_name}... (runs inline so worktree isolation and verification run — the dashboard resumes when it returns; expected, not a freeze)
```
Then loop back to dashboard step.
</step>
<step name="background_completion">
@@ -422,8 +422,8 @@ Display final status with progress bar:
- [ ] Dependency resolution: blocked phases show which deps are missing
- [ ] Recommendations prioritize: execute > plan > discuss
- [ ] Discuss phases run inline via Skill() — interactive questions work
- [ ] Plan phases spawn background Task agents — return to dashboard immediately
- [ ] Execute phases spawn background Task agents — return to dashboard immediately
- [ ] Plan phases run inline (or as background Task agents on Codex) — dashboard resumes when complete
- [ ] Execute phases run inline (or as background Task agents on Codex) — dashboard resumes when complete
- [ ] Dashboard refreshes pick up changes from background agents via disk state
- [ ] Background agent completion triggers notification and dashboard refresh
- [ ] Background agent errors present retry/skip options

View File

@@ -109,9 +109,9 @@ elif [ -n "$OPENCODE_CONFIG_DIR" ] || [ -n "$OPENCODE_CONFIG" ]; then RUNTIME="o
else RUNTIME="claude"; fi
```
Set the instruction file variable:
Set the instruction file variable via the shared runtime-name policy adapter (`gsd-tools query project-instruction-file`, backed by `getProjectInstructionFile` in `runtime-name-policy.cjs` — the single source of truth shared with `profile-output.cjs`):
```bash
if [ "$RUNTIME" = "codex" ]; then INSTRUCTION_FILE="AGENTS.md"; else INSTRUCTION_FILE=".claude/CLAUDE.md"; fi
INSTRUCTION_FILE=$(gsd_run query project-instruction-file --runtime "$RUNTIME")
```
All subsequent references to the project instruction file use `$INSTRUCTION_FILE`.
@@ -1533,7 +1533,7 @@ PHASE1_HAS_UI=$(echo "$PHASE1_SECTION" | grep -qi "UI hint.*yes" && echo "true"
- `.planning/REQUIREMENTS.md`
- `.planning/ROADMAP.md`
- `.planning/STATE.md`
- `$INSTRUCTION_FILE` (`AGENTS.md` for Codex, `.claude/CLAUDE.md` for all other runtimes)
- `$INSTRUCTION_FILE` (runtime-derived via the shared `getProjectInstructionFile` policy: `AGENTS.md` for codex/opencode/kilo/kimi, `.github/copilot-instructions.md` for copilot, `GEMINI.md` for gemini/antigravity, `.claude/CLAUDE.md` for claude)
</output>
@@ -1555,7 +1555,7 @@ PHASE1_HAS_UI=$(echo "$PHASE1_SECTION" | grep -qi "UI hint.*yes" && echo "true"
- [ ] ROADMAP.md created with phases, requirement mappings, success criteria
- [ ] STATE.md initialized
- [ ] REQUIREMENTS.md traceability updated
- [ ] `$INSTRUCTION_FILE` generated with GSD workflow guidance (AGENTS.md for Codex, `.claude/CLAUDE.md` otherwise; an existing hand-crafted file without GSD markers is left untouched unless `--force`)
- [ ] `$INSTRUCTION_FILE` generated with GSD workflow guidance (runtime-derived via the shared `getProjectInstructionFile` policy — `AGENTS.md` for codex/opencode/kilo/kimi, `.github/copilot-instructions.md` for copilot, `GEMINI.md` for gemini/antigravity, `.claude/CLAUDE.md` for claude; an existing hand-crafted file without GSD markers is left untouched unless `--force`)
- [ ] User knows next step is `/gsd:discuss-phase 1`
**Atomic commits:** Each phase commits its artifacts immediately. If context is lost, artifacts persist.

View File

@@ -43,6 +43,162 @@ Commits: {AHEAD} ahead
```
</step>
<step name="handle_sub_repos">
Read the sub-repo list from config using the canonical key path — `planning.sub_repos`.
A non-zero exit code means the key is absent; treat that as "no sub-repos configured".
```bash
SUB_REPOS_JSON=$(gsd_run query config-get planning.sub_repos 2>/dev/null)
if [ $? -ne 0 ] || [ -z "$SUB_REPOS_JSON" ] || [ "$SUB_REPOS_JSON" = "null" ] || [ "$SUB_REPOS_JSON" = "[]" ]; then
: # Not configured or empty — skip to analyze_commits
fi
```
Scan each sub-repo for uncommitted changes using node (always available — avoids undeclared
jq dependency). Write dirty repo names to a temp file so the list survives across
subsequent command executions:
```bash
ROOT=$(git rev-parse --show-toplevel)
DIRTY_FILE=$(mktemp)
node -e "
const repos = JSON.parse(process.argv[1]);
const { execFileSync } = require('child_process');
const path = require('path');
const fs = require('fs');
const root = process.argv[2];
// realpath parity with the pr-subrepo seam's validatePath: resolve $ROOT through
// symlinks once so the containment check below compares real paths, not text.
let realRoot;
try { realRoot = fs.realpathSync(root); } catch (_) { realRoot = path.resolve(root); }
const out = [];
for (const r of repos) {
// Reject before any git invocation: this scan runs on raw config values,
// ahead of the pr-subrepo seam's own validatePath guard. A traversal,
// embedded-newline, or symlink entry here would run git outside the
// workspace, or inject a spurious record into the dirty-file output.
if (typeof r !== 'string' || !/^[A-Za-z0-9._\/-]+$/.test(r)) continue;
// realpathSync follows symlinks — path.resolve only normalizes '..' textually,
// so an in-tree symlink pointing outside root would otherwise smuggle git out.
let resolved;
try { resolved = fs.realpathSync(path.resolve(realRoot, r)); } catch (_) { continue; }
if (resolved !== realRoot && !resolved.startsWith(realRoot + path.sep)) continue;
try {
const res = execFileSync('git', ['-C', resolved, 'status', '--porcelain'],
{ encoding: 'utf8', timeout: 10_000 });
// Exclude untracked-only repos: seam filters ?? lines, so detection must match.
const tracked = res.split('\n').filter(l => l.length > 0 && !l.startsWith('??'));
if (tracked.length > 0) out.push(r);
} catch (_) {}
}
fs.writeFileSync(process.argv[3], out.join('\n'));
" "$SUB_REPOS_JSON" "$ROOT" "$DIRTY_FILE"
DIRTY_REPOS=$(cat "$DIRTY_FILE")
```
If `$DIRTY_REPOS` is empty, remove the temp file and continue to `analyze_commits`.
Display dirty repos and prompt the user:
```
Sub-repos with uncommitted changes:
backend
frontend
How should sub-repo changes be handled?
1. all — branch, commit (explicit files only), push -u, open companion PR per repo
2. select — choose which sub-repos to process
3. skip — ignore sub-repos, continue with root repo only
```
If the user chooses **skip**, remove the temp file and continue to `analyze_commits`.
For each selected sub-repo `$REPO_REL`, delegate all git work to the `pr-subrepo` query
seam — it stages explicit changed files (never `git add -A`), creates the branch,
commits, and pushes with `--set-upstream`. Branch names include the repo slug to avoid
colliding with the root `PR_BRANCH` that `create_pr_branch` creates later:
```bash
# Replace path separators to make the name safe as a branch component
REPO_SAFE="${REPO_REL//\//-}"
SUB_BRANCH="${CURRENT_BRANCH}-${REPO_SAFE}-pr"
COMMIT_MSG="fix(${REPO_REL}): sync uncommitted changes for PR"
RESULT=$(gsd_run query pr-subrepo "$COMMIT_MSG" \
--repo "$REPO_REL" \
--branch "$SUB_BRANCH")
SUBREPO_EXIT=$?
```
If the seam exited non-zero (stage/commit/push failure), report its error and move on to
the next selected sub-repo. **Do not run the companion-PR step below for this repo** —
the seam's stderr already explains the failure, and the "branch pushed" path would
otherwise contradict it:
```bash
if [ "$SUBREPO_EXIT" -ne 0 ]; then
echo "pr-subrepo failed for $REPO_REL — see error above; skipping companion PR." >&2
fi
```
Only when `$SUBREPO_EXIT` is `0`, parse the structured result with node and open the
companion PR. If `remote_slug` is null (non-GitHub remote), skip `gh pr create` and show
the push URL instead:
```bash
REMOTE_SLUG=$(node -e "
try { console.log(JSON.parse(process.argv[1]).remote_slug || ''); } catch(_) {}
" "$RESULT")
if [ -n "$REMOTE_SLUG" ]; then
# Defense-in-depth: $REPO_REL was already validated by the dirty-scan filter and
# the pr-subrepo seam's validatePath, but these are separate, independent git -C
# invocations on the same value. Resolve it through symlinks with the SAME realpath
# containment the seam uses (path.resolve alone would not catch a symlink escape),
# and run git against the validated absolute path rather than re-concatenating.
SUB_REPO_DIR=$(node -e "
const fs = require('fs'), path = require('path');
try {
const realRoot = fs.realpathSync(process.argv[1]);
const resolved = fs.realpathSync(path.resolve(realRoot, process.argv[2]));
if (resolved !== realRoot && !resolved.startsWith(realRoot + path.sep)) process.exit(1);
process.stdout.write(resolved);
} catch (_) { process.exit(1); }
" "$ROOT" "$REPO_REL" 2>/dev/null)
if [ -z "$SUB_REPO_DIR" ]; then
echo "Refusing unsafe sub-repo path: $REPO_REL" >&2
SUB_TARGET="$TARGET"
else
# Resolve base branch: use $TARGET if it exists in sub-repo, else fall back to
# the sub-repo's own default branch
if git -C "$SUB_REPO_DIR" ls-remote --exit-code --heads origin "$TARGET" \
> /dev/null 2>&1; then
SUB_TARGET="$TARGET"
else
SUB_TARGET=$(git -C "$SUB_REPO_DIR" remote show origin 2>/dev/null \
| awk '/HEAD branch/ {print $NF}')
SUB_TARGET="${SUB_TARGET:-main}"
fi
fi
gh pr create \
--repo "$REMOTE_SLUG" \
--base "$SUB_TARGET" \
--head "$SUB_BRANCH" \
--title "$COMMIT_MSG" \
--body "Companion PR for root repo branch \`$CURRENT_BRANCH\`."
else
echo "No GitHub remote detected for $REPO_REL — branch pushed, open PR manually."
fi
```
After processing all selected sub-repos, remove the temp file and continue to
`analyze_commits` for the root repo.
</step>
<step name="analyze_commits">
Classify commits:

View File

@@ -137,7 +137,12 @@ AGENT_SKILLS_VERIFIER=$(gsd_run query agent-skills gsd-verifier)
Parse JSON for: `planner_model`, `executor_model`, `checker_model`, `verifier_model`, `commit_docs`, `branch_name`, `quick_id`, `slug`, `date`, `timestamp`, `quick_dir`, `task_dir`, `roadmap_exists`, `planning_exists`.
```bash
USE_WORKTREES=$(gsd_run query config-get workflow.use_worktrees 2>/dev/null || echo "true")
USE_WORKTREES=$(gsd_run query config-get workflow.use_worktrees --raw 2>/dev/null || echo "true")
RUNTIME=$(gsd_run query config-get runtime --default claude --raw 2>/dev/null || echo "claude")
if [ "$RUNTIME" != "claude" ] && [ "$USE_WORKTREES" != "false" ]; then
echo "FATAL: git worktree isolation (isolation=\"worktree\") is unsupported on runtime '$RUNTIME' — it would run executor agents unisolated against the main checkout. Set workflow.use_worktrees=false." >&2
exit 1
fi
```
If `USE_WORKTREES` is not `"false"`, run a startup orphan sweep before spawning any executors. This reaps locked worktrees whose lock-owner process is dead, whose branch is merged into the default branch, and whose lock file mtime is older than 5 minutes. Running it at startup prevents accumulation of orphaned worktrees from prior sessions that exited without cleanup (#3707).

View File

@@ -157,6 +157,14 @@ Provide structured feedback on plan quality, completeness, and risks.
## Review Instructions
**Verify against source — do not review the plan text in isolation.** You are running inside the project's git working tree (the current directory). The plans reference real files, migrations, routes, and tests that exist in this repo now.
1. Open the referenced files and check each claim against the actual code.
2. For every strength or concern, cite concrete `path/to/file:line` evidence plus the mechanism.
3. When a plan asserts a mechanism works (a guard, a query filter, a test that exercises a path), trace whether it actually does what is claimed — do not take the plan's word for it.
4. If you cannot read the repo (no file access), say so and downgrade that finding to an open question rather than asserting it.
Findings citing `file:line` evidence are weighted far more heavily than impressionistic ones; a review that only restates the plan's own claims has low value.
Analyze each plan and provide:
1. **Summary** — One-paragraph assessment
@@ -273,7 +281,7 @@ fi
**CodeRabbit:**
Note: CodeRabbit reviews the current git diff/working tree — it does not accept a prompt or model flag. It may take up to 5 minutes. Use `timeout: 360000` on the Bash tool call.
Note: CodeRabbit reviews the current git diff/working tree — it does not accept a prompt or model flag. It may take up to 5 minutes. Use `timeout: 360000` on the Bash tool call. The source-grounding requirement in the build_prompt Review Instructions applies only to the prompt-fed reviewers above; CodeRabbit is a diff-only reviewer and never receives it. Treat its output as a diff observation, not a grounded plan-level verdict.
```bash
coderabbit review --prompt-only 2>/dev/null > /tmp/gsd-review-coderabbit-{phase}.md
@@ -714,7 +722,7 @@ trimmed_reviewers: # only present if at least one reviewer was trimmed
## Consensus Summary
{synthesize common concerns across all reviewers}
{synthesize common concerns across all reviewers. CodeRabbit is a diff-only reviewer (it never received the source-grounding prompt), so do not weight its verdict as a grounded plan review — fold in its diff findings, but base plan-level consensus on the prompt-fed reviewers.}
### Agreed Strengths
{strengths mentioned by 2+ reviewers}

View File

@@ -365,10 +365,15 @@ For each Requirement gathered so far, run the two-stage recall→precision pass:
- `check_target` — the negative-test file path (for `node-test`), or the path to lint
(for `lint-rule`).
- `check_rule` — the eslint rule id (e.g. `local/no-source-grep`); `lint-rule` only.
- `check_violation_fixture` (#1346) — path to a KNOWN-BAD subject the wired check is run
- `check_violation_fixture` (#1279) — path to a KNOWN-BAD subject the wired check is run
against to **machine-prove fail-first**; rides BOTH kinds. Capture it to let the item green
end-to-end with zero hand-authoring at verify time; for `node-test` the negative test should
read its subject from the `GSD_PROHIB_SUBJECT` env var so the prover can inject this fixture.
- `check_clean_fixture` (#1346) — **optional** path to a KNOWN-CLEAN control subject. When
captured, the `node-test` prover also runs the check against it and requires GREEN — proving
the violation's RED is caused by the subject's *content*, not by `GSD_PROHIB_SUBJECT` merely
being set. Capture it for a stronger guarantee; omit it and the check still proves fail-first
on the violation alone (the content-causation residual stays documented for that case).
This is a **SOFT capture (CHK-04): a `test`-tier prohibition WITHOUT a descriptor is still
allowed** — if the author cannot yet name the wired check, leave the descriptor empty and
proceed. It is NOT a hard authoring block; the item simply stays fail-closed/flagged
@@ -395,7 +400,7 @@ For each Requirement gathered so far, run the two-stage recall→precision pass:
written (test or judgment tier); otherwise leave `unresolved`. **`--auto` NEVER auto-dismisses
a prohibition** — a wrong dismissal is the exact silent failure this probe eliminates (PROB-06,
the load-bearing safety property). On a `test`-tier auto-resolution, capture the `check_kind` /
`check_target` / `check_rule` / `check_violation_fixture` descriptor **only when a wired check is unambiguous**; otherwise
`check_target` / `check_rule` / `check_violation_fixture` / `check_clean_fixture` descriptor **only when a wired check is unambiguous**; otherwise
leave it empty — `--auto` NEVER fabricates a check path or fixture (a wrong locate is re-validated and
fails closed at the producer, but a fabricated path is still noise to avoid). Log:
`[auto] prohibitions: R resolved, U unresolved`.
@@ -408,7 +413,7 @@ Populate the `## Prohibitions` section of SPEC.md from the resolved prohibitions
`resolved`/`test` row is a checkable negative acceptance criterion; `resolved`/`judgment`
rows route to judgment review; `⚠ UNRESOLVED` rows are flagged as assumptions). A
`resolved`/`test` row ALSO carries its captured `check_kind` / `check_target` / `check_rule` /
`check_violation_fixture` descriptor when present (so the projection feeds `verify-phase`'s deterministic locate + machine-proof, #1278 + #1346);
`check_violation_fixture` / `check_clean_fixture` descriptor when present (so the projection feeds `verify-phase`'s deterministic locate + machine-proof + causation control, #1278 + #1279 + #1346);
a `test` row with no captured descriptor is still valid — it stays fail-closed/flagged
downstream rather than blocking authoring.

View File

@@ -76,11 +76,11 @@ Aggregate all must_haves across plans for phase-level verification.
gsd_run check prohibition-enforcement <request.json>
```
where `<request.json>` carries `{ prohibition, check, mode }` — `check` being the wired mechanical-check descriptor `{ kind: 'node-test' | 'lint-rule', target, rule?, violationFixture, failFirst? }`, with `kind`/`target`/`rule`/`violationFixture` now sourced from the projected `check_*` scalars (not author/verifier invention — #1278 + #1346). For `node-test`, `target` (from `check_target`) is the negative-test file path; for `lint-rule`, `target` is the PATH to lint and `rule` (from `check_rule`) is the eslint rule id (e.g. `local/no-source-grep`) — both required (a lint-rule without `rule` is not a valid wired check). `violationFixture` (from `check_violation_fixture`) is the path to a KNOWN-BAD subject the producer runs the check against to **machine-prove fail-first** (for `node-test`, injected via the `GSD_PROHIB_SUBJECT` env convention — #1279); `failFirst` is a DEMOTED, non-authoritative hint kept only for backward route-JSON shape (no path greens on it alone — FF-08). The producer LOCATES the wired check from the projection, **machine-proves it is fail-first** by running it against the violation and confirming it goes RED, RUNS it for a genuine non-vacuous pass, builds `enforcementEvidence`, and emits the `dispositionForProhibition()` verdict (#1259 + #1278 + #1279, ADR-550 D5d). Fail-first is **machine-proven, not caller-attested** — absent a provable violation the producer fails closed, never falling back to attestation. Route the result by its typed fields:
where `<request.json>` carries `{ prohibition, check, mode }` — `check` being the wired mechanical-check descriptor `{ kind: 'node-test' | 'lint-rule', target, rule?, violationFixture, cleanFixture?, failFirst? }`, with `kind`/`target`/`rule`/`violationFixture`/`cleanFixture` now sourced from the projected `check_*` scalars (not author/verifier invention — #1278 + #1279 + #1346). For `node-test`, `target` (from `check_target`) is the negative-test file path; for `lint-rule`, `target` is the PATH to lint and `rule` (from `check_rule`) is the eslint rule id (e.g. `local/no-source-grep`) — both required (a lint-rule without `rule` is not a valid wired check). `violationFixture` (from `check_violation_fixture`) is the path to a KNOWN-BAD subject the producer runs the check against to **machine-prove fail-first** (for `node-test`, injected via the `GSD_PROHIB_SUBJECT` env convention — #1279); the optional `cleanFixture` (from `check_clean_fixture`) is a KNOWN-CLEAN control subject the `node-test` prover ALSO requires to stay GREEN, proving the RED is content-caused (#1346); `failFirst` is a DEMOTED, non-authoritative hint kept only for backward route-JSON shape (no path greens on it alone — FF-08). The producer LOCATES the wired check from the projection, **machine-proves it is fail-first** by running it against the violation and confirming it goes RED, RUNS it for a genuine non-vacuous pass, builds `enforcementEvidence`, and emits the `dispositionForProhibition()` verdict (#1259 + #1278 + #1279, ADR-550 D5d). Fail-first is **machine-proven, not caller-attested** — absent a provable violation the producer fails closed, never falling back to attestation. Route the result by its typed fields:
- **`status: 'green'`, `flagged: false`** (a genuinely-passing wired negative test / lint rule, `located: true`, non-empty `evidence`) → the item is satisfiable → it can reach **passed**.
- **missing, non-attested, or genuinely-non-passing check** (`located: false` OR `status: 'unverified'`, `flagged: true`) → **hard-gate**: disposes flagged-unverified, NEVER green, routing to `gaps_found` in BOTH interactive and autonomous modes (a failing mechanical check blocks even AFK; ADR-550 D4 / D3). The deterministic fail-closed default backing every miss/fail is `dispositionForProhibition()` in probe-core (`status: 'unverified'`, `flagged: true` on empty `enforcementEvidence`).
> **Descriptor source — deterministic locate + machine-proof compose (#1278 + #1346, DELIVERED).** The `check` descriptor's `{ kind, target, rule, violationFixture }` is now sourced **deterministically from the projected `check_kind` / `check_target` / `check_rule` / `check_violation_fixture` scalars** on the `must_haves.prohibitions` item (authored at `/gsd:spec-phase`, projected by `projectProhibitions`, read back via the `descriptorFromProjection` adapter). So both halves close with **zero manual descriptor authoring** — the verifier neither invents the locate (#1278) nor hand-supplies the violation fixture (#1346): a prohibition authored with all four scalars machine-proves fail-first and greens end-to-end through the projection alone (removing the spoofable invent-at-verify-time surface; ADR-857 §147 exogenous grading). **Fail-closed is preserved:** an item with NO projected descriptor, a PARTIAL one (e.g. a `lint-rule` missing `check_rule`), OR a descriptor with **no `check_violation_fixture`** makes `descriptorFromProjection` return `null` / an under-specified or fixture-less descriptor, which falls through to the producer's fail-closed paths (`located: false`, or located-but-unprovable) → flagged-unverified, NEVER green, in BOTH modes. `failFirst` is demoted and greens nothing on its own (#1279, FF-08). Residual (tracked **#1346**): the node-test proof confirms the fixture exists and the check goes RED, but cannot generically prove the red was *caused by* the subject's content vs the env merely being set.
> **Descriptor source — deterministic locate + machine-proof compose (#1278 + #1346, DELIVERED).** The `check` descriptor's `{ kind, target, rule, violationFixture }` is now sourced **deterministically from the projected `check_kind` / `check_target` / `check_rule` / `check_violation_fixture` scalars** on the `must_haves.prohibitions` item (authored at `/gsd:spec-phase`, projected by `projectProhibitions`, read back via the `descriptorFromProjection` adapter). So both halves close with **zero manual descriptor authoring** — the verifier neither invents the locate (#1278) nor hand-supplies the violation fixture (#1346): a prohibition authored with all four scalars machine-proves fail-first and greens end-to-end through the projection alone (removing the spoofable invent-at-verify-time surface; ADR-857 §147 exogenous grading). **Fail-closed is preserved:** an item with NO projected descriptor, a PARTIAL one (e.g. a `lint-rule` missing `check_rule`), OR a descriptor with **no `check_violation_fixture`** makes `descriptorFromProjection` return `null` / an under-specified or fixture-less descriptor, which falls through to the producer's fail-closed paths (`located: false`, or located-but-unprovable) → flagged-unverified, NEVER green, in BOTH modes. `failFirst` is demoted and greens nothing on its own (#1279, FF-08). Causation (**#1346**): supplying `check_clean_fixture` adds an opt-in control — the `node-test` prover also requires GREEN on a known-clean subject, proving the RED is content-caused; with no clean fixture that one residual case (a deceptive test reding merely because the env var is set) stays a documented constraint, an author opting into the stronger proof by wiring a clean control.
**Option B: Use Success Criteria from ROADMAP.md**

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@opengsd/gsd-core",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@opengsd/gsd-core",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"license": "MIT",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.84",

View File

@@ -1,6 +1,6 @@
{
"name": "@opengsd/gsd-core",
"version": "1.6.0-rc.1",
"version": "1.6.0-rc.2",
"description": "GSD Core is a meta-prompting, context engineering, and spec-driven development system for AI coding agents.",
"bin": {
"gsd-core": "bin/install.js",
@@ -120,5 +120,8 @@
"test:coverage:all": "npm run test:coverage",
"test:mutation": "stryker run",
"test:mutation:since": "stryker run --incremental --since origin/next"
},
"allowScripts": {
"fallow@2.70.0": true
}
}

View File

@@ -78,6 +78,7 @@ ALLOWLIST=(
'hooks/gsd-read-injection-scanner.js'
'tests/read-injection-scanner.security.test.cjs'
'tests/security-prompt-injection.security.test.cjs'
'tests/list-seeds.test.cjs'
'tests/fixtures/adversarial/security/'
'SECURITY.md'
# These files contain intentional injection examples / security-model prose

View File

@@ -72,7 +72,6 @@ const CANONICAL_HEADERS: Record<CanonicalHeader, string[]> = {
'candidates',
'approaches considered',
'variants',
'trade-offs',
'pros and cons of the options',
'discussion',
],
@@ -208,13 +207,30 @@ function normalizeAdrHeader(raw: unknown): string {
.trim();
}
function classifyHeader(normalizedHeader: string): CanonicalHeader | null {
// Normalized synonym index (audit M7). classifyHeader receives an ALREADY-normalized
// header (via normalizeAdrHeader), but historically compared it against the RAW synonym
// strings. Because normalizeAdrHeader collapses [\s:._-]+ to a space and strips [^\w\s],
// any synonym carrying a hyphen/apostrophe/etc. ('trade-offs', "won't do", 'post-grilling')
// could never match a normalized header — it was silently dead, and its ADR section went
// unmapped. Normalizing BOTH sides closes that abstraction asymmetry once, so every synonym
// (current and future) is reachable regardless of punctuation. Precomputed at module load to
// avoid re-normalizing the whole table per call; insertion order is preserved so first-match-
// wins and the exact-then-prefix precedence stay identical to the prior raw-compare loop.
const _NORMALIZED_SYNONYM_INDEX: Array<[string, CanonicalHeader]> = (() => {
const index: Array<[string, CanonicalHeader]> = [];
for (const [canonical, synonyms] of Object.entries(CANONICAL_HEADERS) as Array<[CanonicalHeader, string[]]>) {
for (const synonym of synonyms) {
if (normalizedHeader === synonym) return canonical;
if (normalizedHeader.startsWith(`${synonym} `)) return canonical;
index.push([normalizeAdrHeader(synonym), canonical]);
}
}
return index;
})();
function classifyHeader(normalizedHeader: string): CanonicalHeader | null {
for (const [synonym, canonical] of _NORMALIZED_SYNONYM_INDEX) {
if (normalizedHeader === synonym) return canonical;
if (normalizedHeader.startsWith(`${synonym} `)) return canonical;
}
return null;
}

View File

@@ -9,6 +9,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { execGit, platformWriteSync, platformReadSync, platformEnsureDir } from './shell-command-projection.cjs';
import { requireSafePath, sanitizeForDisplay } from './security.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import ioMod = require('./io.cjs');
const { output, error } = ioMod;
@@ -195,6 +196,120 @@ function cmdListTodos(cwd: string, area: string | undefined, raw: boolean): void
output(result, raw, count.toString());
}
/**
* List captured seeds from .planning/seeds/SEED-*.md for browsing/audit (#441).
*
* Unlike audit.scanSeeds (which returns only *unimplemented* seeds for the
* milestone surface), this lists seeds of every status with the richer fields a
* human audit needs (scope, trigger, planted date). An optional case-insensitive
* status filter narrows the set. Seed content is user-controlled, so every
* displayed field is passed through sanitizeForDisplay and each file path is
* validated with requireSafePath before reading. Read-only — never mutates.
*/
/**
* Derive the canonical `{ seed_id, slug }` from a seed filename stem and the
* frontmatter `id:` value. Pure (no I/O) so it can be property-tested directly.
*
* seed_id: frontmatter `id:` when it matches `SEED-NNN`, else the numeric prefix
* of the filename (`SEED-NNN-…`), else the whole stem. slug: the descriptive
* remainder after `SEED-NNN-`, else the stem with a leading `SEED-` stripped.
* `rawFmId` is `unknown` because frontmatter values are not guaranteed strings.
*/
function deriveSeedIdentity(stem: string, rawFmId: unknown): { seed_id: string; slug: string } {
const fmId = typeof rawFmId === 'string' ? rawFmId.trim() : '';
let seedId: string;
if (/^SEED-\d+$/i.test(fmId)) {
seedId = fmId;
} else {
const numMatch = stem.match(/^(SEED-\d+)/i);
seedId = numMatch ? numMatch[1] : stem;
}
const slugMatch = stem.match(/^SEED-\d+-(.+)$/i);
const slug = slugMatch ? slugMatch[1] : stem.replace(/^SEED-/i, '');
return { seed_id: seedId, slug };
}
function cmdListSeeds(cwd: string, statusFilter: string | undefined, raw: boolean): void {
const planDir = planningDir(cwd);
const seedsDir = path.join(planDir, 'seeds');
const wantStatus = statusFilter ? statusFilter.trim().toLowerCase() : null;
const seeds: Array<{
seed_id: string; slug: string; status: string; scope: string;
trigger_when: string; planted: string; title: string; path: string;
}> = [];
const summary: Record<string, number> = {};
// Frontmatter values are not guaranteed to be scalars: extractFrontmatter
// yields {} for a bare `key:` line and an array for `key: [a, b]`. Coerce every
// read to a string so one malformed seed cannot crash the whole audit list
// (`.toLowerCase()` on a non-string throws) or leak a raw object/array into the
// JSON contract. Mirrors the existing `typeof fm.id === 'string'` guard below.
const fmStr = (v: unknown): string => (typeof v === 'string' ? v : '');
let files: fs.Dirent[];
try {
files = fs.readdirSync(seedsDir, { withFileTypes: true });
} catch {
// No seeds dir (or unreadable) — an empty, non-error result. The seed dir is
// created lazily by the first plant-seed, so absence is the normal zero case.
output({ count: 0, seeds: [], summary: {} }, raw, '0');
return;
}
for (const entry of files) {
if (!entry.isFile()) continue;
if (!entry.name.startsWith('SEED-') || !entry.name.endsWith('.md')) continue;
let safeFilePath: string;
try {
safeFilePath = requireSafePath(path.join(seedsDir, entry.name), planDir, 'seed file', { allowAbsolute: true });
} catch {
continue;
}
const content = platformReadSync(safeFilePath);
if (content === null) continue;
const fm = extractFrontmatter(content) as Record<string, unknown>;
const status = (fmStr(fm.status) || 'dormant').toLowerCase().trim() || 'dormant';
// Match on the raw lowercased status (both sides already normalized);
// sanitizeForDisplay is for output, not comparison.
if (wantStatus && status !== wantStatus) continue;
// Canonical seed id is `SEED-NNN` (frontmatter `id:`, e.g. SEED-001). Fall
// back to the numeric prefix of the filename, then to the whole stem. The
// descriptive remainder of the filename (`SEED-NNN-<slug>.md`) is the slug.
const stem = path.basename(entry.name, '.md');
const { seed_id: seedId, slug } = deriveSeedIdentity(stem, fm.id);
let title = sanitizeForDisplay(fmStr(fm.title).slice(0, 100));
if (!title) {
const headingMatch = content.match(/^#\s*(.+)$/m);
if (headingMatch) title = sanitizeForDisplay(headingMatch[1].trim().slice(0, 100));
}
const safeStatus = sanitizeForDisplay(status);
summary[safeStatus] = (summary[safeStatus] || 0) + 1;
seeds.push({
seed_id: sanitizeForDisplay(seedId),
slug: sanitizeForDisplay(slug),
status: safeStatus,
scope: sanitizeForDisplay(fmStr(fm.scope) || 'unknown'),
trigger_when: sanitizeForDisplay(fmStr(fm.trigger_when)),
planted: sanitizeForDisplay(fmStr(fm.planted)),
title,
path: toPosixPath(path.relative(cwd, safeFilePath)),
});
}
// Stable order: by seed_id so output is deterministic across filesystems.
seeds.sort((a, b) => a.seed_id.localeCompare(b.seed_id));
output({ count: seeds.length, seeds, summary }, raw, seeds.length.toString());
}
function cmdVerifyPathExists(cwd: string, targetPath: string | undefined, raw: boolean): void {
if (!targetPath) {
error('path required for verification');
@@ -729,6 +844,171 @@ function cmdCommitToSubrepo(cwd: string, message: string | undefined, files: str
output(result, raw, Object.entries(repos).map(([r, v]) => `${r}:${v.hash || 'skip'}`).join(' '));
}
/**
* Prepare a sub-repo for a companion PR branch.
*
* Detects uncommitted changes, creates a new branch, stages every changed
* file explicitly (never git add -A per universal-anti-patterns.md:44), commits,
* and pushes with --set-upstream. Returns a structured result the workflow uses
* to call `gh pr create`.
*
* On a stage/commit failure (nothing committed yet), the branch is deleted and
* the caller is returned to the original HEAD so the repo is left clean. On a
* push failure, the commit already exists — the branch is left in place instead
* so the user's work is not lost; the error includes a retry instruction.
*/
function cmdPrSubrepo(
cwd: string,
repo: string | undefined,
branch: string | undefined,
commitMessage: string | undefined,
raw: boolean,
): void {
if (!repo) {
error('--repo required');
}
if (!branch) {
error('--branch required');
}
if (!commitMessage || commitMessage.startsWith('--')) {
error('commit message required');
}
if ((branch as string).startsWith('-')) {
error(`Branch name must not start with '-': ${branch}`);
}
// 0. Security: validate repo path is contained within the workspace root.
// Uses security.cjs validatePath (symlink-safe realpathSync + startsWith guard)
// to reject ../escape, absolute paths, and symlink traversal.
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/unbound-method
const { validatePath } = require('./security.cjs') as {
validatePath(filePath: string, baseDir: string): { safe: boolean; resolved: string; error?: string };
};
const pathCheck = validatePath(repo as string, cwd);
if (!pathCheck.safe) {
error(`Sub-repo path is unsafe: ${pathCheck.error}`);
}
const repoCwd = pathCheck.resolved;
if (!fs.existsSync(repoCwd)) {
error(`Sub-repo not found: ${repoCwd}`);
}
// 1. Collect changed files via porcelain status — explicit, never git add -A.
// ?? (untracked) lines are excluded — only stage tracked modifications.
const statusResult = execGit(['-c', 'core.quotePath=false', 'status', '--porcelain'], { cwd: repoCwd });
if (statusResult.exitCode !== 0) {
error(`git status failed in ${repo}: ${statusResult.stderr}`);
}
// Parse porcelain output into two lists:
// changedFiles — all affected paths (old + new for renames) → goes into result.files
// filesToStage — paths to pass to git add (rename old-paths are already staged by
// the rename op and no longer exist in the worktree; only add new paths)
const changedFiles: string[] = [];
const filesToStage: string[] = [];
for (const line of statusResult.stdout.split('\n').filter(Boolean).filter(l => !l.startsWith('??'))) {
// execGit trims the entire stdout string, which may strip the leading X-status
// space from the first output line. Normalize before slicing.
const normalized = line.trimStart();
const file = normalized.slice(2).trim();
const arrowIdx = file.indexOf(' -> ');
if (arrowIdx !== -1) {
const oldPath = file.slice(0, arrowIdx).trim();
const newPath = file.slice(arrowIdx + 4).trim();
changedFiles.push(oldPath, newPath);
filesToStage.push(newPath); // old path already staged; worktree no longer has it
} else {
changedFiles.push(file);
filesToStage.push(file);
}
}
if (changedFiles.length === 0) {
output(
{ ok: true, repo, branch, committed: false, reason: 'nothing_to_commit', files: [] },
raw,
'nothing_to_commit',
);
return;
}
// 2. Guard: refuse if branch already exists — checkout -b is non-idempotent
const branchCheck = execGit(['rev-parse', '--verify', branch as string], { cwd: repoCwd });
if (branchCheck.exitCode === 0) {
error(`Branch already exists in ${repo}: ${branch}. Delete it first or choose a unique name.`);
}
// Capture current HEAD before switching so rollback can return explicitly.
// git checkout - fails on a fresh single-branch repo with no prior HEAD.
const prevBranchResult = execGit(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: repoCwd });
const prevBranchName = prevBranchResult.exitCode === 0 ? prevBranchResult.stdout.trim() : null;
// 3. Create branch
const checkoutResult = execGit(['checkout', '-b', branch as string], { cwd: repoCwd });
if (checkoutResult.exitCode !== 0) {
error(`Failed to create branch ${branch} in ${repo}: ${checkoutResult.stderr}`);
}
// Helper: rollback the created branch and return to the previous HEAD.
const rollback = (): void => {
if (prevBranchName) {
execGit(['checkout', prevBranchName], { cwd: repoCwd });
}
execGit(['branch', '-D', branch as string], { cwd: repoCwd });
};
// 4. Stage explicit files (never git add -A per universal-anti-patterns.md:44)
for (const file of filesToStage) {
const addResult = execGit(['add', '--', file], { cwd: repoCwd });
if (addResult.exitCode !== 0) {
rollback();
error(`Failed to stage ${file} in ${repo}: ${addResult.stderr}`);
}
}
// 5. Commit
const commitResult = execGit(['commit', '-m', commitMessage as string], { cwd: repoCwd });
if (commitResult.exitCode !== 0) {
rollback();
error(`Failed to commit in ${repo}: ${commitResult.stderr}`);
}
// 6. Capture commit hash
const hashResult = execGit(['rev-parse', '--short', 'HEAD'], { cwd: repoCwd });
const commitHash = hashResult.exitCode === 0 ? hashResult.stdout.trim() : null;
// 7. Capture remote URL and derive GitHub owner/repo slug for gh pr create
const remoteResult = execGit(['remote', 'get-url', 'origin'], { cwd: repoCwd });
const remoteUrl = remoteResult.exitCode === 0 ? remoteResult.stdout.trim() : null;
let remoteSlug: string | null = null;
if (remoteUrl) {
const m = remoteUrl.match(/github\.com[:/](.+?)(?:\.git)?$/);
remoteSlug = m ? m[1] : null;
}
// 8. Push with --set-upstream so gh pr create can find the branch.
// Network operation — use a longer timeout than the default 10 s.
// Do NOT rollback on push failure — the commit already exists on the local branch.
// Deleting the branch here would destroy the only ref holding the user's work.
// Leave the branch in place so the user can retry the push.
const pushResult = execGit(['push', '--set-upstream', 'origin', branch as string], { cwd: repoCwd, timeout: 60_000 });
if (pushResult.exitCode !== 0) {
error(`Failed to push ${branch} in ${repo}: ${pushResult.stderr}\nBranch ${branch} was created locally — retry with: git -C ${repo} push --set-upstream origin ${branch}`);
}
const result = {
ok: true,
repo,
branch,
committed: true,
files: changedFiles,
commit_hash: commitHash,
remote_url: remoteUrl,
remote_slug: remoteSlug,
};
output(result, raw, `${repo}@${commitHash ?? 'unknown'}`);
}
function cmdSummaryExtract(cwd: string, summaryPath: string | undefined, fields: string[] | undefined, raw: boolean): void {
if (!summaryPath) {
error('summary-path required for summary-extract');
@@ -1413,6 +1693,8 @@ export = {
cmdGenerateSlug,
cmdCurrentTimestamp,
cmdListTodos,
cmdListSeeds,
deriveSeedIdentity,
cmdVerifyPathExists,
cmdHistoryDigest,
cmdResolveModel,
@@ -1421,6 +1703,7 @@ export = {
cmdEffortSync,
cmdCommit,
cmdCommitToSubrepo,
cmdPrSubrepo,
cmdSummaryExtract,
cmdWebsearch,
cmdProgressRender,

View File

@@ -138,6 +138,11 @@ function _deepMergeConfig(base: Record<string, unknown>, overlay: Record<string,
if (typeof base !== 'object' || typeof overlay !== 'object') return overlay;
const result: Record<string, unknown> = { ...base };
for (const key of Object.keys(overlay)) {
// Prototype-pollution guard — mirrors the four sibling guards in this file
// (lines ~315/319/331/341/549). Without it a workstream/root config.json with
// {"__proto__": {...}} pollutes this merged object's prototype chain and can
// spoof unset config flags. (Per-object pollution, not global Object.prototype.)
if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
if (overlay[key] !== null && typeof overlay[key] === 'object' && !Array.isArray(overlay[key])) {
result[key] = _deepMergeConfig((base[key] ?? {}) as Record<string, unknown>, overlay[key] as Record<string, unknown>);
} else {

View File

@@ -56,10 +56,14 @@ function extractFrontmatter(content: string): Frontmatter {
const frontmatter: Frontmatter = {};
// Match frontmatter only at byte 0 — a `---` block later in the document
// body (YAML examples, horizontal rules) must never be treated as frontmatter.
const match = content.match(/^---\r?\n([\s\S]+?)\r?\n---/);
if (!match) return frontmatter;
const headerEnd = content.startsWith('---\r\n') ? 5 : content.startsWith('---\n') ? 4 : -1;
if (headerEnd === -1) return frontmatter;
const yaml = match[1];
const closingLineStart = content.indexOf('\n---', headerEnd);
if (closingLineStart === -1) return frontmatter;
const yamlEnd = content[closingLineStart - 1] === '\r' ? closingLineStart - 1 : closingLineStart;
const yaml = content.slice(headerEnd, yamlEnd);
const lines = yaml.split(/\r?\n/);
// Stack to track nested objects: [{obj, key, indent}]

View File

@@ -303,6 +303,11 @@ export interface Prohibition {
// against to MACHINE-PROVE fail-first. Projected only alongside a well-formed descriptor; absent ->
// the producer hard-gates (green requires a fixture). Mirrors `CheckDescriptor.violationFixture`.
check_violation_fixture?: string;
// Optional 5th flat scalar (#1346): the path to a KNOWN-CLEAN control subject the prover ALSO runs
// the check against, requiring it to stay GREEN — proving the violation RED is caused by the
// subject's CONTENT, not merely by GSD_PROHIB_SUBJECT being set. Projected only alongside a
// well-formed descriptor; absent -> no control (documented residual). Mirrors `CheckDescriptor.cleanFixture`.
check_clean_fixture?: string;
}
/**
@@ -387,6 +392,13 @@ export function projectProhibitions(
if (typeof p.check_violation_fixture === 'string' && p.check_violation_fixture.trim() !== '') {
entry.check_violation_fixture = String(p.check_violation_fixture);
}
// `check_clean_fixture` (#1346) rides BOTH kinds — the KNOWN-CLEAN control subject the prover
// requires to stay GREEN (content-dependence proof). Emit ONLY a non-empty fixture (blank ->
// absent so no control runs; the documented residual remains). Like the violation fixture it is
// meaningless without the descriptor, so it lives inside this well-formed-descriptor branch.
if (typeof p.check_clean_fixture === 'string' && p.check_clean_fixture.trim() !== '') {
entry.check_clean_fixture = String(p.check_clean_fixture);
}
}
out.push(entry);
}

View File

@@ -25,7 +25,7 @@ const { loadConfig } = configLoader;
import { platformReadSync as safeReadFile, platformWriteSync, platformEnsureDir } from './shell-command-projection.cjs';
import { getGlobalSkillDir, getGlobalConfigDir } from './runtime-homes.cjs';
import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
import { resolveRuntimeNameFromCandidates } from './runtime-name-policy.cjs';
import { resolveRuntimeNameFromCandidates, getProjectInstructionFile } from './runtime-name-policy.cjs';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -1120,20 +1120,32 @@ function cmdGenerateClaudeMd(cwd: string, options: CmdGenerateClaudeMdOptions, r
// repo-root `CLAUDE.md`, so generated GSD content does not land next to — or
// pollute — a hand-crafted repo-root CLAUDE.md. An explicit `claude_md_path`
// config value or `--output` still wins.
let configClaudeMdPath = './.claude/CLAUDE.md';
let configClaudeMdPath = '.claude/CLAUDE.md';
try {
const config = loadConfig(cwd);
if (config['claude_md_path']) configClaudeMdPath = config['claude_md_path'] as string;
if (config['claude_md_assembly']) assemblyConfig = config['claude_md_assembly'] as Record<string, unknown>;
// #3163: When runtime is codex, override the output target to AGENTS.md
// regardless of claude_md_path, so Codex projects never write to CLAUDE.md.
// GSD_RUNTIME env var takes precedence over config.runtime, mirroring detectRuntime().
// #1529: When no explicit --output is provided, derive the instruction
// file from the runtime via the shared `getProjectInstructionFile` policy
// (single source of truth in runtime-name-policy.cjs, shared with the
// new-project.md bash workflow via `gsd-tools query
// project-instruction-file`). Previously this was a codex-only override
// (#3163) that left AGENTS-native runtimes (opencode/kilo/kimi) emitting
// CLAUDE.md; copilot now resolves to .github/copilot-instructions.md, and
// antigravity/gemini to GEMINI.md. GSD_RUNTIME env var takes precedence
// over config.runtime, mirroring detectRuntime().
//
// Non-claude runtimes always win over a stale `claude_md_path` (the #3163
// rationale: a Codex/AGENTS-native project must never write to CLAUDE.md
// even if a prior Claude setup left a `claude_md_path` behind). For the
// claude runtime, `claude_md_path` config is honored — it IS the
// Claude-specific output setting (per #1098 and the #3163 non-codex test).
const effectiveRuntime = resolveRuntimeNameFromCandidates(
process.env['GSD_RUNTIME'],
config['runtime']
);
if (!options.output && effectiveRuntime === 'codex') {
configClaudeMdPath = './AGENTS.md';
if (!options.output && effectiveRuntime && effectiveRuntime !== 'claude') {
configClaudeMdPath = getProjectInstructionFile(effectiveRuntime);
}
} catch { /* use default */ }

View File

@@ -76,6 +76,15 @@ export interface CheckDescriptor {
* prove fail-first; ABSENT for node-test → the default prover fails closed (never attestation).
*/
violationFixture?: string;
/**
* OPTIONAL author-supplied path to a KNOWN-CLEAN control subject (#1346). When present, the prover
* runs the check against it as a CAUSATION CONTROL and requires it to stay GREEN — proof that the
* RED on `violationFixture` was caused by the subject's CONTENT, not merely by `GSD_PROHIB_SUBJECT`
* being set. A deceptive content-independent check reds on the clean subject too → control fails →
* not proven. ABSENT → no control runs (the documented residual remains; backward-compatible with
* the #1314 zero-authoring compose path). A supplied-but-missing path fails closed.
*/
cleanFixture?: string;
}
/**
@@ -90,8 +99,9 @@ export interface CheckDescriptor {
* - `null`/`undefined`/non-object input -> `null`.
* - `check_kind` ABSENT -> `null` (no descriptor -> producer locates nothing -> fail-closed).
* - `check_kind` present -> `{ kind: check_kind, target: check_target }`, adding `rule: check_rule`
* ONLY when `check_rule` is a non-empty string, and `violationFixture: check_violation_fixture`
* ONLY when that scalar is a non-empty string (#1346 — composes #1278 locate with #1279 proof).
* ONLY when `check_rule` is a non-empty string, `violationFixture: check_violation_fixture`
* ONLY when that scalar is a non-empty string (composes #1278 locate with #1279 proof), and
* `cleanFixture: check_clean_fixture` ONLY when that scalar is non-empty (#1346 causation control).
* - `failFirst` is NEVER sourced from the projection — it stays a verify-time caller attestation
* (#1279 machine-proves it; out of scope here). The returned descriptor carries no `failFirst`.
* - The adapter does NOT strictly validate kind/target/rule: it faithfully reconstructs whatever
@@ -128,6 +138,12 @@ export function descriptorFromProjection(
// hard-gates (fail-closed; green requires a fixture), never fabricated.
const fixture = scalar(projected.check_violation_fixture);
if (fixture.trim().length > 0) descriptor.violationFixture = fixture;
// `cleanFixture` (#1346) rides BOTH kinds — reconstruct it from `check_clean_fixture` so the
// causation control runs end-to-end: when present the prover also requires the check to stay GREEN
// against this known-clean subject (proving the violation RED is content-dependent). Absent/blank ->
// no control (the documented residual remains; backward-compatible with the #1314 compose path).
const clean = scalar(projected.check_clean_fixture);
if (clean.trim().length > 0) descriptor.cleanFixture = clean;
return descriptor;
}
@@ -438,6 +454,31 @@ function posTimeout(timeoutMs: number | undefined, def: number): number {
return typeof timeoutMs === 'number' && timeoutMs > 0 ? timeoutMs : def;
}
/**
* Spawn the negative `node --test` against a single subject (set via the `GSD_PROHIB_SUBJECT`
* convention, #1279) and return its TAP output. Reuses the bounded-subprocess machinery
* (`process.execPath`, arg arrays → no shell, `childEnv`, bounded `timeout`/`maxBuffer`) and NEVER
* throws — a RED run exits non-zero, so the partial TAP (with the `# fail` summary) is recovered from
* the thrown error's `stdout`. The prover calls this once per subject: the KNOWN-BAD violation fixture
* (expect RED) and, for the #1346 causation control, the KNOWN-CLEAN control subject (expect GREEN).
*/
function runNodeTestWithSubject(check: CheckDescriptor, cwd: string, subject: string, timeoutMs?: number): string {
try {
return execFileSync(process.execPath, buildNodeTestArgs(check), {
cwd,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
env: { ...childEnv(), GSD_PROHIB_SUBJECT: subject },
timeout: posTimeout(timeoutMs, NODE_TEST_TIMEOUT_MS),
maxBuffer: CHECK_MAX_BUFFER,
});
} catch (e) {
const stdout = e && typeof e === 'object' && 'stdout' in e ? (e as { stdout?: unknown }).stdout : '';
return typeof stdout === 'string' ? stdout : '';
}
}
function defaultRunCheck(check: CheckDescriptor, cwd: string, timeoutMs?: number): CheckRunResult {
try {
if (check.kind === 'node-test') {
@@ -554,34 +595,32 @@ function defaultProveFailFirst(check: CheckDescriptor, cwd: string, timeoutMs?:
// a setup crash, not from the prohibition firing. Requiring the fixture to exist before spawning
// closes the realistic typo/stale-path case (#1279 review, Major 1).
//
// KNOWN RESIDUAL (documented, fail-open direction, tracked follow-up #1346): existence is
// necessary but not sufficient — a deliberately deceptive negative test that reds merely BECAUSE
// `GSD_PROHIB_SUBJECT` is set (rather than because the subject's CONTENT violates the must-NOT)
// is still accepted. Proving "the red was CAUSED BY the violation" cannot be done generically for
// an arbitrary author-supplied test, so it is recorded as a constraint, not silently implied-solved.
// CAUSATION (#1346): existence + a non-vacuous red is necessary but not sufficient — a deceptive
// negative test that reds merely BECAUSE `GSD_PROHIB_SUBJECT` is set (rather than because the
// subject's CONTENT violates the must-NOT) would otherwise be accepted. The OPTIONAL `cleanFixture`
// control below proves content-dependence when supplied (red on bad AND green on clean). When NO
// clean fixture is authored the control cannot run, so the residual remains a documented constraint
// for that case (an author opts into the stronger proof by supplying a known-clean control subject).
// Resolve the fixture against `cwd` (NOT the verify process's cwd): the spawned test reads
// `GSD_PROHIB_SUBJECT` and resolves a relative subject against `cwd`, so the existence check must
// use the SAME base or it could pass here yet ENOENT in the child (re-opening the fail-open hole).
if (!fixture || !fs.existsSync(path.resolve(cwd, fixture))) return { provenFailFirst: false };
let out = '';
try {
out = execFileSync(process.execPath, buildNodeTestArgs(check), {
cwd,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
// CONVENTION (#1279): the negative test reads its subject-under-test from this env var.
env: { ...childEnv(), GSD_PROHIB_SUBJECT: fixture },
timeout: posTimeout(timeoutMs, NODE_TEST_TIMEOUT_MS),
maxBuffer: CHECK_MAX_BUFFER,
});
} catch (e) {
// A negative test that goes RED exits non-zero; the partial TAP (with the `# fail` summary)
// is on stdout. Parse what we have: a real failure here is the PROOF the test is fail-first.
const stdout = e && typeof e === 'object' && 'stdout' in e ? (e as { stdout?: unknown }).stdout : '';
out = typeof stdout === 'string' ? stdout : '';
// Run the negative test against the KNOWN-BAD subject and require a NON-VACUOUS red.
const redOut = runNodeTestWithSubject(check, cwd, fixture, timeoutMs);
if (!isNonVacuousNodeTestRed(redOut, check.target)) return { provenFailFirst: false, method: 'violation-fixture' };
// #1346 CAUSATION CONTROL (optional): if a clean control subject is supplied, run the SAME test
// against it and require it to stay GREEN. This proves the red above was caused by the subject's
// CONTENT — a deceptive test that reds merely because GSD_PROHIB_SUBJECT is SET reds here too →
// not content-dependent → not proven. Absent → no control (documented residual; backward-compat).
const clean = check.cleanFixture;
if (clean) {
// A supplied-but-missing/typo'd control path can't run the control → fail-closed, symmetric
// with the violation-fixture existence guard (resolve against the SAME `cwd` as the child).
if (!fs.existsSync(path.resolve(cwd, clean))) return { provenFailFirst: false, method: 'violation-fixture' };
const cleanOut = runNodeTestWithSubject(check, cwd, clean, timeoutMs);
if (!isNonVacuousNodeTestPass(cleanOut, check.target)) return { provenFailFirst: false, method: 'violation-fixture' };
}
return { provenFailFirst: isNonVacuousNodeTestRed(out, check.target), method: 'violation-fixture' };
return { provenFailFirst: true, method: 'violation-fixture' };
}
// Unknown kind — defensive; the LOCATE guard already rejects it.
return { provenFailFirst: false };

View File

@@ -181,10 +181,25 @@ function routeRoadmapCommand({ roadmap, args, cwd, raw, error }: RouteRoadmapCom
},
'upgrade': () => {
const dryRun = !args.includes('--apply');
const convention = args.find((_a, i) => args[i - 1] === '--convention') || 'milestone-prefixed';
// Parse `--convention <value>` and `--convention=<value>`. When the flag is
// absent entirely, default to the only supported convention; when present
// with a missing/unsupported value, fall through to the rejection below
// (fail-closed — never silently run a migration the user did not request).
let convention = 'milestone-prefixed';
const conventionFlagIdx = args.findIndex(
(a) => a === '--convention' || a.startsWith('--convention='),
);
if (conventionFlagIdx !== -1) {
const token = args[conventionFlagIdx];
convention = token.includes('=')
? token.slice(token.indexOf('=') + 1)
: (args[conventionFlagIdx + 1] ?? '');
}
if (convention !== 'milestone-prefixed') {
process.stderr.write('Only --convention milestone-prefixed is supported\n');
process.exit(1);
// No-throw hub contract (ADR-0012): a hub-dispatched handler must not call
// process.exit. Throw instead — the hub converts this to HandlerFailure and
// the adapter routes it through the injected error() boundary.
throw new Error('Only --convention milestone-prefixed is supported');
}
const plan = roadmapUpgrade.computeMigrationPlan(cwd);
roadmapUpgrade.applyMigration(cwd, plan, { dryRun });

View File

@@ -492,14 +492,6 @@ function applyMigration(cwd: string, plan: MigrationPlan, options: { dryRun?: bo
throw new Error('Working tree is dirty. Commit or stash changes before migrating.');
}
// Capture HEAD sha for rollback
let headSha: string;
try {
headSha = execSync('git rev-parse HEAD', { cwd, encoding: 'utf8', windowsHide: true }).trim();
} catch (err) {
throw new Error(`git rev-parse HEAD failed: ${(err as Error).message}`);
}
const pDir = planningDir(cwd);
const phasesDir = path.join(pDir, 'phases');
const roadmapPath = path.join(pDir, 'ROADMAP.md');
@@ -508,6 +500,23 @@ function applyMigration(cwd: string, plan: MigrationPlan, options: { dryRun?: bo
const renamedDirs: string[] = [];
const editedFiles: string[] = [];
// Surgical, git-independent rollback state (#1542). A `git reset --hard` +
// `git clean` rollback restores NOTHING for a gitignored `.planning/`
// (commit_docs:false — the default) and is a whole-repo operation besides.
// Instead, record the exact renames performed and snapshot each file before
// rewriting it, then undo precisely those on failure — correct whether
// `.planning/` is git-tracked or ignored.
const performedRenames: Array<{ oldPath: string; newPath: string }> = [];
const fileBackups = new Map<string, { existed: boolean; content: string }>();
const snapshotFile = (filePath: string): void => {
if (fileBackups.has(filePath)) return;
try {
fileBackups.set(filePath, { existed: true, content: fs.readFileSync(filePath, 'utf8') });
} catch {
fileBackups.set(filePath, { existed: false, content: '' });
}
};
try {
// 1. Rename phase directories
for (const phaseEntry of plan.phases) {
@@ -515,6 +524,7 @@ function applyMigration(cwd: string, plan: MigrationPlan, options: { dryRun?: bo
const newPath = path.join(phasesDir, phaseEntry.newDir);
if (fs.existsSync(oldPath)) {
fs.renameSync(oldPath, newPath);
performedRenames.push({ oldPath, newPath });
renamedDirs.push(`${phaseEntry.oldDir} → ${phaseEntry.newDir}`);
}
}
@@ -532,6 +542,7 @@ function applyMigration(cwd: string, plan: MigrationPlan, options: { dryRun?: bo
}
}
snapshotFile(roadmapPath);
fs.writeFileSync(roadmapPath, lines.join('\n'), 'utf8');
editedFiles.push('ROADMAP.md');
}
@@ -561,6 +572,7 @@ function applyMigration(cwd: string, plan: MigrationPlan, options: { dryRun?: bo
}
if (changed) {
snapshotFile(filePath);
fs.writeFileSync(filePath, content, 'utf8');
editedFiles.push(fileName);
}
@@ -573,18 +585,28 @@ function applyMigration(cwd: string, plan: MigrationPlan, options: { dryRun?: bo
} catch { /* config may not exist yet */ }
configData['phase_id_convention'] = 'milestone-prefixed';
snapshotFile(configPath);
fs.writeFileSync(configPath, JSON.stringify(configData, null, 2) + '\n', 'utf8');
editedFiles.push('config.json');
} catch (err) {
// Rollback via git reset --hard + git clean
try {
execSync(`git reset --hard ${headSha}`, { cwd, stdio: 'pipe', windowsHide: true });
execSync('git clean -fd .planning/phases/', { cwd, stdio: 'pipe', windowsHide: true });
} catch {
// Swallow rollback errors — surface original error
// Surgical rollback: reverse the renames (newest first) and restore every
// file we snapshotted (deleting files that did not previously exist). This
// actually restores `.planning/` regardless of git tracking — so the
// "rolled back" claim is truthful — and never touches anything else.
for (let i = performedRenames.length - 1; i >= 0; i--) {
const { oldPath, newPath } = performedRenames[i];
try {
if (fs.existsSync(newPath)) fs.renameSync(newPath, oldPath);
} catch { /* best-effort */ }
}
throw new Error(`Migration failed (rolled back to ${headSha}): ${(err as Error).message}`);
for (const [filePath, backup] of fileBackups) {
try {
if (backup.existed) fs.writeFileSync(filePath, backup.content, 'utf8');
else if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
} catch { /* best-effort */ }
}
throw new Error(`Migration failed and rolled back: ${(err as Error).message}`);
}
return { applied: true, renamedDirs, editedFiles };

View File

@@ -426,8 +426,11 @@ function cmdRoadmapAnalyze(cwd: string, raw: boolean): void {
const totalSummaries = phases.reduce((sum, p) => sum + p.summary_count, 0);
const completedPhases = phases.filter(p => p.disk_status === 'complete').length;
// Detect phases in summary list without detail sections (malformed ROADMAP)
const checklistPattern = /-\s*\[[ x]\]\s*\*\*Phase\s+(\d+[A-Z]?(?:\.\d+)*)/gi;
// Detect phases in summary list without detail sections (malformed ROADMAP).
// The char class must allow `-` (not just `.`) so dash-separated milestone-prefixed
// IDs (e.g. `1-01`) match the detail-heading scanner above; otherwise they truncate
// at the dash (`1-01` -> `1`) and every such phase reports a phantom missing detail.
const checklistPattern = /-\s*\[[ x]\]\s*\*\*Phase\s+(\d+[A-Z]?(?:[.-]\d+)*)/gi;
const checklistPhases = new Set<string>();
let checklistMatch: RegExpExecArray | null;
while ((checklistMatch = checklistPattern.exec(content)) !== null) {

View File

@@ -21,10 +21,46 @@ import os from 'node:os';
import fs from 'node:fs';
import commandRoster = require('./command-roster.cjs');
const { readGsdCommandNames, transformContentToHyphen } = commandRoster;
const pkg = require('../../../package.json');
import runtimeNamePolicy = require('./runtime-name-policy.cjs');
const { getDirName } = runtimeNamePolicy;
// #1383: resolve GSD's version WITHOUT a top-level
// `require('../../../package.json')`. That require ran at module load on every
// gsd-tools invocation (this module sits in the gsd-tools loader chain) and
// threw `Cannot find module '../../../package.json'` on runtimes whose root has
// no package.json — notably Codex, where the installer omits the synthetic root
// package.json — taking the entire CLI down before it did anything. And even
// where it resolved (Claude's synthetic `{"type":"commonjs"}`), there is no
// `version` field, so the single consumer below already emitted
// `version: undefined`. Resolve lazily and defensively instead:
// 1. Installed trees carry <root>/gsd-core/VERSION (written by the installer);
// this module lives at <root>/gsd-core/bin/lib, so VERSION is two dirs up.
// 2. The source / npm-package tree has no gsd-core/VERSION but carries a real
// package.json three dirs up — read it lazily, never at module-load time.
// A failed/invalid lookup degrades to '' (the caller omits the field) rather
// than crashing or emitting `version: undefined`. Both sources are validated
// against the same semver shape the repo's other VERSION reader enforces
// (src/update-context.cts) so a garbled VERSION file is never emitted verbatim.
// Exported for the #1383 regression.
const SEMVER_PREFIX = /^\d+\.\d+\.\d+/; // mirrors src/update-context.cts SEMVER_PREFIX
function resolveVersionFrom(libDir: string): string {
try {
const v = fs.readFileSync(path.join(libDir, '..', '..', 'VERSION'), 'utf8').trim();
if (SEMVER_PREFIX.test(v)) return v;
} catch { /* not an installed tree (no gsd-core/VERSION) */ }
try {
const pkg = require(path.join(libDir, '..', '..', '..', 'package.json'));
if (pkg && typeof pkg.version === 'string' && SEMVER_PREFIX.test(pkg.version)) return pkg.version;
} catch { /* runtime root has no package.json (e.g. Codex) */ }
return '';
}
let cachedVersion: string | undefined;
function gsdVersion(): string {
if (cachedVersion === undefined) cachedVersion = resolveVersionFrom(__dirname);
return cachedVersion;
}
const colorNameToHex = {
cyan: '#00FFFF',
@@ -393,7 +429,10 @@ function convertClaudeCommandToClaudeSkill(content, skillName, runtime = null, c
// Hermes' SKILL.md spec lists `version` as a required frontmatter field.
// Track GSD's package version so Hermes' skill_view() reports a stable
// identifier per install.
if (runtime === 'hermes') fm += `version: ${yamlQuote(pkg.version)}\n`;
if (runtime === 'hermes') {
const version = gsdVersion();
if (version) fm += `version: ${yamlQuote(version)}\n`;
}
// #778 (b) — Qwen-only numeric priority for /skills ordering. Scoped to qwen
// so Claude/Hermes skill frontmatter is unchanged (they ignore the field, but
// we keep their output byte-stable). skillName is the `gsd-<stem>` dir name.
@@ -2119,6 +2158,40 @@ function computePathPrefix({ isGlobal, isOpencode, isWindowsHost: _isWindowsHost
return `${resolvedTarget}/`;
}
/**
* Canonical list of every non-Claude runtime that gsd-core emits artifacts for.
* Exported so test files can import this single source of truth rather than
* maintaining divergent hand-rolled arrays (#1521).
*
* Keep in sync with the runtime flags in bin/install.js and getDirName().
*/
const NON_CLAUDE_RUNTIMES: string[] = [
'codex', 'opencode', 'kilo', 'gemini', 'copilot', 'antigravity',
'cursor', 'windsurf', 'augment', 'trae', 'qwen', 'hermes', 'kimi',
'codebuddy', 'cline',
];
/**
* #1521: Every non-Claude runtime resolves its own runtime identity from a
* runtime-neutral config, and defaults workflow.use_worktrees to false —
* GSD's worktree isolation uses Claude Code's isolation="worktree" spawn
* parameter, which no other runtime honors. Stamped into the emitted
* workflow runtime-resolution blocks. (Generalizes the Codex-only #1515 fix.)
*
* @private — exported as `_stampNonClaudeRuntimeDefaults` for tests.
*/
function _stampNonClaudeRuntimeDefaults(content: string, runtime: string): string {
content = content.replace(
/config-get workflow\.use_worktrees --raw 2>\/dev\/null \|\| echo "true"/g,
'config-get workflow.use_worktrees --default false --raw 2>/dev/null || echo "false"',
);
content = content.replace(
/config-get runtime --default claude --raw 2>\/dev\/null \|\| echo "claude"/g,
`config-get runtime --default ${runtime} --raw 2>/dev/null || echo "${runtime}"`,
);
return content;
}
/**
* Apply the per-runtime rewrite table to a single content string.
* Relocated from bin/install.js `_applyRuntimeRewrites`.
@@ -2133,12 +2206,20 @@ function _applyRuntimeRewrites(content, runtime, pathPrefix, isGlobal = false, a
const dirName = getDirName(runtime);
const normalizedPathPrefix = pathPrefix.replace(/\/$/, '');
// #1521: stamp runtime identity + use_worktrees=false for every non-Claude runtime
// before brand-specific path rewrites, so the replace operates on the pristine
// source line and is idempotent regardless of subsequent path substitutions.
if (runtime !== 'claude') {
content = _stampNonClaudeRuntimeDefaults(content, runtime);
}
switch (runtime) {
case 'codex':
content = content.replace(/~\/\.claude\//g, pathPrefix);
content = content.replace(/\$HOME\/\.claude\//g, pathPrefix);
content = content.replace(/\.\/\.claude\//g, `./${dirName}/`);
content = content.replace(/~\/\.codex\//g, pathPrefix);
// #1515 stamp moved to _stampNonClaudeRuntimeDefaults (#1521 generalisation).
content = processAttribution(content, attribution);
break;
@@ -2402,6 +2483,11 @@ function rewriteStagedSkillBodies(stagedDir, opts) {
* attribution from opts, then delegates to applyRuntimeContentRewritesForCommandsInPlace
* (single copy+rewrite owner).
*
* @internal — symmetric companion to rewriteStagedSkillBodies; retained as the deep-seam
* API for command bodies. No production caller today (install rewrites commands via
* copyWithPathReplacement → applyRuntimeContentRewritesForCommandsInPlace). Kept for
* API symmetry + test coverage.
*
* @returns {string} path to the temp dir (caller is responsible for cleanup)
*/
function rewriteStagedCommandBodies(stagedDir, opts) {
@@ -2494,6 +2580,9 @@ export = {
convertClaudeCommandToKiloSkill,
readGsdCommandNames,
transformContentToHyphen,
// #1383: version resolver (exported for regression test of the Codex
// missing-package.json crash + the VERSION-file source of truth).
resolveVersionFrom,
// #1182: agent converters + tool-name table dependency closure
claudeToCopilotTools,
convertCopilotToolName,
@@ -2517,4 +2606,7 @@ export = {
rewriteStagedCommandBodies,
_computePathPrefix: computePathPrefix,
_applyRuntimeRewrites,
_stampNonClaudeRuntimeDefaults,
// #1521: canonical non-Claude runtime list for test files and tooling
NON_CLAUDE_RUNTIMES,
};

View File

@@ -0,0 +1,165 @@
'use strict';
/**
* Runtime Artifact Install Plan Module.
*
* Turns a pre-resolved runtime artifact layout into staged copy inputs. The
* installer adapter still owns pruning, copying, migrations, output, and final
* cleanup execution.
*/
// In .cts (CommonJS output) files, `require` is available as a global.
const _require: NodeRequire = require;
const path = _require('node:path') as typeof import('node:path');
type ArtifactKindName = 'commands' | 'agents' | 'skills' | 'kimi-agents';
type InstallScope = 'local' | 'global';
interface ResolvedProfile {
name?: string;
skills?: Set<string> | '*';
agents?: Set<string>;
}
interface ArtifactKind {
kind: ArtifactKindName;
destSubpath: string;
prefix?: string;
stage: (resolvedProfile: ResolvedProfile) => string;
}
interface Layout {
runtime: string;
configDir: string;
scope?: InstallScope;
kinds: ArtifactKind[];
}
interface RewriteOpts {
runtime: string;
configDir: string;
scope: InstallScope;
homedir?: () => string;
platform?: NodeJS.Platform;
resolveAttribution?: (runtime: string) => string | null | undefined;
}
interface Dependencies {
rewriteStagedSkillBodies?: (stagedDir: string, opts: RewriteOpts) => string | void;
rewriteStagedCommandBodies?: (stagedDir: string, opts: RewriteOpts) => string | void;
}
interface RuntimeArtifactConversionExports {
rewriteStagedSkillBodies: (stagedDir: string, opts: RewriteOpts) => string | void;
rewriteStagedCommandBodies: (stagedDir: string, opts: RewriteOpts) => string | void;
}
interface PlanItem {
kind: ArtifactKindName;
sourceDir: string;
destDir: string;
}
interface InstallPlan {
items: PlanItem[];
cleanupDirs: string[];
}
interface UninstallPlanItem {
kind: ArtifactKindName;
destDir: string;
}
interface UninstallPlan {
items: UninstallPlanItem[];
}
type InstallPlanResult =
| { ok: true; plan: InstallPlan }
| { ok: false; kind: 'stage_failed' | 'rewrite_failed'; message: string; cleanupDirs: string[]; failedKind?: ArtifactKindName };
interface CreateRuntimeArtifactInstallPlanArgs {
layout: Layout;
resolvedProfile: ResolvedProfile;
homedir?: () => string;
platform?: NodeJS.Platform;
resolveAttribution?: (runtime: string) => string | null | undefined;
deps?: Dependencies;
}
function errorMessage(err: unknown): string {
if (err instanceof Error) return err.message;
return String(err);
}
function addCleanupDir(cleanupDirs: string[], stagedDir: string, rewrittenDir: string | void): string {
const sourceDir = rewrittenDir ?? stagedDir;
if (sourceDir !== stagedDir) cleanupDirs.push(sourceDir);
return sourceDir;
}
function createRuntimeArtifactInstallPlan(args: CreateRuntimeArtifactInstallPlanArgs): InstallPlanResult {
const {
layout,
resolvedProfile,
homedir,
platform,
resolveAttribution,
deps = {},
} = args;
const conversionExports = _require('./runtime-artifact-conversion.cjs') as RuntimeArtifactConversionExports;
const rewriteStagedSkillBodies = deps.rewriteStagedSkillBodies ?? conversionExports.rewriteStagedSkillBodies;
const rewriteStagedCommandBodies = deps.rewriteStagedCommandBodies ?? conversionExports.rewriteStagedCommandBodies;
const cleanupDirs: string[] = [];
const items: PlanItem[] = [];
const scope = layout.scope ?? 'global';
const rewriteOpts: RewriteOpts = {
runtime: layout.runtime,
configDir: layout.configDir,
scope,
homedir,
platform,
resolveAttribution,
};
for (const kind of layout.kinds) {
let stagedDir: string;
try {
stagedDir = kind.stage(resolvedProfile);
} catch (err) {
return { ok: false, kind: 'stage_failed', message: errorMessage(err), cleanupDirs, failedKind: kind.kind };
}
let sourceDir = stagedDir;
try {
if (kind.kind === 'commands') {
const rewrittenDir = rewriteStagedCommandBodies(stagedDir, rewriteOpts);
sourceDir = addCleanupDir(cleanupDirs, stagedDir, rewrittenDir);
} else if (kind.kind === 'skills' || kind.kind === 'kimi-agents') {
const rewrittenDir = rewriteStagedSkillBodies(stagedDir, rewriteOpts);
sourceDir = addCleanupDir(cleanupDirs, stagedDir, rewrittenDir);
}
} catch (err) {
return { ok: false, kind: 'rewrite_failed', message: errorMessage(err), cleanupDirs, failedKind: kind.kind };
}
items.push({
kind: kind.kind,
sourceDir,
destDir: path.join(layout.configDir, kind.destSubpath),
});
}
return { ok: true, plan: { items, cleanupDirs } };
}
function createRuntimeArtifactUninstallPlan(layout: Layout): UninstallPlan {
return {
items: layout.kinds.map((kind) => ({
kind: kind.kind,
destDir: path.join(layout.configDir, kind.destSubpath),
})),
};
}
export = { createRuntimeArtifactInstallPlan, createRuntimeArtifactUninstallPlan };

View File

@@ -89,6 +89,48 @@ export function resolveRuntimeNameFromCandidates(...candidates: unknown[]): stri
return null;
}
/**
* Map a runtime id to its project instruction file path (relative to project
* root). Bug #1529: this is the SINGLE source of truth shared by both
* consumption surfaces —
* (A) the Node surface: profile-output.cjs (generate-claude-md handler)
* (B) the bash surface: `gsd-tools query project-instruction-file --runtime <r>`,
* consumed by gsd-core/workflows/new-project.md to set $INSTRUCTION_FILE
*
* Mapping table (per the #1529 issue contract):
*
* claude → .claude/CLAUDE.md
* codex, opencode, kilo, kimi → AGENTS.md
* copilot → .github/copilot-instructions.md
* antigravity, gemini → GEMINI.md
* unknown / future runtimes → AGENTS.md (safe cross-agent default)
*
* Source-of-truth references for each runtime's read path:
* - copilot: GitHub Docs — repository-wide custom instructions are read ONLY
* from `.github/copilot-instructions.md`; a root `copilot-instructions.md`
* is not a read path. `AGENTS.md` is also read (agent instructions).
* https://docs.github.com/en/copilot/how-tos/configure-custom-instructions/add-repository-instructions
* (Installer parity: runtime-config-adapter-registry.cts installSurface
* 'copilot-instructions' writes the same `.github/copilot-instructions.md`.)
* - codex/opencode/kilo/kimi: AGENTS.md is the documented cross-agent
* instruction file (agentsmd/agents.md convention).
* - antigravity/gemini: GEMINI.md is Gemini CLI's contextFileName.
*
* Aliases are normalized via `canonicalizeRuntimeName` first, so inputs like
* `codex-cli` resolve to `codex` → `AGENTS.md`. Replaces the prior codex-only
* override in profile-output.cjs (#3163) which left AGENTS-native runtimes
* (opencode/kilo/kimi) incorrectly emitting `.claude/CLAUDE.md`. Pure: no I/O.
*/
export function getProjectInstructionFile(runtime: unknown): string {
const canonical = canonicalizeRuntimeName(runtime);
if (canonical === 'claude') return '.claude/CLAUDE.md';
if (canonical === 'copilot') return '.github/copilot-instructions.md';
if (canonical === 'antigravity' || canonical === 'gemini') return 'GEMINI.md';
// codex, opencode, kilo, kimi, AND unknown/future runtimes all default to
// root AGENTS.md (the safe cross-agent instruction file).
return 'AGENTS.md';
}
/**
* Map a canonical runtime id to its on-disk local config directory name
* (e.g. `cursor` -> `.cursor`, `windsurf` -> `.devin`). Unknown/empty inputs

View File

@@ -550,17 +550,71 @@ export function normalizeContent(filePath: string, content: string, opts: { enco
return { content: normalized, encoding };
}
// Rename errnos that are transient on Windows: a concurrent reader (or an AV
// scanner / indexer) holding the target open makes renameSync fail briefly.
// Same idiom as capability-ledger.cts / capability-consent.cts.
const RENAME_RETRY_ERRNOS = new Set(['EPERM', 'EBUSY', 'EACCES']);
const RENAME_MAX_ATTEMPTS = 3;
const RENAME_RETRY_BACKOFF_MS = 50;
/** Synchronous best-effort backoff sleep (Atomics.wait — same idiom as io.cts). */
let _renameSleepBuf: Int32Array | null = null;
function renameBackoff(): void {
if (_renameSleepBuf === null) _renameSleepBuf = new Int32Array(new SharedArrayBuffer(4));
Atomics.wait(_renameSleepBuf, 0, 0, RENAME_RETRY_BACKOFF_MS);
}
/**
* Atomic publish with bounded retry on transient Windows lock errnos.
* Returns null on success, or the final error if every attempt failed.
*/
function atomicRenameWithRetry(tmpPath: string, filePath: string): NodeJS.ErrnoException | null {
let renameErr: NodeJS.ErrnoException | null = null;
for (let attempt = 1; attempt <= RENAME_MAX_ATTEMPTS; attempt++) {
try {
fs.renameSync(tmpPath, filePath);
return null;
} catch (err) {
renameErr = err as NodeJS.ErrnoException;
if (attempt < RENAME_MAX_ATTEMPTS && RENAME_RETRY_ERRNOS.has(renameErr.code ?? '')) {
renameBackoff();
continue;
}
break;
}
}
return renameErr;
}
export function platformWriteSync(filePath: string, content: string, opts: { encoding?: BufferEncoding } = {}): void {
const { content: normalized, encoding } = normalizeContent(filePath, content, opts);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const tmpPath = filePath + '.tmp.' + process.pid;
// Step 1: write the sibling tmp file. If THIS fails, nothing was published, so a
// direct fallback write cannot truncate a concurrent reader of an existing file.
try {
fs.writeFileSync(tmpPath, normalized, encoding);
fs.renameSync(tmpPath, filePath);
} catch {
try { fs.unlinkSync(tmpPath); } catch { /* already gone */ }
fs.writeFileSync(filePath, normalized, encoding);
return;
}
// Step 2: atomic publish, retrying transient Windows locks.
const renameErr = atomicRenameWithRetry(tmpPath, filePath);
if (renameErr === null) return;
try { fs.unlinkSync(tmpPath); } catch { /* already gone */ }
if (RENAME_RETRY_ERRNOS.has(renameErr.code ?? '')) {
// A live reader still holds the target open after every retry. A non-atomic
// direct write here would truncate that reader (the exact corruption this seam
// exists to prevent), so surface the error instead of falling back.
throw renameErr;
}
// Atomic publish is genuinely impossible here (e.g. EXDEV cross-device move):
// fall back to a direct write to preserve write availability.
fs.writeFileSync(filePath, normalized, encoding);
}
export function platformReadSync(filePath: string, opts: { encoding?: BufferEncoding; required?: boolean } = {}): string | null {

View File

@@ -868,6 +868,241 @@ function cmdWorktreeCleanupWave(cwd: string, args: string[] = []): void {
}
}
interface RecordAgentFields {
agentId: string;
worktreePath: string;
branch: string;
base: string;
}
interface RecordAgentPlan {
ok: boolean;
reason: string;
hint?: string;
entry: CleanupManifestEntry | null;
/** Serialized manifest to write back (with trailing newline); null when ok === false. */
manifest: string | null;
}
/**
* Pure planner for the per-agent wave-manifest append.
*
* Validates the candidate entry at write time using the SAME rules the
* cleanup-wave reader enforces (via `normalizeCleanupManifestEntry`), so an
* entry that `record-agent` accepts is guaranteed to survive
* `normalizeCleanupManifest` on read — a field that would be silently dropped
* at cleanup time fails loudly here instead.
*
* `agent_id` is treated write-strict (required) even though the reader is
* lenient (nullable): the whole point of this verb is to catch an
* under-populated entry at write time, and an entry whose author cannot be
* identified defeats that. A duplicate `(worktree_path, branch)` is also
* rejected loudly — the reader dedups on that key, so a re-record would be
* silently dropped (the failure mode this verb exists to eliminate). The
* on-disk shape stays the existing 4-field entry (`agent_id`, `worktree_path`,
* `branch`, `expected_base`) — no schema change; the reader re-derives
* `allowed_bases`.
*/
function planWorktreeRecordAgent(manifestRaw: string, fields: RecordAgentFields): RecordAgentPlan {
// 1. Write-strict required-field check (loud, with which flag is missing).
// Trim first so a whitespace-only value (" ") is rejected here rather
// than deferred to a guaranteed `git worktree remove` failure at cleanup.
const agentId = (fields.agentId || '').trim();
const worktreePath = (fields.worktreePath || '').trim();
const branch = (fields.branch || '').trim();
const base = (fields.base || '').trim();
const missing: string[] = [];
if (!agentId) missing.push('--agent-id');
if (!worktreePath) missing.push('--path');
if (!branch) missing.push('--branch');
if (!base) missing.push('--base');
if (missing.length > 0) {
return {
ok: false,
reason: 'missing_field',
hint: `record-agent requires ${missing.join(', ')}. Re-run with all of --agent-id, --path, --branch, --base set to non-empty (non-whitespace) values.`,
entry: null,
manifest: null,
};
}
// 2. Shared validation: run the candidate through the reader's normalizer.
// If it returns null the reader would drop this entry on read — reject now.
const candidate = {
agent_id: agentId,
worktree_path: worktreePath,
branch,
expected_base: base,
};
const entry = normalizeCleanupManifestEntry(candidate);
if (!entry) {
return {
ok: false,
reason: 'invalid_entry',
hint: `Entry failed cleanup-manifest validation: --path/--branch/--base must be non-empty and --branch must match ^worktree-agent-[A-Za-z0-9._/-]+$ (got branch="${branch}"). Fix the field and re-run.`,
entry: null,
manifest: null,
};
}
// 3. Parse the existing manifest. The init shell ({orchestrator_root, worktrees: []})
// is written inline by the orchestrator before any agent spawns; a missing or
// malformed manifest is a loud failure here, not a silent under-populated write.
let parsed: unknown;
try {
parsed = JSON.parse(manifestRaw);
} catch {
return {
ok: false,
reason: 'invalid_manifest_json',
hint: 'Manifest is not valid JSON. The orchestrator must initialize it as {"orchestrator_root": "...", "worktrees": []} before recording agents.',
entry: null,
manifest: null,
};
}
// Accept the canonical {worktrees: []} shell or a bare top-level array (both
// are read by normalizeCleanupManifest); preserve any other top-level keys.
let worktrees: unknown[];
let writeBack: unknown;
if (Array.isArray(parsed)) {
worktrees = parsed;
writeBack = worktrees;
} else if (parsed && typeof parsed === 'object') {
const container = parsed as Record<string, unknown>;
if (container.worktrees === undefined) container.worktrees = [];
if (!Array.isArray(container.worktrees)) {
return {
ok: false,
reason: 'manifest_shape_invalid',
hint: 'Manifest "worktrees" must be an array. Re-initialize as {"orchestrator_root": "...", "worktrees": []}.',
entry: null,
manifest: null,
};
}
worktrees = container.worktrees;
writeBack = container;
} else {
return {
ok: false,
reason: 'manifest_shape_invalid',
hint: 'Manifest must be a JSON object {"worktrees": []} or a top-level array.',
entry: null,
manifest: null,
};
}
// 4. Reject a duplicate (worktree_path, branch). The reader dedups on this
// exact key, but only over entries that NORMALIZE successfully — so an
// existing malformed same-key entry (which the reader would drop) must NOT
// block recording a valid one. Run each existing entry through the reader's
// own normalizer and compare only the entries the reader would keep; this
// matches its dedup behavior exactly. A real duplicate signals an upstream
// double-spawn — surface it loudly instead of silently dropping it.
const dupKey = `${entry.worktree_path}\0${entry.branch}`;
const isDuplicate = worktrees.some((existing) => {
const normalized = normalizeCleanupManifestEntry(existing);
return normalized !== null && `${normalized.worktree_path}\0${normalized.branch}` === dupKey;
});
if (isDuplicate) {
return {
ok: false,
reason: 'duplicate_entry',
hint: `The manifest already records worktree_path="${entry.worktree_path}" branch="${entry.branch}". The cleanup reader dedups on (worktree_path, branch), so re-recording would be silently dropped — this usually signals an upstream double-spawn. Investigate rather than re-record.`,
entry: null,
manifest: null,
};
}
// 5. Append the minimal 4-field entry, matching the existing on-disk format.
const recorded: CleanupManifestEntry = {
agent_id: entry.agent_id,
worktree_path: entry.worktree_path,
branch: entry.branch,
expected_base: entry.expected_base,
};
worktrees.push(recorded);
return {
ok: true,
reason: 'ok',
entry: recorded,
manifest: `${JSON.stringify(writeBack, null, 2)}\n`,
};
}
interface RecordAgentCmdDeps {
readFile?: (p: string) => string;
writeFile?: (p: string, content: string) => void;
write?: (s: string) => void;
writeErr?: (s: string) => void;
}
interface RecordAgentCmdResult {
ok: boolean;
reason: string;
hint?: string;
entry: CleanupManifestEntry | null;
manifest_path?: string;
}
/**
* CLI command: append a validated per-agent entry to a wave cleanup manifest.
*
* Usage: worktree record-agent --manifest <path> --agent-id <id> --path <worktree> --branch <branch> --base <sha>
*
* Fails loudly (non-zero exit + recovery hint on stderr) when a field is
* missing/garbled or the manifest is absent/malformed, rather than appending an
* under-populated entry that the cleanup reader would silently drop.
*/
function cmdWorktreeRecordAgent(cwd: string, args: string[] = [], deps: RecordAgentCmdDeps = {}): RecordAgentCmdResult {
const flag = (name: string): string => {
const i = args.indexOf(name);
return i >= 0 && i + 1 < args.length ? args[i + 1] : '';
};
const write = deps.write || ((s: string) => process.stdout.write(s));
const writeErr = deps.writeErr || ((s: string) => process.stderr.write(s));
const manifestPath = flag('--manifest');
if (!manifestPath) {
writeErr('Usage: worktree record-agent --manifest <path> --agent-id <id> --path <worktree> --branch <branch> --base <sha>\n');
process.exitCode = 2;
return { ok: false, reason: 'usage', entry: null };
}
const resolved = path.resolve(cwd, manifestPath);
const readFile = deps.readFile || ((p: string) => fs.readFileSync(p, 'utf8'));
let manifestRaw: string;
try {
manifestRaw = readFile(resolved);
} catch (err) {
const hint = `Manifest not found or unreadable at ${manifestPath}. The orchestrator must initialize it ({"orchestrator_root": "...", "worktrees": []}) before recording agents.`;
writeErr(`[gsd] worktree.record-agent: manifest_read_failed — ${hint}\n`);
write(`${JSON.stringify({ ok: false, reason: 'manifest_read_failed', hint, error: (err as Error).message }, null, 2)}\n`);
process.exitCode = 1;
return { ok: false, reason: 'manifest_read_failed', hint, entry: null };
}
const plan = planWorktreeRecordAgent(manifestRaw, {
agentId: flag('--agent-id'),
worktreePath: flag('--path'),
branch: flag('--branch'),
base: flag('--base'),
});
if (!plan.ok || plan.manifest === null) {
writeErr(`[gsd] worktree.record-agent: ${plan.reason} — ${plan.hint || ''}\n`);
write(`${JSON.stringify({ ok: false, reason: plan.reason, hint: plan.hint }, null, 2)}\n`);
process.exitCode = 1;
return { ok: false, reason: plan.reason, hint: plan.hint, entry: null };
}
const writeFile = deps.writeFile || ((p: string, content: string) => fs.writeFileSync(p, content, 'utf8'));
writeFile(resolved, plan.manifest);
write(`${JSON.stringify({ ok: true, reason: 'ok', entry: plan.entry, manifest_path: resolved }, null, 2)}\n`);
return { ok: true, reason: 'ok', entry: plan.entry, manifest_path: resolved };
}
/**
* Reap orphaned linked worktrees whose lock owner process is dead, whose
* branch tip is fully merged into the default branch, and whose lock file
@@ -1167,6 +1402,8 @@ export = {
planWorktreeWaveCleanup,
executeWorktreeWaveCleanupPlan,
cmdWorktreeCleanupWave,
planWorktreeRecordAgent,
cmdWorktreeRecordAgent,
reapOrphanWorktrees,
cmdWorktreeReapOrphans,
resolveWorktreeRoot,

View File

@@ -620,10 +620,10 @@ describe('parseAdrMarkdown: risks section', () => {
assert.deepEqual(out.consequences_positive, []);
});
test('"Trade-offs" heading normalized to "trade offs" does NOT match synonym "trade-offs" (unreachable synonym)', () => {
test('"Trade-offs" maps to consequences_negative (M7: both sides normalized, synonym now reachable)', () => {
const out = parseAdrMarkdown('## Trade-offs\n- Increased latency.');
assert.deepEqual(out.consequences_negative, []);
assert.ok(out.unmapped_headers.includes('Trade-offs'));
assert.deepEqual(out.consequences_negative, ['Increased latency.']);
assert.ok(!out.unmapped_headers.includes('Trade-offs'));
});
test('"Drawbacks" maps to consequences_negative', () => {
@@ -712,12 +712,12 @@ describe('parseAdrMarkdown: success_criteria section', () => {
assert.deepEqual(out.consequences_positive, ['Better DX.']);
});
test('"How We\'ll Know" normalized to "how well know" does NOT match synonym "how we\'ll know" (unreachable synonym)', () => {
// The apostrophe in "we'll" is stripped by normalizeAdrHeader, yielding "how well know".
// The synonym "how we'll know" is stored with apostrophe — can't match.
test('"How We\'ll Know" maps to consequences_positive (M7: synonym normalized on both sides, now reachable)', () => {
// The apostrophe in "we'll" is stripped by normalizeAdrHeader on BOTH the header and the
// synonym, so both yield "how well know" and now match (success_criteria → consequences_positive).
const out = parseAdrMarkdown("## How We'll Know\n- Sales increase.");
assert.deepEqual(out.consequences_positive, []);
assert.ok(out.unmapped_headers.includes("How We'll Know"));
assert.deepEqual(out.consequences_positive, ['Sales increase.']);
assert.ok(!out.unmapped_headers.includes("How We'll Know"));
});
test('"Compliance" maps to consequences_positive', () => {
@@ -967,12 +967,10 @@ describe('parseAdrMarkdown: key_files section', () => {
// parseAdrMarkdown — out_of_scope section
// ─────────────────────────────────────────────────────────────────────────────
describe('parseAdrMarkdown: out_of_scope section', () => {
test('"Non-goals" heading normalized to "non goals" does NOT match synonym "non-goals" (unreachable synonym)', () => {
// "Non-goals" normalizes to "non goals"; CANONICAL_HEADERS stores "non-goals" (with hyphen).
// classifyHeader does exact equality — these can't match, so it goes to unmapped_headers.
test('"Non-goals" maps to out_of_scope (M7: both sides normalized to "non goals", now reachable)', () => {
const out = parseAdrMarkdown('## Non-goals\n- Not this.');
assert.deepEqual(out.out_of_scope, []);
assert.ok(out.unmapped_headers.includes('Non-goals'));
assert.deepEqual(out.out_of_scope, ['Not this.']);
assert.ok(!out.unmapped_headers.includes('Non-goals'));
});
test('"Excluded" maps to out_of_scope', () => {
@@ -995,10 +993,10 @@ describe('parseAdrMarkdown: out_of_scope section', () => {
assert.deepEqual(out.out_of_scope, ['Billing system.']);
});
test('"Anti-goals" heading normalized to "anti goals" does NOT match synonym "anti-goals" (unreachable synonym)', () => {
test('"Anti-goals" maps to out_of_scope (M7: both sides normalized to "anti goals", now reachable)', () => {
const out = parseAdrMarkdown('## Anti-goals\n- Gold plating.');
assert.deepEqual(out.out_of_scope, []);
assert.ok(out.unmapped_headers.includes('Anti-goals'));
assert.deepEqual(out.out_of_scope, ['Gold plating.']);
assert.ok(!out.unmapped_headers.includes('Anti-goals'));
});
test('out_of_scope is empty when no section', () => {
@@ -1026,12 +1024,10 @@ describe('parseAdrMarkdown: deferred section', () => {
assert.deepEqual(out.deferred, ['Optimize later.']);
});
test('"Follow-up" heading normalized to "follow up" does NOT match synonym "follow-up" (unreachable synonym)', () => {
// Synonym "follow-up" has a hyphen which normalizeAdrHeader converts to a space.
// Since classifyHeader does exact string comparison with raw synonyms, this can't match.
test('"Follow-up" maps to deferred (M7: both sides normalized to "follow up", now reachable)', () => {
const out = parseAdrMarkdown('## Follow-up\n- Monitor metrics.');
assert.deepEqual(out.deferred, []);
assert.ok(out.unmapped_headers.includes('Follow-up'));
assert.deepEqual(out.deferred, ['Monitor metrics.']);
assert.ok(!out.unmapped_headers.includes('Follow-up'));
});
test('"Next Steps" maps to deferred', () => {
@@ -1074,10 +1070,10 @@ describe('parseAdrMarkdown: dependencies section', () => {
assert.deepEqual(out.dependencies, ['Team capacity.']);
});
test('"Cross-cuts" heading normalized to "cross cuts" does NOT match synonym "cross-cuts" (unreachable synonym)', () => {
test('"Cross-cuts" maps to dependencies (M7: both sides normalized to "cross cuts", now reachable)', () => {
const out = parseAdrMarkdown('## Cross-cuts\n- Security layer.');
assert.deepEqual(out.dependencies, []);
assert.ok(out.unmapped_headers.includes('Cross-cuts'));
assert.deepEqual(out.dependencies, ['Security layer.']);
assert.ok(!out.unmapped_headers.includes('Cross-cuts'));
});
test('"Related ADRs" maps to dependencies', () => {
@@ -1144,10 +1140,11 @@ describe('parseAdrMarkdown: update section', () => {
assert.deepEqual(out.updates[0].entries, ['Ship v2.']);
});
test('"Post-grilling" heading normalized to "post grilling" does NOT match synonym "post-grilling" (unreachable synonym)', () => {
test('"Post-grilling" maps to updates (M7: both sides normalized to "post grilling", now reachable)', () => {
const out = parseAdrMarkdown('## Post-grilling\n- Revised after review.');
assert.equal(out.updates.length, 0);
assert.ok(out.unmapped_headers.includes('Post-grilling'));
assert.equal(out.updates.length, 1);
assert.deepEqual(out.updates[0].entries, ['Revised after review.']);
assert.ok(!out.unmapped_headers.includes('Post-grilling'));
});
test('"Addendum" maps to updates', () => {
@@ -1208,6 +1205,59 @@ describe('parseAdrMarkdown: consequences canonical section', () => {
});
});
// ─────────────────────────────────────────────────────────────────────────────
// classifyHeader — cross-bucket synonym collision (audit M7)
// 'trade-offs' must resolve to risks (consequences_negative), not considered_options.
// CANONICAL_HEADERS once listed 'trade-offs' under BOTH buckets; classifyHeader is
// first-match-wins over Object.entries and considered_options is declared first, so
// '## Trade-offs' always misclassified as options and the risks entry was dead code.
// ─────────────────────────────────────────────────────────────────────────────
describe('parseAdrMarkdown: punctuated synonyms are reachable (M7)', () => {
// Root cause: classifyHeader receives a normalized header but historically compared it
// against RAW synonyms; normalizeAdrHeader collapses [\s:._-]+ → space and strips [^\w\s],
// so any synonym with a hyphen/apostrophe was dead and its section went unmapped. The fix
// normalizes both sides, making the whole class reachable while the table stays readable.
test('"## Trade-offs" lands in consequences_negative (risks), not options_considered', () => {
const out = parseAdrMarkdown('## Trade-offs\n- adds a per-acquire syscall\n- larger lock body');
assert.deepEqual(out.consequences_negative, ['adds a per-acquire syscall', 'larger lock body']);
assert.deepEqual(out.options_considered, []);
});
test('all formerly-dead punctuated headers now classify to their bucket', () => {
assert.deepEqual(parseAdrMarkdown('## Non-Goals\n- x').out_of_scope, ['x']);
assert.deepEqual(parseAdrMarkdown('## Anti-Goals\n- x').out_of_scope, ['x']);
assert.deepEqual(parseAdrMarkdown("## Won't Do\n- x").out_of_scope, ['x']);
assert.deepEqual(parseAdrMarkdown('## Follow-up\n- x').deferred, ['x']);
assert.deepEqual(parseAdrMarkdown('## Cross-cuts\n- x').dependencies, ['x']);
assert.deepEqual(parseAdrMarkdown("## How We'll Know\n- x").consequences_positive, ['x']);
assert.equal(parseAdrMarkdown('## Post-grilling\n- 2026-01-01: note').updates[0].heading, 'Post-grilling');
});
test("'trade-offs' lives only in risks (de-duped from considered_options to avoid a cross-bucket collision)", () => {
assert.ok(!CANONICAL_HEADERS.considered_options.includes('trade-offs'));
assert.ok(CANONICAL_HEADERS.risks.includes('trade-offs'));
});
// Reachability invariant — guards the whole class against regression: every synonym in
// CANONICAL_HEADERS must classify (a header written as that synonym is never unmapped),
// and no two synonyms may normalize into different buckets (cross-bucket collision).
test('invariant: every CANONICAL_HEADERS synonym is reachable and collision-free', () => {
const byNormalized = new Map();
for (const [bucket, synonyms] of Object.entries(CANONICAL_HEADERS)) {
for (const syn of synonyms) {
const out = parseAdrMarkdown(`## ${syn}\n- z`);
assert.ok(!out.unmapped_headers.includes(syn), `synonym "${syn}" (bucket ${bucket}) is unreachable`);
const n = syn.toLowerCase().replace(/[\s:._-]+/g, ' ').replace(/[^\w\s]/g, '').trim();
if (byNormalized.has(n)) {
assert.equal(byNormalized.get(n), bucket, `normalized synonym "${n}" collides across buckets (${byNormalized.get(n)} vs ${bucket})`);
} else {
byNormalized.set(n, bucket);
}
}
}
});
});
// ─────────────────────────────────────────────────────────────────────────────
// classifyHeader — prefix-match branch
// ─────────────────────────────────────────────────────────────────────────────

View File

@@ -28,7 +28,8 @@ function parseWorkflowSteps(content) {
name: match[1],
// After #3797 architectural fix, callsites use gsd_run
readsRuntimeConfig: body.includes('RUNTIME=$(gsd_run query config-get runtime --default claude'),
codexWorktreeGuard: body.includes('Codex execute-phase worktree isolation is unsupported'),
// #1521: guard generalized from Codex-specific to all non-Claude runtimes
codexWorktreeGuard: body.includes('git worktree isolation') && body.includes('unsupported on runtime'),
worktreeDispatchGuidance: body.includes('isolation="worktree"'),
};
});

View File

@@ -60,7 +60,10 @@ describe('bug #685: Windows spawns must set windowsHide:true (no console-window
test('roadmap-upgrade execSync git calls all set windowsHide', () => {
const src = read('src/roadmap-upgrade.cts');
const calls = src.match(/execSync\([^)]*\)/g) || [];
assert.ok(calls.length >= 4, 'expected the roadmap-upgrade git execSync calls to be present');
// #1542 made rollback git-independent (surgical fs restore), so the only
// remaining git execSync is the `git status --porcelain` precondition. The
// durable guard is that EVERY git execSync still present sets windowsHide.
assert.ok(calls.length >= 1, 'expected at least the roadmap-upgrade git status execSync call to be present');
const missing = calls.filter((c) => !/windowsHide:\s*true/.test(c));
assert.deepEqual(missing, [], `execSync without windowsHide:\n${missing.join('\n')}`);
});

Some files were not shown because too many files have changed in this diff Show More