gsd-core

GitHub

Git. Ship. Done - Core

RAW Doc

README

GSD Core documentation

Documentation is organised into four quadrants: tutorials help you learn by doing, how-to guides solve specific tasks, reference states authoritative facts, and explanation explores concepts and design decisions.

Language versions: English · Português (pt-BR) · 日本語 · 简体中文

---

Tutorials

- Your first project — install to first shipped phase, one guaranteed path
- Onboarding an existing codebase — bring GSD Core to a brownfield repo
- Build your first capability — author a tiny declarative capability and watch it act in the loop
- Install your first capability — install a third-party capability end-to-end: consent, verify, check for updates, remove

---

How-to guides

- Install on your runtime — runtime-specific install steps for all 16 supported runtimes
- Install a minimal GSD and add skills later — install only the core skills, then grow the surface with profiles and /gsd-surface
- Attach a plugin-provided skill to a GSD agent — use the global:plugin:skill entry form to load Claude Code plugin skills into agent prompts
- Discuss a phase — capture implementation decisions before planning begins
- Resolve edge-coverage findings — turn the spec phase's surfaced domain-boundary edges into covered, dismissed, or backstopped spec decisions
- Resolve prohibition findings — turn the spec phase's surfaced must-NOT constraints into resolved, dismissed, or deferred spec decisions
- Plan a phase — run research, decompose work, and verify plan quality
- Execute a phase — run plans in parallel waves with fresh-context subagents
- Verify and ship — walk through completed work, diagnose failures, and create the PR
- Run phases autonomously — use autonomous mode for unattended phase execution
- Handle quick and fast tasks — use /gsd-quick and /gsd-fast for ad-hoc work outside the phase loop
- Configure model profiles — switch between quality, balanced, and budget model tiers
- Set up cross-AI review — configure a second AI to review code produced by the primary agent
- Work in parallel with workstreams — run independent lines of work simultaneously using workstreams
- Isolate work with workspaces — use workspaces to sandbox experimental or risky changes
- Debug a failed execution — diagnose and recover from broken or incomplete phase execution
- Spike and sketch — use /gsd-spike and /gsd-sketch for exploratory work before committing to a plan
- Design a UI phase — use the UI phase loop for frontend and visual work
- Develop a Capability for GSD 1.5+ — add feature Capabilities, hook fragments, and registry entries
- Ship a reviewer lane in your capability — declare a reviewer body so /gsd-review discovers, invokes, and renders your external review CLI or model endpoint
- List your reviewer lane in the registry — publish a lane you have built to the Reviewer Lane Registry so other people can find and install it
- Take over a capability or EoS integration — assume maintainership of an existing third-party capability, reviewer lane, or EoS host integration through a handoff, an adoption fork, first-party absorption, or a de-listing
- Add or update a host's integration — set a host's documentation-sourced runtime.hostIntegration axes (ADR-1239 Phase A), with the undocumented sentinel rule
- Turn a capability off (and keep it off) — disable a capability via the surface, or gate individual hooks off without removing the capability
- Drive GSD from a tracker issue — start a phase from a GitHub, Linear, or Jira issue
- Migrate from GSD 2 — upgrade an existing GSD 2 project to GSD Core
- Update GSD — re-run the installer to pick up the latest release
- Clean up get-shit-done-cc — remove leftover old-package artifacts that cause a spurious ⬆ /gsd-update indicator after migrating to @opengsd/gsd-core
- Fix the worktree base-mismatch (exit 42) error — resolve the branch-divergence condition that halts parallel phase execution
- Recover and troubleshoot — fix common problems, rebuild context, and uninstall

---

Reference

- Commands — every command with flags and examples
- Configuration — full config schema, model profiles, git branching strategies
- CLI toolsgsd-tools.cjs programmatic API for workflows and agents
- Features — complete feature index
- Inventory — installed skills and surface map
- STATE.md schema — field-by-field reference for .planning/STATE.md
- CONTEXT.md schema — field-by-field reference for .planning/phases/<N>/CONTEXT.md
- PLAN.md schema — field-by-field reference for .planning/phases/<N>/PLAN.md
- Planning artifacts — all .planning/ files and their roles
- Review and verification capabilities — code review, security, and Nyquist capability ownership and hook contracts
- Gate predicates — canonical specification of the phase-gate predicate vocabulary
- Capability matrix — generated catalogue of every capability's role, tier, extension points, hook kinds, and engines.gsd
- Capability manifest — the full capability.json schema and validation rules
- gsd capability command — install / update / remove / list reference for third-party capabilities
- Workflow fragments — in-file ` marker grammar for fragmentizing workflow markdown at emission time
- Reviewer Lane Registry — generated catalogue of third-party reviewer lanes, with their flags, transport, and install commands

---

Explanation

- Context engineering — how context rot forms and how GSD Core prevents it
- The phase loop — design rationale for the Discuss → Plan → Execute → Verify → Ship cycle
- Multi-agent orchestration — how subagents are spawned, scoped, and coordinated
- Security model — trust boundaries, permissions, and safe automation
- The capability trust model — why third-party capabilities are gated by consent + integrity + reversibility, not a sandbox
- How overlay capabilities compose — why first-party always wins and how the loader resolves precedence, conflicts, and fail-open load-failure warnings
- Architecture — system architecture, agent model, and data flow
- The Embeddable Orchestration System — one public, versioned contract for embedding GSD across many hosts
- Discuss modes — assumptions mode vs interview mode for
/gsd-discuss-phase
- Context monitoring — context window monitoring hook architecture
- Issue-driven orchestration — recipe for driving GSD from a tracker issue using existing primitives

---

- What's new in 1.7.0 — curated highlights of the 1.7.0 release
- Root README — landing page, quickstart, and documentation overview
- Changelog — release history

---

Adr/0001 Dispatch Policy Module

Dispatch policy module as single seam for query execution outcomes

- Status: Accepted
- Date: 2026-05-03

We decided to centralize query dispatch outcomes in one Dispatch Policy Module that returns a structured union result (ok success or failure with typed kind, details, and final exit_code) instead of mixing throws and ad-hoc error mapping across CLI and SDK paths. This keeps fallback policy, timeout classification, and exit mapping in one place for better locality, prevents drift between native and fallback behavior, and makes callers thin adapters over a stable interface.

Amendment (2026-05-03): query seam deepening completion

To complete the query architecture pass, we deepened adjacent seams around the Dispatch Policy Module:

- Extracted Query Runtime Context Module to own projectDir + ws resolution policy.
- Extracted Native Dispatch Adapter Module so Dispatch Policy consumes a stable native dispatch Interface (not closure-wired call sites).
- Extracted Query CLI Output Module to own projection from dispatch results/errors to CLI output contract.
- Converged internal command-resolution and policy imports onto canonical modules and removed dead wrapper modules.
- Added Command Topology Module as dispatch-facing seam that resolves commands, projects command policy, binds handler Adapters, and emits no-match diagnosis consumed by Dispatch Policy.
- Locked pre-project query config policy for parity-sensitive query Interfaces: when
.planning/config.json is absent, use built-in defaults and parity-aligned empty model ids for model-resolution surfaces.
- Gated real-CLI SDK E2E suites behind explicit opt-in (
GSD_ENABLE_E2E=1) to keep default CI/local verification deterministic while preserving full-path validation when requested.

Dead-wrapper convergence

Removed wrapper Modules after call-site convergence:
-
normalize-query-command.ts
-
command-resolution.ts
-
policy-convergence.ts
-
query-policy-snapshot.ts
-
query-registry-capability.ts

This amendment preserves the original ADR direction: keep policy depth high, adapters thin, and locality concentrated in explicit modules.

Amendment (2026-05-05): SDK Runtime Bridge seam deepening

To make SDK dispatch a cleaner publishable seam, we deepened GSDTools dispatch behind one SDK Runtime Bridge Module (sdk/src/query-runtime-bridge.ts) and converged policy wiring into that seam:

- GSDTools callers now route through one runtime bridge Interface for command resolution, execution, and hotpath dispatch.
- Added explicit fallback policy at the seam (
allowFallbackToSubprocess) instead of implicit transport behavior.
- Added strict native-only enforcement mode (
strictSdk) so SDK consumers can fail fast when a command lacks a native adapter.
- Added structured bridge observability (
onDispatchEvent) for dispatch mode, fallback reason, latency, outcome, and error kind.
- Kept transport and command callers as thin adapters over the bridge seam.

This continues the dispatch-policy design goal: deep policy Modules, thin Adapters, and high locality for behavior changes.

---

Adr/0002 Command Contract Validation Module

Command Contract Validation Module

- Status: Accepted
- Date: 2026-05-05

We decided to centralize the commands/gsd/*.md file contract into a single validation seam enforced at two layers: a fast lint script (scripts/lint-command-contract.cjs) that runs as a pre-test CI step, and a behavioral regression test (tests/command-contract.test.cjs) that validates the full contract against the live filesystem.

Decision

The command file contract defines what makes a valid commands/gsd/*.md:

- name: field present, non-empty, matches gsd: or gsd- (ns- commands use gsd-)
-
description: field present and non-empty
-
allowed-tools: block present and non-empty, all entries from the canonical tool set
- Every
@-reference inside <execution_context> blocks resolves to an existing file on disk
-
@-references inside <execution_context> blocks appear on their own line (no trailing prose)

Context

Before this ADR, the command contract was enforced inconsistently:
-
tests/skill-frontmatter-contract.test.cjs (folds former enh-2790-skill-consolidation, consolidation epic #1969) checked existence and frontmatter of specific post-consolidation commands
-
tests/docs-update.test.cjs (folds former bug-3135-capture-backlog-workflow, consolidation epic #1969) checked execution_context @-ref resolution (added 2026-05-05)
- No test checked
allowed-tools validity, name: convention, or description: non-emptiness across all commands simultaneously

This meant any PR touching a command file could break the contract without a single test catching it. The add-backlog.md gap (#3135) is a concrete example: the workflow file was missing for the full consolidation cycle before a targeted regression test was written.

Additionally, 40 of 65 command files contained redundant prose @-references — the same path appearing once in <execution_context> (which loads the file) and again in <process> body text (inert). This added ~900 tokens of dead weight per invocation and created a drift seam where prose refs could go stale independently of the executable execution_context ref.

The two largest commands (debug.md, thread.md) embedded their full implementation inline rather than delegating to workflow files, causing ~4,400 tokens of implementation detail to load as part of the skills index description on every session regardless of whether those commands are used.

Consequences

- A single lint-command-contract.cjs script enforces frontmatter invariants across all 65 commands in milliseconds, runs before the test suite in CI
-
tests/command-contract.test.cjs replaces the scattered contract coverage in enh-2790 and bug-3135, becoming the authoritative behavioral contract test for the entire command surface
- Redundant prose @-refs removed from 40 command files (~900 tokens/invocation recovered)
-
debug.md and thread.md refactored to the workflow-delegation pattern (~4,400 tokens removed from eager system-prompt load)
-
workflows/extract_learnings.md renamed to workflows/extract-learnings.md to align with the hyphen convention used by all other workflow files
- The
execution_context block is the single authoritative declaration of what a command loads — no duplication in prose

---

Adr/0003 Model Catalog Module

Model Catalog Module as single source of truth for agent profiles and runtime tier defaults

- Status: Accepted
- Date: 2026-05-07

We decided to centralize model-selection data in one Model Catalog Module so the SDK, the CLI/CJS layer, and the docs do not maintain separate agent lists, profile maps, or runtime tier defaults.

Problem

Before this ADR there were four drifting sources:

1. gsd-core/bin/lib/model-profiles.cjs — agent → profile alias map, phase-type map, dynamic-routing default tiers
2.
sdk/src/query/config-query.ts — stale 18-agent copy of MODEL_PROFILES
3.
gsd-core/workflows/settings-advanced.md — runtime → built-in model-id table
4.
sdk/src/session-runner.ts — hardcoded Claude-only profile → model-id map

This caused issue #3229: the SDK knew only 18 agents while 33 agent files existed on disk, so ~15 agents silently fell back to Sonnet with unknown_agent: true.

Decision

Create one machine-readable catalog and derive everything else from it.

The catalog owns:
- supported runtime names
- runtime tier defaults (
opus / sonnet / haiku) and runtime capabilities (e.g. reasoning_effort support)
- the full agent registry for model resolution
- the canonical per-agent golden alias (quality intent)
- derived profile aliases for
balanced, budget, and adaptive
- agent → phase-type mapping
- agent → dynamic-routing default tier mapping

The canonical file lives in a location both packages ship:
- repo root package (
@opengsd/get-shit-done-redux) includes it
- standalone SDK package (
@opengsd/gsd-sdk) includes it

Both CJS and SDK load this exact file. Neither package keeps its own independent list.

Golden profile

The catalog stores a golden alias per agent. quality is defined as the golden profile exactly. Other profiles (balanced, budget, adaptive) are explicit views over the same agent registry. This keeps the highest-quality intent in one place while allowing lower-cost profiles to differ per agent where needed.

Consequences

- resolve-model in SDK and CJS read the same registry, so missing-agent drift disappears
-
settings-advanced.md runtime tier table must stay in parity with the catalog (enforced by test)
-
sdk/src/query/helpers.ts runtime list comes from the catalog, fixing drift like the missing hermes runtime
-
sdk/src/session-runner.ts uses the catalog's Claude runtime tier defaults instead of a private hardcoded profile map
- tests validate:
- every
agents/gsd-*.md file exists in the catalog
- SDK and CJS resolve the same aliases for all known agents
- unknown-agent fallback follows profile semantics (
qualityopus, budgethaiku, etc.), not a hardcoded sonnet
- docs/runtime tables stay aligned with the catalog

---

Adr/0004 Worktree Workstream Seam Module

Planning Workspace Module as single seam for worktree and workstream state

- Status: Accepted
- Date: 2026-05-08

We decided to treat planning/worktree behavior as one explicit Planning Workspace Module Interface rather than spread policy across ad-hoc call sites. The Module owns .planning path resolution, active workstream pointer policy, workstream-name invariants, and lock semantics, while a focused Worktree Root Resolution Adapter owns linked-worktree root mapping and metadata prune behavior. This raises depth at the seam, increases leverage for callers, and improves locality for bug fixes in the worktree/workstream loop.

Decision

- The Planning Workspace Module Interface is authoritative for:
-
planningDir / planningRoot / planningPaths
- active workstream pointer policy (
session-scoped > shared)
- pointer self-heal behavior (invalid/stale pointers clear to null)
- planning lock semantics (
withPlanningLock)
- Worktree root detection stays behind one Worktree Root Resolution Adapter (
resolveWorktreeRoot), so callers do not re-derive git-dir/common-dir logic.
- Worktree metadata cleanup remains non-destructive by default:
pruneOrphanedWorktrees runs git worktree prune only and does not remove linked worktree directories.
- Workstream naming is one invariant across create/migrate/set/get/env-pointer paths: values must be canonical slugs that remain addressable by all workstream commands.

Consequences

- Tests can pin behavior through one Interface instead of source-grep fragments, improving regression quality for worktree/workstream bugs.
- Bug classes caused by contract drift (for example migration names accepted in one path but rejected in another) are fixed once in the Module and propagate to all callers.
- Callers become thin Adapters over a deeper seam; future policy changes (session identity strategy, lock recovery, worktree prune behavior) stay localized.

---

Adr/0005 Sdk Architecture Seam Map

SDK Architecture seam map for query/runtime surfaces

- Status: Superseded by ADR-0174 (2026-05-23); originally Accepted (2026-05-09)
- Date: 2026-05-09

We decided to keep SDK architecture explicitly module-seamed rather than allow feature logic to spread across query handlers, runtime adapters, and compatibility shims. This ADR is the top-level map for SDK seams and their ownership boundaries.

Decision

- Treat the SDK as a composition of explicit seam Modules with thin call-site Adapters.
- Keep compatibility policy isolated behind the SDK Package Seam Module (see
0007-sdk-package-seam-module.md).
- Keep dispatch transport/outcome policy behind the Dispatch Policy Module and SDK Runtime Bridge Module (see
0001-dispatch-policy-module.md amendment).
- Keep model/runtime profile resolution behind the Model Catalog Module (see
0003-model-catalog-module.md).
- Keep planning/worktree/workstream path-state policy behind the Planning Workspace Module (see
0004-worktree-workstream-seam-module.md).
- Keep planning path projection policy explicit and centralized (detailed in
0006-planning-path-projection-module.md).

Consequences

- SDK callers (init*, query handlers, runtime entry points) remain thin Adapters over stable interfaces.
- Changes to package layout compatibility, dispatch transport, model policy, and planning path policy are localized to owning Modules.
- Architecture reviews can classify drift quickly: if behavior changes outside owning seam Module, it is a design violation.

---

Adr/0006 Planning Path Projection Module

Planning Path Projection Module for SDK query handlers

- Status: Accepted
- Date: 2026-05-09

We decided to centralize SDK planning-path projection behind one Module interface instead of reconstructing .planning paths in each handler with ad-hoc joins. This deepens the planning seam and prevents path-policy drift between helper and caller layers.

Decision

- helpers.planningPaths(projectDir, workstream?) is the canonical SDK projection interface for planning paths.
-
helpers.planningPaths delegates to workspacePlanningPaths + resolveWorkspaceContext for policy, not duplicate local path composition.
- Policy precedence is explicit and stable:
explicit workstream > env workstream > env project > root.
- Query/init handlers (
initExecutePhase, initPlanPhase, initPhaseOp, initMilestoneOp) must consume planningPaths(...).planning rather than direct relPlanningPath joins.
- SDK project scope for planning is
.planning/<project> (never .planning/projects/<project>), aligned with CJS planning workspace behavior.

Consequences

- One fix in planning path policy updates all handlers and reduces regression surface.
- Tests can target seam behavior (
workspace.test.ts, helpers.test.ts, init handler tests) instead of source-grep heuristics.
- Cross-package parity bugs between SDK and CJS planning path resolution become easier to detect and correct.

---

Adr/0007 Sdk Package Seam Module

SDK Package Seam Module owns SDK-to-get-shit-done-redux compatibility

- Status: Superseded by ADR-0174 (2026-05-23); originally Accepted (2026-05-07)
- Date: 2026-05-07

We decided to define one explicit SDK Package Seam Module for the @opengsd/gsd-sdk@opengsd/get-shit-done-redux transition. During this transition, install-layout probing, legacy gsd-tools.cjs discovery, legacy core.cjs discovery, and compatibility-only missing-asset diagnostics must live behind one seam instead of leaking across SDK Modules. This keeps callers thin, raises leverage for standalone-SDK testing, and improves locality by making package-readiness bugs land in one place. First tracer-bullet slice: add one compatibility Adapter Module at this seam and migrate current legacy asset callers onto it before broader native replacement work.

Runtime-global skills directory resolution is explicitly out of scope for this seam. That policy varies by runtime (claude, codex, cline, etc.) rather than by legacy package/install layout, so it now lives in a separate Runtime-Global Skills Policy Module consumed by agent-skills and skill-manifest.

---

Adr/0008 Installer Migration Module

Installer Migration Module owns install-time upgrade safety

- Status: Accepted
- Date: 2026-05-11

We decided to introduce an explicit Installer Migration Module for install-time file moves, removals, config rewrites, and user-data preservation. Installer upgrade behavior must be represented as versioned migration records that produce a dry-run plan before applying changes.

Decision

- Add an Installer Migration Module as the owner for upgrade migrations.
- Keep the existing installer materialization pipeline, but move cleanup and feature-retirement behavior into migration records over time.
- Track applied migrations in an install-state file next to the existing file manifest.
- Treat the existing file manifest as the managed-file ownership baseline.
- Treat user-owned artifacts as a single shared policy consumed by preservation and manifest writing.
- Require migrations to plan first, then apply through a shared executor that owns backup, rollback, and reporting.
- Default ambiguous or unknown files to preserve; destructive changes need managed-file evidence or explicit user choice.
- Support dry-run output using the same planner used by apply mode.
- Include a first-time baseline scanner for legacy installs that need classification before destructive migrations can be trusted.
- Treat the runtime configuration contract registry in
docs/installer-migrations.md as the source of truth for migrations that touch host runtime config.

Runtime Contract Decision

Every migration that rewrites runtime config, moves an invocation surface, or
retires a generated runtime artifact must cite the registry row in
docs/installer-migrations.md. If the migration changes where a runtime loads
commands, skills, agents, hooks, or rules, the PR must update both the registry
and
docs/ARCHITECTURE.md.

The registry records what GSD installs, where it installs it, when migrations
may touch it, who owns the surrounding config, and why the shape matches the
host runtime. When upstream docs do not publish an API or docs version, the
checked date is the drift sentinel. A later upstream docs or CLI release that
changes command, skill, agent, hook, or rule loading requires a new registry
snapshot before migration work proceeds.

Consequences

- Retiring features requires an explicit migration instead of a hidden cleanup block.
- The installer can remove stale GSD-owned artifacts without guessing about user files.
- Locally modified managed files get a consistent backup path before removal or replacement.
- Future rollback work can become runtime-neutral instead of Codex-specific.
- Migration authors must define ownership evidence, conflict behavior, runtime scope, and non-interactive behavior.
- Migration authors must also define which runtime contract they are relying on and whether the upstream documentation is versioned.
- The installer gains another state file, so tests must cover missing, legacy, and checksum-mismatch state.

Scope

The first implementation should extract manifest/user-owned helpers, add install-state persistence, add migration planning, and port one existing orphan cleanup into the migration runner. It should not rewrite every runtime installer branch in the first pass.

The detailed module contract lives in docs/installer-migrations.md.

Amendment (2026-05-11): Authoring guard enforcement

The Installer Migration Authoring Guard Module validates migration records and
planned actions before planning can proceed. Records must declare title,
description, introduction version, explicit install scopes, destructive status,
and a plan function. Destructive or config-rewrite actions must include
ownership evidence, and runtime config rewrites must cite the runtime
configuration contract registry.

Amendment (2026-08-07): Non-recursive empty-directory removal primitive

Migration 003's docblock records, as an intentional consequence of this ADR,
that the framework has no recursive directory-removal primitive: every
action targets a single file by
relPath, and an emptied directory shell is
left behind for the user (or a future migration) to clean up. #3023 exposed a
case where that is not enough: pi reserves the directory NAME
hooks/ for its
own deprecated-extension check, which warns on the path's mere existence
regardless of contents. Leaving an emptied
hooks/ shell behind would keep
the warning firing forever, defeating the retirement.

We added remove-empty-dir, a new action type, rather than relaxing the
"never remove directories" posture generally:

- It calls fs.rmdirSync only — never fs.rmSync, { recursive: true }, or
{ force: true }. A non-empty directory fails the underlying syscall and is
treated as a successful no-op (
skipped-not-empty), not swept.
- Emptiness is re-checked immediately before the call, not trusted from
planning time, so a file that survived an earlier action in the same run (a
failed removal, or a legitimately preserved unknown file) keeps the
directory alive.
- The target must not be a symlink, and its realpath must resolve strictly
inside — and never equal — the config directory's own realpath.
- Any unexpected failure degrades to
left-in-place, matching every sibling
action type's non-throwing posture.

Recursive directory removal remains deliberately absent. This primitive
only retires a directory NODE once every file inside it has already been
individually classified and actioned by other, ordinary file-level actions in
the same migration — it is not a shortcut for sweeping a subtree in one step,
and a migration author who wants that should still enumerate files
individually per migration 003's and 009's pattern.

See docs/installer-migrations.md#action-types (remove-empty-dir) and
src/installer-migrations/009-pi-retire-reserved-hooks-dir.cts.

---

Adr/0009 Shell Command Projection Module

Shell Command Projection Module owns runtime-aware OS command rendering

- Status: Accepted
- Supersedes: ADR-0010 (File Operation Engine Module) — absorbed into this seam's Phases 3–4 (
#3467#3468), 2026-05-13
- Date: 2026-05-12

We propose introducing a Shell Command Projection Module that owns projection from typed command intent to concrete shell/runtime-specific command text. GSD currently hand-builds hook commands, PATH repair commands, shim scripts, and other serialized OS-facing command strings across installer call sites. That drift has repeatedly produced cross-shell regressions (#2376, #2979, #3002, #3011, #3181, #3393, #3413). The proposed seam concentrates quoting, path-style, and runtime-wrapper policy in one module while keeping real subprocess execution on array-arg/non-shell paths.

Decision

- Add a Shell Command Projection Module under gsd-core/bin/lib/ as the single owner for runtime-aware command-text rendering.
- Feed the module typed inputs (
platform, shell, runtime, executable token, args, path policy) instead of prebuilt shell strings.
- Keep callers as thin Adapters that request projected text for:
- managed hook commands in
settings.json
- managed hook commands in runtime config TOML/JSON surfaces
- user-facing PATH repair / setup instructions
- generated shim / wrapper script text written to disk
- Keep internal subprocess execution (
spawnSync, execFileSync, SDK query dispatch) outside this seam. The module does not become a generic command runner.
- Make runtime-specific wrappers explicit policy at the seam (for example, emit PowerShell call-operator prefixes only for shells/runtimes that require them).
- Make path-style projection explicit policy at the seam (
native Windows, POSIX slash, $HOME-relative, project-dir-relative, etc.).
- Prefer typed IR outputs that tests can assert against directly, then render text at the final Adapter.

Initial Scope

First migration slice should cover installer/runtime surfaces already proving this bug class:

1. managed JS and .sh hook command construction
2. managed hook rewrite / normalization on reinstall
3. Codex hook block command rendering
4. PATH diagnostic action-line rendering
5. Windows shim / wrapper script text builders
6. local-install hook command rendering (
$CLAUDE_PROJECT_DIR vs cwd-relative runtime paths)

It should not in the first pass expand into workflow markdown

text
/ Detailed source-code truncated for AI context efficiency. /
js
projectShellCommand({
platform: 'linux' | 'darwin' | 'win32',
shell: 'bash' | 'zsh' | 'cmd' | 'pwsh',
runtime: 'claude' | 'gemini' | 'codex' | 'opencode' | 'copilot' | 'antigravity' | 'generic',
executable: { kind: 'node' | 'bash' | 'pwsh' | 'literal', token: '...' },
args: ['...'],
pathStyle: 'native' | 'posix' | 'home-relative' | 'project-relative',
})
text
For user-facing multi-line guidance, the seam should return typed action IR first, then let the installer print it:
js
projectShellActions({ intent: 'prepend-path', platform, targetDir, runtime })
text
For generated shim/wrapper files, the seam should own script text rendering too:
js
projectShellScript({ shell: 'cmd' | 'pwsh' | 'sh', executable, argsTemplate })
text

Consequences

- Quoting, slash-direction, wrapper-prefix, and variable-expansion policy become local to one module.
- Installer/runtime call sites become thinner and stop inventing sibling string builders.
- Windows runtime-specific regressions become easier to classify as seam bugs instead of one-off installer bugs.
- Tests can assert against typed projection IR instead of source-grepping ad-hoc string concatenation sites.
- The first implementation will move a broad installer surface, so scope discipline matters: start with installer/runtime projection only, not every shell string in the repo.
- If accepted, CONTEXT.md should gain a canonical Shell Command Projection Module entry and future architecture reviews should treat out-of-seam command rendering as drift.

Open questions

- Whether hooks.shell_preference from #3082 should become an input policy consumed by this module or remain a higher-level runtime config concern.
- Whether Windows Git Bash should be modeled as explicit shell: 'bash' + platform: 'win32' or as a distinct shell target.
- Whether existing shim/script builders should migrate in the first pass or follow immediately after the hook/diagnostic path is stable.
- Whether the seam should live entirely in installer land or later become shared with other runtime-output surfaces outside bin/install.js.

References

- Feature issue: #3439
- Related bug history: #2376, #2979, #3002, #3011, #3017, #3020, #3082, #3181, #3393, #3413
- See 0005-sdk-architecture-seam-map.md
- See 0008-installer-migration-module.md

Update — 2026-05-13 (Phases 1–4 expansion, #3465#3468)

The seam grew beyond the original "rendering only" scope. The "does not become a generic command runner" and "does not replace safe internal subprocess APIs" constraints (Decision §17, Initial Scope §33) were intentionally superseded.

Scope now owned by shell-command-projection.cjs:

- runtime-aware command-text rendering (original ADR scope)
- subprocess dispatch — execGit, execNpm, execTool, probeTty (Phase 2, #3466)
- platform file I/O — platformWriteSync, platformReadSync, platformEnsureDir, normalizeContent (Phase 3, #3467)
- legacy wrappers atomicWriteFileSync / safeReadFile / normalizeMd removed from core.cjs (Phase 4, #3468)

Result-shape invariant: all exec* return { exitCode, stdout, stderr } and never throw on non-zero exit. Platform-conditional logic (shell: process.platform === 'win32', probeTty Windows null return, .md-aware normalization) lives only at the seam.

Open question resolutions:

- Q4 (installer-only vs shared seam): resolved — shared. The seam lives in gsd-core/bin/lib/, consumed by installer, planning workflow, and every fs/subprocess call site across the tool.
- Q1, Q2, Q3 (hooks.shell_preference, Windows Git Bash modeling, shim/script builder migration timing): unresolved, carried forward as projection-design concerns independent of the I/O expansion.

See CONTEXT.md "Shell Command Projection Module" entry for the canonical current-state description.

---

Adr/0010 File Operation Engine Module

File Operation Engine Module owns safe runtime/config file mutations

- Status: Superseded by ADR-0009 (Shell Command Projection Module expansion, Phases 3–4, #3467#3468)
- Date: 2026-05-12
- Superseded: 2026-05-13

Supersession note. Rather than build a separate File Operation Engine, the file-mutation safety policy this ADR proposed was absorbed into the Shell Command Projection Module (ADR-0009). Phase 3 (#3467) added platformWriteSync / platformReadSync / platformEnsureDir / normalizeContent to that seam, owning atomic write (tmp+rename), .md normalization, and directory creation as a single platform-conditional surface. Phase 4 (#3468) removed the duplicated atomicWriteFileSync / safeReadFile / normalizeMd wrappers from core.cjs. The applyFileMutationPlan / typed plan IR design proposed below was not built — the simpler per-call seam proved sufficient for the actual drift sites. Lock-file lifecycle (Track B item 3) remains owned by withPlanningLock in planning-workspace.cjs because its { flag: 'wx' } exclusive-create semantics differ from atomic-write rename semantics.

---

We propose introducing a File Operation Engine Module that owns policy for managed file reads, writes, deletes, locks, backups, and rollbacks across installer, migration, and planning surfaces. Today, file mutation behavior is duplicated across bin/install.js, gsd-core/bin/lib/installer-migrations.cjs, and multiple planning modules, with drift in atomic-write guarantees, path safety checks, and ownership classification.

This ADR also captures where Shell Command Projection Module policy should be consumed or expanded for hook-command-specific file mutations, so shell command drift and file mutation drift do not evolve as separate bug classes.

Decision

- Add a File Operation Engine Module under gsd-core/bin/lib/ as the single seam for file mutation safety policy.
- Keep command-text projection in the Shell Command Projection Module (ADR-0009), but route projection-adjacent hook file mutations through shared managed-hook ownership policy.
- Move file operation adapters to the new seam in two tracks:
- Track A (projection-adjacent): runtime config hook-command detection/rewrite/delete paths consume shared managed-hook policy from the projection seam.
- Track B (solution-wide): shared file operation engine owns atomic write, path containment, lock behavior, rollback bookkeeping, and best-effort cleanup policy.
- Keep internal subprocess execution out of this seam (same boundary as ADR-0009): this is a file operation seam, not a command runner.

Initial Scope

1. Unify managed-hook ownership classification used by install/uninstall/migration hook config rewrites.
2. Unify atomic write behavior currently duplicated in installer/core/migration paths.
3. Unify lock-file lifecycle policy used by planning workspace and installer migration journal flows.
4. Expose typed file mutation plan IR for tests (rewrite-json, rewrite-text with format (toml/markdown/plain), delete-file, backup-file, restore-file, ensure-dir).

Migration Inventory

Projection-adjacent file mutation drift (Track A)

- bin/install.js
- hook cleanup command detection (isGsdHookCommand)
- stale Codex hook strip basenames (STALE_HOOK_BASENAMES)
- settings/config hook entry prune/rewrite paths
- gsd-core/bin/lib/installer-migrations/002-codex-legacy-hooks-json.cjs
- isManagedCodexHookCommand regex/path detection duplicated from installer-owned hook policy
- gsd-core/bin/lib/shell-command-projection.cjs
- isManagedHookBasename already owns part of this policy and should become the canonical owner

Solution-wide file operation drift (Track B)

- bin/install.js
- local atomicWriteFileSync and temp cleanup registry
- large inlined read/modify/write + backup/rollback logic for runtime config and hooks
- gsd-core/bin/lib/core.cjs
- atomicWriteFileSync helper diverges in fallback behavior from installer/migration variants
- gsd-core/bin/lib/installer-migrations.cjs
- separate writeFileAtomicSync, rollback journaling, lock handling, and containment checks
- gsd-core/bin/lib/planning-workspace.cjs and gsd-core/bin/lib/state.cjs
- duplicated lock-file create/release/remove patterns and best-effort cleanup semantics
- gsd-core/bin/lib/roadmap.cjs, phase.cjs, milestone.cjs, frontmatter.cjs, drift.cjs
- direct read/modify/write flows with inconsistent atomicity and normalization policy application

Interface sketch

The File Operation Engine Module should expose typed mutation planning and execution helpers:

js
planFileMutations({
rootDir,
operations: [
{ type: 'rewrite-json', relPath, mutate },
{ type: 'rewrite-text', relPath, mutate, format: 'toml' | 'markdown' | 'plain' },
{ type: 'delete-file', relPath },
{ type: 'ensure-dir', relPath },
],
ownership: { mode: 'managed-only' | 'allow-user', classifier },
})
text
js
applyFileMutationPlan({
plan,
atomic: true,
rollback: true,
lock: { scope: 'config' | 'planning', id: '...' },
})
text
For projection-adjacent paths, adapters should consume projection policy:
js
isManagedHookCommand(commandText, { surface, configDir })
text

Consequences

- File mutation safety policy becomes local to one module, reducing drift across installer/migration/planning paths.
- Shell command projection and hook ownership classification stay aligned at one seam family.
- Tests can assert typed mutation IR and reason codes instead of source-grep and duplicated predicate mirrors.
- Initial migration is broad; sequencing should prioritize projection-adjacent hook config paths first, then converge atomic-write and lock semantics.

Open questions

- Whether lock semantics should be one shared policy for installer + planning, or two adapters over one lock primitive.
- Whether SDK query write paths should consume the same engine in the first pass or follow after CJS convergence.
- Whether file mutation telemetry (per-op reason codes and rollback events) should be required for all engine adapters.

References

- ADR-0008: 0008-installer-migration-module.md
- ADR-0009: 0009-shell-command-projection-module.md
- Related bug history: #1755, #2866, #2979, #3002, #3017, #3439

---

Adr/0010 Skill Surface Budget Module

Skill Surface Budget Module owns install-time skill listing curation

- Status: Superseded by ADR-0011 (Skill Surface Budget Module — install-time profile staging and runtime surface control); originally Proposed (2026-05-12)
- Date: 2026-05-12

Provenance of this status (2026-07-16). This file said Proposed while the hand-maintained index in README.md recorded it as "Skill Surface Budget Module — earlier draft superseded by ADR-0011", status "Superseded by 0011". The index was right and the file was stale. When the index became a generated artifact (derived from these files), that assertion would have been silently dropped and this superseded draft would have reappeared as a live Proposed decision — so it is recorded here, at its source, instead. This is the one status corrected from the old index rather than left for ratification, because leaving it would have lost a decision the maintainer had already made.

We propose extending the existing install profile seam (gsd-core/bin/lib/install-profiles.cjs) into a Skill Surface Budget Module that owns which subset of GSD's 66 skills is written to the runtime config dirs, and that owns the per-skill requires: dependency manifest used to keep that subset closed under cross-skill references. GSD currently ships a binary --minimal / full toggle; runtimes that enumerate skills (Claude Code, OpenCode, etc.) cap the <available_skills> system-prompt block at skillListingBudgetFraction of the context window (default 1% = ~2k tokens at 200k), and GSD alone consumes ~60% of that cap (#3408). Further description shrinkage is unavailable — scripts/lint-descriptions.cjs already enforces a hard 100-char ceiling and the mean is 72.5 chars. The remaining lever is surfacing fewer skills, which requires a typed profile model plus a dependency manifest, not more ad-hoc allowlists.

Decision

- Add a Skill Surface Budget Module by extending gsd-core/bin/lib/install-profiles.cjs as the single owner for which commands/gsd/.md and agents/gsd-.md files are staged into the per-runtime copy pipeline.
- Replace the single MINIMAL_SKILL_ALLOWLIST constant with a typed PROFILES map keyed by profile name. Each profile is a base set of skills; the module computes the transitive closure over each skill's declared requires: set before staging.
- Add a requires: frontmatter field to every skill whose body references another GSD skill. The dependency graph in the research memo (docs/research/2026-05-12-skill-surface-budget.md §3.1) is the migration spec for this pass.
- Extend bin/install.js argument parsing to accept --profile=<name> and --profile=<name1>,<name2> (composable). Preserve --minimal / --core-only as aliases for --profile=core. Default install (no flag) remains full for back-compat.
- Persist the active profile to ~/.claude/skills/.gsd-profile (and runtime-equivalent locations) so gsd update re-applies the same profile instead of expanding silently to full.
- Add scripts/lint-skill-deps.cjs and wire it into the existing npm run lint:descriptions pretest gate. The lint fails if:
- a skill body references another skill not in its requires: set, or
- any profile would ship a skill whose requires: closure is not satisfied.
- Keep the interactive install picker behind the same AskUserQuestion-style flow already used for runtime/location selection. Non-interactive installs (CI, npx --yes) fall back to --profile=full unless overridden.

Initial Scope

First migration slice should land the profile model and one new tier above core:

1. Profile map (typed): core (current minimal, 7 skills including phase), standard (~13 skills covering the audit + main-loop + utility floor), full (current default, 66 skills).
2. requires: frontmatter added to the hot nodes of the dependency graph first: phase (38 callers), review (11), config (7), progress (5), update (5). These are the skills whose absence silently breaks others, so they need explicit required_by audit before any profile narrows them out.
3. Confirm-and-lock the latent bug fix surfaced by the audit: phase is referenced by 38 skills and now belongs in MINIMAL_SKILL_ALLOWLIST / PROFILES.core. Keep explicit coverage in minimal/core tests so this cannot regress.
4. CLI surface: --profile=, comma-composed profiles, --profile=help listing each profile's contents and token cost.
5. Profile marker persistence + gsd update re-application.

It should not in the first pass:

- Build a runtime enable/disable surface (/gsd:surface). Track as a follow-up ADR (see "Open questions").
- Split GSD into multiple npm packages. The packaging-level alternative was considered and rejected — see research memo §4 Option F.
- Consolidate further skills (e.g. collapsing *-phase into a dispatcher). Track separately as IA cleanup; orthogonal to surface curation.

Migration Inventory

gsd-core/bin/lib/install-profiles.cjs

- Replace MINIMAL_SKILL_ALLOWLIST Object.freeze constant with PROFILES Object.freeze map of profile-name → base skill set.
- Replace isMinimalMode(mode) with resolveProfile(mode) returning a typed {name, skills: Set, agents: Set} after transitive-closure computation.
- Replace shouldInstallSkill(name, mode) with shouldInstallSkill(name, resolvedProfile).
- Replace stageSkillsForMode(srcDir, mode) with stageSkillsForProfile(srcDir, resolvedProfile). Add a sibling stageAgentsForProfile since this module now owns agent staging too (current --minimal skips agents wholesale; tiered profiles need finer control).
- Keep the existing exit-cleanup machinery (STAGED_DIRS, ensureExitCleanup) unchanged — the bug surface it covers is the same.

bin/install.js

These call sites should migrate behind the Skill Surface Budget Module:

- --minimal / --core-only flag parsing — bin/install.js:123-124
- _effectiveInstallMode plumbing + isMinimalMode() checks — bin/install.js:7634-8465 (passes through to per-runtime copy fns)
- minimal-agent skip block — bin/install.js:8167-8207 (becomes "skip agents not in profile")
- runtime-specific copy entry points that consume stageSkillsForMode — 13 sites per the existing comment in install-profiles.cjs
- usage help block — bin/install.js:508 (add --profile= documentation)

Frontmatter changes

- Add requires: field to every skill in commands/gsd/*.md whose body references another GSD skill. Audit data lists the full set (docs/research/2026-05-12-skill-surface-budget.md §3.1). Estimate: 25-30 files touched in Phase 1.
- Field is optional. Absence = "no GSD-skill dependencies." lint-skill-deps.cjs enforces consistency, not presence.

New: scripts/lint-skill-deps.cjs

- Walks commands/gsd/*.md, parses requires:, walks the body for gsd:<name> or \b<stem>\b references to other skills (same matching rules documented in docs/research/2026-05-12-skill-surface-budget.md §3.1).
- Fails CI if requires: set ≠ actual references (modulo ignore-list for prose mentions that aren't actual dispatches).
- Walks PROFILES from install-profiles.cjs, fails if any profile's transitive closure references a skill not in the profile.
- Wires into npm run lint:descriptions (or as a sibling lint:skill-deps) and pretest.

Profile marker

- New ~/.claude/skills/.gsd-profile (and per-runtime equivalents under .codex/, .cursor/, etc. as enumerated in install.js) containing the active profile name.
- Installer Migration Module (ADR-0008) gains a one-shot migration: if marker absent and skills dir matches core exactly, write core; otherwise write full. Migrations are idempotent per existing module contract.

Tests expected to move with the seam

- tests/install-profiles-*.test.cjs (any existing) — extend to assert profile resolution, transitive closure, and --profile=core,standard composition.
- New tests/skill-surface-budget-*.test.cjs covering:
- profile closure: a profile that lists discuss-phase must transitively include phase if discuss-phase requires it
- lint failures: a skill body that references an un-required skill makes lint:skill-deps fail
- marker persistence: gsd install --profile=standard followed by gsd update preserves standard
- minimal back-compat: --minimal resolves to --profile=core and emits the same file set as today (modulo the phase-inclusion bug fix)

Interface sketch

The module should accept typed profile intent and return a typed resolved profile:

js
// install-profiles.cjs (extended)
resolveProfile({
modes: ['core' | 'standard' | 'full'],
skillsManifest: ManifestMap, // parsed requires: graph
})
// → { name: 'standard', skills: Set<string>, agents: Set<string> }
text
Profile composition: --profile=core,standard resolves to union(closure(core), closure(standard)). --profile=full is the identity profile (every skill).
js
stageSkillsForProfile(srcDir, resolvedProfile) // returns staged dir path
stageAgentsForProfile(srcAgentsDir, resolvedProfile) // new
text
Profile marker IO is typed too, not stringly:
js
readActiveProfile(runtimeConfigDir) // → 'core' | 'standard' | 'full' | null
writeActiveProfile(runtimeConfigDir, profileName)
text
Per-skill frontmatter contract:
yaml
---
name: gsd:plan-phase
description: ...
requires: [phase, discuss-phase] # GSD skills only; not Claude Code primitives
---
text
requires: lists GSD skills (file stems). It does not include Claude Code built-ins (Read, Bash, etc.) — those continue to live in allowed-tools: per existing convention.

Consequences

- The skill-set written by the installer becomes a typed first-class artifact, not a side effect of file copies + an allowlist constant. ADR-0008 (Installer Migration Module) gains a clean handle for safe profile migrations on upgrade.
- gsd update stops silently re-expanding a --minimal install to full — a current foot-gun documented inline in install-profiles.cjs (its module-level comment recommends gsd update without --minimal to "expand to the full surface"; that path remains available, but the default gsd update now respects the recorded profile).
- The requires: manifest creates a new authoring obligation (~30 files in Phase 1), enforced by CI. Skill authors who add a /gsd:phase reference in a new skill body have to update requires:. The lint script keeps drift low-cost.
- The phase-in-minimal latent gap (research memo §3.1) gets resolved as a side effect of adopting closure-based profile resolution — phase is auto-included whenever any minimal-loop skill requires: it.
- First-time install UX gains a profile picker. The default remains full for non-interactive (npx --yes) installs, so back-compat for CI scripts is preserved.
- The module becomes the canonical place to land future Anthropic platform features (lazy descriptions, per-plugin budgets, .disabled toggles — see Open Questions). It does not, in this ADR, use those features.
- If accepted, CONTEXT.md should gain a canonical Skill Surface Budget Module entry alongside the existing seam entries, and future architecture reviews should treat ad-hoc commands/gsd/ filtering outside this seam as drift.

Open questions

- Whether the Phase-2 runtime /gsd:surface command (research memo §4 Option B) should be its own ADR or an amendment to this one. Leaning separate ADR because it introduces persistent runtime state outside the install pipeline.
- Profile naming bikeshed. core / standard / full is the working proposal. Alternatives surveyed: minimal / recommended / everything, functional names (planning, audit, research). Settle in the implementation PR after a contributor poll.
- Whether the requires: field should also be consumed by /gsd:help to render a "skills you have installed and what depends on what" graph. Likely yes, but out of scope for this ADR.
- Whether to keep phase explicitly listed in core forever vs relying purely on closure semantics. Current recommendation: keep explicit listing because minimal mode has a back-compat allowlist path.
- Whether telemetry (opt-in) is worth proposing to inform where the standard profile line goes. Without it, the cut points are author-intuition. Track separately; not a blocker.
- Whether the Anthropic platform asks (research memo §6 — lazy descriptions, per-plugin budgets, dependency-aware listing, .disabled toggles) should be filed before or after this ADR ships. Recommendation: file as a feedback bundle when ADR is accepted, so we ship Phase 1 unilaterally and platform improvements compose on top.

References

- Feature issue: #3408
- Research input: docs/research/2026-05-12-skill-surface-budget.md
- Existing seam being extended: gsd-core/bin/lib/install-profiles.cjs
- Description budget enforcement: scripts/lint-descriptions.cjs
- Installer dispatch site: bin/install.js:123-124, :8167-8207
- See 0008-installer-migration-module.md (the migration that records the profile marker lives here)
- See 0005-sdk-architecture-seam-map.md (the seam map this module joins)

---

Adr/0011 Review Default Reviewers

review.default_reviewers config key scopes the no-flag /gsd-review fan-out

- Status: Accepted — ratified 2026-07-17 (originally Proposed 2026-05-13); see "Ratification" below
- Date: 2026-05-13

We propose adding a review.default_reviewers key to .planning/config.json that scopes the no-flag default of /gsd-review to a user-chosen subset of detected CLI reviewers. Today the no-flag branch of workflows/review.md (line 52) invokes all available CLIs, which for multi-CLI users plus local model servers (ollama, lm-studio, llama.cpp) means probing up to ~10 backends per review, paying timeout costs on servers that aren't running and burning tokens on reviewers the user doesn't want for routine work (#3079). The only workaround today is patching workflows/review.md in place; that patch is wiped on every /gsd-update and requires /gsd-update --reapply to restore, with no machine-readable record of intent. The proposed key sits inside the existing review. namespace (alongside review.models.<cli> and review._host), follows GSD's absent = enabled config philosophy, and is implementable as a one-line config read plus an intersection on the detected reviewer set.

Ratification (2026-07-17): Proposed → Accepted

Ratified by explicit maintainer directive; the Status field had sat stale at "Proposed" for roughly 65 days after the decision actually shipped.

Evidence the decision shipped:

- Landing commit 245d5f66a ("feat: add review.default_reviewers config for /gsd-review defaults (#3464)", 2026-05-13) added the schema, resolution logic, workflow wiring, docs, and three test files in one change.
- src/review-reviewer-selection.cts (329 lines) exports KNOWN_REVIEWER_SLUGS (line 51) and normalizeConfiguredDefaultReviewers (line 105), implementing the ADR's precedence order (explicit flags > --all > review.default_reviewers > all detected).
- src/config.cts:878 handles kp === 'review.default_reviewers' for config-get/config-set, running values through normalizeConfiguredDefaultReviewers and surfacing schema errors.
- gsd-core/workflows/review.md (no-flag branch, ~lines 55-70) intersects detected reviewers with review.default_reviewers exactly as specified, including unknown-slug warnings and undetected-slug info notes.
- docs/CONFIGURATION.md:219-225 documents the key, type, default, and precedence; docs/COMMANDS.md:1451-1461 documents usage with a gsd config-set example.
- Four test files are present and current: tests/review-default-reviewers-config.test.cjs, tests/review-default-reviewers-resolution.test.cjs, tests/review-default-reviewers-workflow.test.cjs, tests/review-reviewer-instances.test.cjs.
- .changeset/archived/daring-badgers-munch.md (type: Added, pr: 3464) is archived, confirming release tooling already processed it.

Governance state: the owning issue (#3079, referenced above) and its landing PR (#3464) both 404 against the current open-gsd/gsd-core tracker — their numbering belongs to a predecessor repo whose issue space predates this repo's 2026-05 range (which topped out near #540), consistent with known predecessor-repo numbering rather than a fabricated reference. No in-tracker close event is directly checkable; the shipped-code evidence above substitutes for it.

Known gaps at ratification: two of the ADR's own non-blocking open questions remain genuinely unresolved — Q-2 (--no-default flag) and Q-3 (review.profiles.* namespace) — exactly as the ADR itself scoped them as future/non-blocking, so this is expected rather than a regression.

Decision

- Add review.default_reviewers to the config.json schema as string[], validated against the existing CLI slug pattern ^[a-zA-Z0-9_-]+$ (the same pattern used for review.models.<cli> slugs).
- When the key is present, the no-flag branch of /gsd-review invokes only the reviewers listed in the key, intersected with the host's detect_clis result.
- When the key is absent, today's behavior is preserved: every detected reviewer runs. This matches the absent = enabled pattern documented in docs/CONFIGURATION.md.
- --all continues to mean "every detected reviewer" and ignores the config key.
- Individual reviewer flags (--gemini, --codex, --cursor, --claude, --opencode, …) continue to win over both config and --all.
- Resolution lives in the detect_clis step of workflows/review.md: detect first, then filter by review.default_reviewers only on the no-flag branch.
- Unknown slugs in the key emit a warning and are dropped; valid-but-undetected slugs emit an info-level note and are dropped; an all-undetected post-filter set emits an actionable error (see "Open questions" Q-1 for empty-array semantics).
- Slug comparison is lowercase-normalized on read. The schema pattern already accepts mixed case, so normalization is forgiving without making the pattern itself stricter.
- No new top-level config namespace, no new command, no new flag in v1.

Precedence (highest first):

1. Individual reviewer flags
2. --all
3. review.default_reviewers
4. No config, no flags → today's behavior (all detected)

Initial Scope

First slice should land the config plumbing and the no-flag branch behavior without expanding into adjacent reviewer-selection design:

1. Schema addition for review.default_reviewers in the config loader; validation as string[] with slug pattern; lowercase normalization; clear schema errors for non-array / non-string-element values.
2. Filter step inside workflows/review.md detect_clis no-flag branch:
- intersect detected ∩ default_reviewers
- emit a single-line "selection source" log identifying which path was taken (default config / --all / explicit flags / no config)
- warn on unknown slugs; info on valid-but-undetected slugs; error if the post-filter selection is empty
3. Docs update: extend docs/CONFIGURATION.md with a review.* subsection documenting the key, allowed values, defaults, and override precedence; add the key to the schema block at the top of that file; update workflows/review.md to reference the key in the no-flag branch.
4. Tests: config parsing (valid / empty / malformed); detect_clis intersection; integration coverage of no-flag honors config, --all overrides, individual flags override, unknown slug warns, only-undetected slugs errors.
5. Release notes entry calling out the new key with the two-line config example.

It should not in the first pass:

- Add --no-default or any new CLI flag (track in "Open questions" Q-2 — likely equivalent to --all).
- Add per-phase or per-file-type reviewer profiles (review.profiles.*). The namespace is left open for this as a future ADR (see "Open questions" Q-3).
- Add reviewer "groups" or aliases (review.groups.cheap = [...]). Same — namespace deliberately left open.
- Auto-suggest defaults from usage history. Different design philosophy (silent behavior drift); explicitly out of scope.
- Extend /gsd-config --integrations to set the key interactively. Track as a fast follow (see "Open questions" Q-4).
- Change --all semantics or the individual flag set.

Migration Inventory

workflows/review.md

- detect_clis step, no-flag branch (current line 52: "No flags → include all available") — replace with: "No flags → if review.default_reviewers is set, intersect detected with the listed slugs; otherwise include all detected."
- Verbose / debug output path — emit one line identifying the selection source.

Config loader

- Add review.default_reviewers: string[] to the JSON schema for .planning/config.json. Pattern per element: ^[a-zA-Z0-9_-]+$. Slug list de-dup on read; lowercase-normalize on read.
- Surface schema errors at config load (file path + line number where the parser supports it), matching the existing handling for other malformed review.* keys.

docs/CONFIGURATION.md

- Add review.default_reviewers to the Full Schema code block at the top of the file as an optional array key under a "review": { ... } object.
- Add a new Reviewer Selection subsection (or extend the existing review.* section if one exists) covering: purpose, type, default, precedence vs. --all and individual flags, edge-case behavior (unknown slugs, undetected slugs, empty result).
- Cross-reference workflows/review.md for how the key is consumed at review time.

Tests expected to move with the seam

- New tests/review-default-reviewers-config.test.cjs:
- valid ["gemini", "codex"] parses; lowercase normalization works
- [] schema decision per Q-1 (proposed: parse-time error)
- non-array → schema error
- non-string element → schema error
- element failing slug pattern → schema error
- New tests/review-default-reviewers-resolution.test.cjs:
- no flags + key set, both slugs detected → exactly those reviewers invoked
- no flags + key set, one slug undetected → info logged, remaining slug invoked
- no flags + key set, all slugs undetected → actionable error, nothing invoked
- no flags + key set, unknown slug present → warning logged, unknowns dropped, rest invoked
- --all + key set → every detected reviewer invoked, key ignored
- --gemini + key set to ["codex"] → only Gemini invoked
- no flags + key absent → every detected reviewer invoked (back-compat)
- Extend existing /gsd-review integration tests to cover the new selection-source log line.

Schema doc cross-reference

- Update the schema example at the top of docs/CONFIGURATION.md to include "review": { "default_reviewers": ["gemini", "codex"] } so the key is discoverable from the canonical schema view.

Example config

json
{
"review": {
"default_reviewers": ["gemini", "codex"]
}
}
text
With this set, /gsd-review (no flags) invokes only Gemini and Codex. /gsd-review --all invokes every detected reviewer. /gsd-review --cursor invokes only Cursor. Today's behavior is preserved by simply omitting the key.

Resolution pseudocode

text
detected = detect_clis() # unchanged
if any individual flag passed:
selected = flags_to_set(flags) ∩ detected
elif --all:
selected = detected
elif config.review.default_reviewers is set:
valid = filter(config.review.default_reviewers, is_known_slug)
# warn on each invalid slug
selected = valid ∩ detected
# info on each valid-but-undetected slug
if selected is empty:
error with actionable message # see Q-1
else:
selected = detected # today's behavior
log_selection_source(selected, source)
text

Consequences

- Multi-CLI users can stop patching workflows/review.md; the patch class that /gsd-update wipes goes away for this case.
- Teams can commit .planning/config.json and share a default reviewer set across machines and contributors, without forking the workflow file.
- /gsd-review wall-clock time drops on machines where detection probes idle local model servers — the timeout cost on stopped daemons is no longer paid on every routine review.
- Schema surface grows by one optional key; doc maintenance and the test matrix grow by a small fixed amount.
- The review. namespace stays internally consistent. Future review.profiles. or review.groups.* can coexist with review.default_reviewers without renaming.
- Cross-runtime impact is minimal: the change operates on the detection layer in detect_clis, not on any per-runtime adapter. The existing resolve_model_ids: "omit" path used by non-Claude runtimes (Codex, OpenCode, Gemini CLI, Kilo) is unaffected.
- One additional surface area for bug reports — primarily edge interactions between the key and --all / individual flags, which the test plan covers.
- If telemetry is ever opt-in for .planning/config.json shape, adoption of this key becomes a useful signal for whether to invest in the richer profiles design (see Q-3).

Open questions

- Q-1. Should review.default_reviewers: [] be a schema error, or should it fall back to "all detected"? Proposal: schema error. Rationale: users who want "all detected" can simply omit the key (more readable); [] looks like a typo or programmatic mistake; surfacing the ambiguity is more helpful than silently swallowing it. Blocking — affects schema validation and tests.
- Q-2. Is a new --no-default flag warranted, or is it equivalent to --all for this use case? Proposal: drop unless a concrete difference surfaces during implementation. Non-blocking.
- Q-3. Do we want to commit now to leaving review.profiles. open as a future namespace, or is that premature? Proposal: leave open; document the intent in docs/CONFIGURATION.md so the next contributor doesn't pick a conflicting key.* Non-blocking.
- Q-4. Should /gsd-config --integrations learn the new key in this pass, or as a fast follow once the schema + resolution land? Proposal: fast follow. Non-blocking; depends on contributor bandwidth.
- Q-5. Should the verbose-mode "selection source" line ship in the first pass, or only behind --verbose? Proposal: behind --verbose. Non-blocking.
- Q-6. Slug normalization: lowercase-on-read (proposed) vs. exact-match enforcement at the schema layer. Proposal: normalize. Non-blocking; document either way.

References

- Feature issue: #3079
- Configuration reference: docs/CONFIGURATION.mdreview.models.<cli>, review.*_host, and the absent = enabled pattern
- Workflow file owning the no-flag branch: workflows/review.md (line 52)
- Existing slug validation pattern: ^[a-zA-Z0-9_-]+$ (used for review.models.<cli> keys)
- Related PRD: 0011-review-default-reviewers-prd.md

---

Adr/0011 Review Default Reviewers Prd

PRD — review.default_reviewers config key for /gsd-review reviewer selection

- Status: Legacy — frozen historical record; not a pattern to follow (see the note below)
- Date: 2026-05-13
- Issue: #3079
- Related ADR: 0011-review-default-reviewers.md

Note (2026-07-16). This PRD's original note said "the repo does not yet have a docs/prd/ directory; if maintainers prefer one, this file can move there." That directory now exists, and docs/prd/README.md records this file's disposition: it "predates this directory and is preserved as immutable historical record. It is not a pattern to follow. New PRDs live here." It is therefore kept in place, and its status is Legacy — the decision is frozen for provenance, not superseded by a specific successor. New PRDs go in docs/prd/.

TL;DR

/gsd-review with no flags fans out to every detected CLI reviewer (Claude, Codex, Cursor, Gemini, OpenCode, plus local model servers such as ollama, lm-studio, llama.cpp). For users with many backends installed, this wastes wall-clock on timeouts and burns tokens on reviewers they don't want for routine work. Add a review.default_reviewers key under the existing review.* namespace in .planning/config.json that scopes the no-flag default to a user-chosen subset. Absent key preserves today's behavior. --all and individual flags continue to work unchanged. Follows GSD's absent = enabled convention.

Problem Statement

GSD's /gsd-review workflow treats "no flags" as "invoke every CLI we can detect" (workflows/review.md line 52). That default is fine at install time — it makes the feature discoverable — but it's the wrong default for any user who has accumulated multiple reviewer CLIs plus local model servers. Each review probes up to ~10 backends, including ones that are slow, expensive, redundant for the change at hand, or not actually running (timeout waits on ollama, lm-studio, llama.cpp when the daemon is off).

The only existing workaround is editing workflows/review.md in place. That patch gets clobbered on every /gsd-update, requiring /gsd-update --reapply to restore. There is no machine-readable record of the user's intent — every machine the user works on needs the same patch reapplied. The issue reporter (#3079) and presumably others are paying a "tax" on every review that is purely a default-selection problem.

This is a small change with broad reach: it lands in a hot-path workflow that power users run many times per day.

Goals

- Eliminate the recurring local-patch tax for multi-CLI users. A user who consistently wants only Gemini + Codex for routine reviews should be able to set that once and forget it.
- Cut median wall-clock time of a no-flag /gsd-review on multi-CLI machines (target: ≥40% reduction for users with ≥4 detected CLIs).
- Keep the change non-breaking. Absent config = today's behavior; nothing changes for the install-day experience.
- Match GSD's established config conventions (review.models., review._host, absent = enabled, namespacing under review.*) so users don't have to learn a new pattern.
- Stay one-line-shaped. The implementation should be a config read plus an intersection with the detected set — no new commands, no schema overhaul, no migration.

Non-Goals

- Per-phase or per-task reviewer routing (e.g., "use Codex on Rust phases, Gemini on docs"). Useful, but a separate, larger design — track as a future ADR.
- Reviewer scoring, weighting, or ensemble logic. This is about which reviewers run, not how their output is aggregated.
- Auto-detecting the "best" default reviewers based on usage history. Out of scope; we want explicit user intent, not silent behavior drift.
- Changing the --all semantics or the individual flag set (--gemini, --codex, --cursor, …). They keep their current meaning.
- A new top-level config namespace. Reviewers belong under the existing review.* namespace.
- GUI / TUI editing of the key in v1. Editing the JSON file directly (or via /gsd-settings / /gsd-config --integrations if maintainers choose to support it later) is sufficient.

Users & Use Cases

Primary persona: "Multi-CLI power user"

A developer who has installed multiple coding CLIs (e.g., Claude Code, Codex, Gemini CLI, Cursor, OpenCode, Kilo) plus one or more local inference servers. They run /gsd-review frequently — sometimes dozens of times per day during a sprint — and have a stable mental model of which 1–3 reviewers actually add signal for their day-to-day work.

Secondary persona: "Single-CLI user with a sometimes-on local server"

Has Claude + ollama installed. Wants reviews from Claude every time, and from ollama only when explicitly asked. Today, every /gsd-review pays the ollama timeout cost when ollama isn't running.

Secondary persona: "Cost-sensitive team lead"

Routine reviews should hit cheap/local reviewers; pre-merge reviews should hit the expensive ones via --all or explicit flags. Wants a config-level expression of "the cheap subset is my default."

Use cases this enables

- "I only want Gemini + Codex for routine reviews." → set review.default_reviewers: ["gemini", "codex"].
- "I want Claude only by default, and I'll opt into the others with flags." → set ["claude"].
- "I want today's behavior." → leave the key absent.
- "I want today's behavior just this once" on a configured project → /gsd-review --all.

User Stories

Grouped by persona, ordered roughly by frequency.

Multi-CLI power user

- As a multi-CLI user, I want to declare which reviewers run by default so that /gsd-review doesn't probe backends I don't use.
- As a multi-CLI user, I want my preference to survive /gsd-update so I don't have to keep re-patching workflows/review.md.
- As a multi-CLI user, I want --all to still work so I can opt into a full review pre-merge without un-setting my config.
- As a multi-CLI user, I want individual flags (--gemini, --cursor, …) to keep working regardless of my default so ad-hoc runs aren't constrained by the default.

Single-CLI user with sometimes-on local server

- As a user with intermittent local servers, I want my default to exclude them so a stopped daemon doesn't cost me a 30-second timeout on every review.

Cost-sensitive team lead

- As a team lead, I want to commit .planning/config.json to the repo so everyone on the team gets the same review defaults.

New user

- As a new user, I want today's behavior preserved so the feature still "just works" out of the box without config.

Edge cases

- As a user with a typo in my default list, I want a clear warning that names an unknown reviewer rather than a silent skip.
- As a user whose configured reviewer is no longer installed, I want a clear note that it was dropped from this run.
- As a user who lists only reviewers that aren't installed, I want a clear error or a documented fallback (see Open Questions).

Requirements

Must-Have (P0)

- P0-1. New config key. review.default_reviewers is string[]. Each element validates against the existing slug pattern ^[a-zA-Z0-9_-]+$. Schema parser accepts the key; rejects non-array or non-string-element values with a clear error. Empty array [] behavior is decided per Q-1.
- P0-2. No-flag honors the key. Given the key is ["gemini", "codex"] and both are detected, running /gsd-review invokes only Gemini and Codex.
- P0-3. Absent key preserves current behavior. Given the key is unset, running /gsd-review runs every detected reviewer, identical to today.
- P0-4. --all overrides the config. Given the key is ["gemini"], running /gsd-review --all invokes every detected reviewer. Verbose mode shows which reviewers came from --all vs. the default.
- P0-5. Individual flags override the config. Given the key is ["gemini"], running /gsd-review --cursor invokes only Cursor. Running /gsd-review --gemini --codex invokes exactly those two regardless of the default.
- P0-6. Graceful slug handling. Unknown slug → start-of-run warning naming the offending slug; run continues with valid entries. Known slug but undetected → info-level note; run continues. Zero post-filter selections → error per Q-1.
- P0-7. Docs updated. docs/CONFIGURATION.md gets a review.* subsection (or an extension of an existing one) covering the new key. workflows/review.md references the key in the no-flag branch. Schema example at the top of docs/CONFIGURATION.md includes the key.
- P0-8. Tests. Unit and integration coverage per the test list in the ADR's Tests expected to move with the seam section.

Nice-to-Have (P1)

- P1-1. /gsd-config --integrations extends to set review.default_reviewers interactively, aligning with the existing interactive config flow for reviewers.
- P1-2. --no-default flag that runs the full detected set without --all semantics — slightly different intent expression. Drop if equivalent to --all (Q-2).
- P1-3. Verbose-mode "selection source" line in /gsd-review output: Running reviewers (default): gemini, codex (set in .planning/config.json).
- P1-4. Per-command override env var (GSD_REVIEW_DEFAULT=...) for CI scenarios where mutating config.json is undesirable.

Future Considerations (P2)

- P2-1. Per-phase or per-file-type reviewer profiles (review.profiles.frontend: ["claude", "cursor"]). The shape of default_reviewers is deliberately chosen not to foreclose this — a future review.profiles.* map can coexist.
- P2-2. Reviewer "groups" or aliases (review.groups.cheap = ["ollama", "gemini-flash"]). Same — leave room under review.*.
- P2-3. Auto-suggestion that detects repeated flag patterns and offers to persist them. Natural follow-up but explicitly out of scope here.

Behavior Specification

Precedence (highest first)

1. Individual reviewer flags (--gemini, --codex, --cursor, …) — always win.
2. --all — full detected set, ignores config.
3. review.default_reviewers in config — subset, intersected with detected set.
4. No config, no flags — full detected set (today's behavior).

This matches the principle of least surprise: explicit user input (flags) always wins over persisted preference (config), and persisted preference only fills the gap when the user hasn't said anything else.

Resolution pseudocode

text
detected = detect_clis() # unchanged
if any individual flag passed:
selected = flags_to_set(flags) ∩ detected
elif --all:
selected = detected
elif config.review.default_reviewers is set:
valid = filter(config.review.default_reviewers, is_known_slug)
# warn on each invalid slug
selected = valid ∩ detected
# info on each valid-but-undetected slug
if selected is empty:
error with actionable message # see Q-1
else:
selected = detected # today's behavior
text

Validation

- Slug pattern: ^[a-zA-Z0-9_-]+$ (already in use for review.models.<cli>).
- Type: JSON array of strings; anything else → schema error at config load.
- Empty array: see Q-1 (proposed: schema error).
- Slug case normalization: lowercase-on-read.
- Duplicates: de-dup silently.

Logging

- One line per /gsd-review start identifying the source of selection (default config / --all / explicit flags / no config). Surfaced under --verbose per Q-5.
- Slug warnings/infos as described in P0-6.

Success Metrics

Leading indicators (1–4 weeks post-release)

- Adoption proxy. Count of .planning/config.json files containing review.default_reviewers (only countable if/when GSD ever ships opt-in telemetry; otherwise qualitative via Discussions).
- Issue echo. Closure of #3079 and zero new issues reporting the same wipe-on-update problem within 60 days.
- Patch-removal proxy. Maintainer observes no further PRs or Discussion threads about patching workflows/review.md defaults within 60 days.

Lagging indicators (1–3 months post-release)

- Median wall-clock per /gsd-review on machines with ≥4 detected CLIs (self-reported or telemetry). Target: ≥40% reduction for users who opt in.
- User-perceived signal-to-noise on review output (qualitative; gather via GitHub Discussions or a single follow-up question on the issue).

Measurement notes

GSD doesn't ship usage telemetry today. Most of these metrics rely on qualitative signal: issue activity, Discussion threads, and a follow-up on #3079. That's appropriate for a config addition of this size — we don't need a metrics pipeline to validate it.

Edge Cases & Error Handling

- Config key missing → today's behavior (all detected).
- Config key is [] → schema error per proposed Q-1 resolution. Message: review.default_reviewers is empty; remove the key to use the default-all behavior or list at least one reviewer.
- Config key contains an unknown slug → warn, drop the unknown entry, continue with the rest.
- Config key contains a known slug not detected on this host → info, drop, continue.
- Config key contains only undetected slugs → error with actionable message: All configured default reviewers are missing on this host: [...]. Install at least one, or pass --all / specific flags.
- Config key is malformed (e.g., string instead of array) → schema error at config load, with file path and line number where the parser supports it.
- Slug case sensitivity → lowercase-normalize on read; document this.
- Duplicates in the array → de-dup silently.
- User passes --all and individual flags together → existing behavior preserved; this change does not alter that interaction. Confirm in tests.

Open Questions

- Q-1. Empty-array semantics. Should review.default_reviewers: [] be a schema error, or should it fall back to "all detected"? Proposal: schema error. Blocking. Affects schema validation and tests.
- Q-2. --no-default flag. Is this meaningfully different from --all? Proposal: drop unless implementation surfaces a concrete difference.
- Q-3. /gsd-config --integrations integration. Land in this pass or as a fast follow? Proposal: fast follow; depends on contributor bandwidth.
- Q-4. Slug case handling. Lowercase-on-read (proposed) or exact-match enforcement at the schema layer?
- Q-5. Verbose-mode "selection source" line. Always-on or only under --verbose? Proposal: --verbose only.
- Q-6. Cross-runtime sanity. Any cross-runtime concerns for Codex, OpenCode, Gemini CLI, or Kilo given the existing resolve_model_ids: "omit" pattern? Proposal: none expected — the change operates on detection, not runtime; add at least one non-Claude integration test to confirm.

Rollout Plan

This is a small, additive, non-breaking change. No migration is required.

1. Implementation (one PR) — schema addition, resolution logic, unit + integration tests per P0-8, docs update per P0-7.
2. Pre-release sanity — dogfood on a multi-CLI setup; confirm --all and individual flags still behave.
3. Release in the next minor version (no semver-major bump needed — additive).
4. Changelog & announcement — call out in release notes; link to #3079; show the two-line config example.
5. Monitor #3079 and any new issues mentioning "default reviewers" or "review.md patch" for 60 days.
6. Optional fast follow — P1-1 (/gsd-config --integrations integration) if there's contributor bandwidth.

Timeline Considerations

- No hard deadlines. Quality-of-life fix, not contractual or compliance-driven.
- No dependencies on other in-flight work.
- Size: estimated ≤1 day of engineering for implementation + tests + docs.

Out-of-Scope (Restated)

- No new commands.
- No new top-level config namespaces.
- No changes to --all or individual flag semantics.
- No reviewer-output aggregation changes.
- No per-phase reviewer profiles in v1 — the namespace is left open.
- No GUI/TUI editing of the key in v1.

Appendix A: Example config

json
{
"review": {
"default_reviewers": ["gemini", "codex"]
}
}
text
With this config, /gsd-review invokes only Gemini and Codex. /gsd-review --all invokes every detected reviewer. /gsd-review --cursor invokes only Cursor.

Appendix B: Glossary

- Reviewer / CLI / backend. Any code-review-capable CLI or model server GSD can invoke (Claude, Codex, Cursor, Gemini, OpenCode, Kilo, ollama, lm-studio, llama.cpp, …).
- Detected set. The list of reviewers detect_clis finds on the current host at review time.
- Slug. The lowercase short name of a reviewer used in flags and config (e.g., gemini, codex).
- "Absent = enabled" pattern. GSD's convention that missing config keys default to a sensible enabled state. Here, missing review.default_reviewers means "all detected."

References

- Feature issue: #3079
- Configuration reference: docs/CONFIGURATION.mdreview.models.<cli>, review.*_host, and the absent = enabled pattern
- Workflow file owning the no-flag branch: workflows/review.md (line 52)
- Companion ADR: 0011-review-default-reviewers.md

---

Adr/0011 Skill Surface Budget Module

Skill Surface Budget Module owns install-time profile staging and runtime surface control

- Status: Accepted
- Date: 2026-05-12
- Decision date: 2026-05-12
- Supersedes: ADR-0010 (Skill Surface Budget Module — earlier draft, install-time skill listing curation)
- Subsumed by: ADR-857 (Capability system) — generalizes this module; this seam remains live at src/surface.cts:348 (applySurface)
- Implementation: feat/3408-skills-description-dropped-due-to-size, PR <TBD>

Every installed gsd-* skill costs eager system-prompt tokens: runtimes (Claude Code, opencode, and others) enumerate all skill descriptions in <available_skills> on every turn. With 66 skills and 33 agents, GSD alone consumes roughly 60% of the default 1%-of-context skill-listing budget, causing descriptions to drop when users stack multiple plugins (#3408).

The root problem is an absence of a profile/surface seam: the installer wrote every skill unconditionally, and no runtime-side control existed for enabling or disabling a cohesive group of skills without a full reinstall.

Decision

- Add a Skill Surface Budget Module under gsd-core/bin/lib/install-profiles.cjs as the single owner for which skills and agents are written to runtime config directories.
- Define three named profiles: core (six skills covering the main loop), standard (core + phase management and workspace skills), and full (all skills — the previous default).
- Compute each profile's effective skill set as the transitive closure over the requires: dependency graph extracted from skill frontmatter, so partial installs never break cross-skill dependencies.
- Persist the chosen profile in a .gsd-profile marker file in each runtime config directory; gsd update reads the marker to honor the profile on re-install.
- When multiple runtimes are configured, resolve disagreement to the most-restrictive profile (smallest effective skill set).
- Allow profile composition: --profile=core,audit resolves to union(closure(core), closure(audit)).
- Preserve back-compat aliases: --minimal and --core-only map to --profile=core; MINIMAL_SKILL_ALLOWLIST, isMinimalMode, shouldInstallSkill, and stageSkillsForMode remain exported for existing callers.
- Add a CI gate (scripts/lint-skill-deps.cjs, wired into pretest) that verifies every skill's requires: entries resolve against real skill stems — prevents the profile closure from silently over-installing or breaking.

Phase 2 — Runtime Surface Module

The Phase 2 decision, previously listed as an open question, is recorded here as an amendment to this ADR.

Decision

- Add a /gsd:surface slash command with the following sub-commands:
- list — show all clusters and their enabled/disabled status for the active runtime
- status — show the active profile, effective skill count, and any dropped-description warnings
- profile <name> — switch the active profile and re-stage skills/agents for the current runtime
- disable <cluster> — mark a cluster disabled; re-stage to remove its skills from the runtime config dir
- enable <cluster> — mark a cluster enabled; re-stage to add its skills back
- reset — clear surface state and re-apply the active profile from .gsd-profile
- Implement the runtime surface engine in gsd-core/bin/lib/surface.cjs, consuming stageSkillsForProfile and stageAgentsForProfile from the Phase 1 module without duplicating staging logic.
- Persist per-runtime surface state in <runtimeConfigDir>/.gsd-surface.json, independent from .gsd-profile. The profile marker owns install-time identity; the surface JSON owns session-scope cluster toggles.
- Source cluster taxonomy from the research memo §3.2 (2026-05-12-skill-surface-budget.md). Define clusters in gsd-core/bin/lib/clusters.cjs — a separate module so the surface engine and future SDK callers can import cluster definitions without loading the full profile module.
- Cluster taxonomy: core_loop, audit_review, milestone, research_ideate, workspace_state, docs, ui, ai_eval, ns_meta, utility. Membership may overlap; every installed skill stem must appear in at least one cluster (enforced by tests/surface-clusters.test.cjs).
- Relationship to Anthropic platform asks: Asks D (native per-skill toggle API) and E (budget-fraction negotiation) remain filed separately. The /gsd:surface command is a unilateral GSD-side workaround that does not depend on those platform changes.

Status — Phase 1 shipped

Phase 1 artifacts landed on feat/3408-skills-description-dropped-due-to-size:

- gsd-core/bin/lib/install-profiles.cjsPROFILES map, resolveProfile, loadSkillsManifest, stageSkillsForProfile, stageAgentsForProfile, readActiveProfile, writeActiveProfile, mostRestrictiveProfile, resolveEffectiveProfile
- requires: frontmatter added to 64 skills in commands/gsd/*.md
- scripts/lint-skill-deps.cjs — CI gate for requires: integrity, wired into pretest
- bin/install.js--profile=<name> flag (composable); --minimal/--core-only as aliases; .gsd-profile marker write on install; gsd update re-reads marker
- Tests: tests/install-profiles-manifest.test.cjs, tests/install-profiles-marker.test.cjs, tests/install-profiles-resolve.test.cjs, tests/install-profiles-stage.test.cjs, tests/lint-skill-deps.test.cjs

Phase 2 shipped on the same branch:

- commands/gsd/surface.md/gsd:surface slash command runbook (sub-commands: list, status, profile <name>, disable <cluster>, enable <cluster>, reset)
- gsd-core/bin/lib/surface.cjs — runtime engine (readSurface, writeSurface, resolveSurface, applySurface, listSurface); reuses stageSkillsForProfile / stageAgentsForProfile from Phase 1
- gsd-core/bin/lib/clusters.cjs — 10-cluster taxonomy covering all installed skill stems
- Tests: tests/surface-state.test.cjs, tests/surface-clusters.test.cjs, tests/surface-resolve.test.cjs, tests/surface-apply.test.cjs, tests/surface-list.test.cjs
- Persistent surface state: <runtimeConfigDir>/.gsd-surface.json (independent from .gsd-profile)

Open questions

- Whether the requires: field should also be consumed by /gsd:help to annotate dependency chains in help output (follow-up).
- Whether telemetry (per-profile install counts, cluster-disable events) should be added to the surface engine or deferred.
- Whether Anthropic platform asks D and E (native skill toggle API, budget-fraction negotiation) should block any future work in this module.

Consequences

- Users with constrained context budgets can install --profile=core and expand incrementally via /gsd:surface enable <cluster> without a full reinstall.
- The requires: closure ensures partial installs never silently break skill cross-references.
- Future skills must declare requires: dependencies to participate in profile resolution; the lint gate enforces this at CI time.
- CONTEXT.md gains a canonical Skill Surface Budget Module entry; future architecture reviews should treat out-of-seam skill staging as drift.
- Cluster definitions in clusters.cjs are the authoritative taxonomy for runtime surface control; additions must be reflected there and in tests.

References

- Feature issue: #3408
- Research memo: docs/research/2026-05-12-skill-surface-budget.md (§3.2 cluster taxonomy)
- See 0008-installer-migration-module.md
- See 0009-shell-command-projection-module.md
- See 0010-file-operation-engine-module.md

---

Adr/0012 Command Routing Hub

CommandRoutingHub as single dispatch seam for CJS command families

- Status: Superseded by ADR-0174 (2026-05-23); originally Accepted (2026-05-20)
- Date: 2026-05-20

Context

Seven *-command-router.cjs files (phase, phases, roadmap, state, verify, validate, init) each duplicate the same three-part dispatch pattern: (1) check GSD_WORKSTREAM + tryLoadSdk() to decide whether to use the SDK or CJS handler, (2) invoke the selected path, (3) map errors to the error() callback. The duplicated mode-selection logic means a policy change (e.g., adding a new fallback condition) must be applied in eight places. Tests for these routers are mock-heavy — they stub tryLoadSdk, stub getExecuteForCjs, and assert on internal call shapes rather than observable dispatch outcomes. The SDK-vs-CJS fallback decision is smeared across every router, making it impossible to reason about or test the policy in isolation.

Decision

Introduce CommandRoutingHub (gsd-core/bin/lib/command-routing-hub.cjs) as the single dispatch seam for all CJS command family routers. The hub contract:


createHub({ mode: 'sdk' | 'cjs', sdkLoader, cjsRegistry, manifest }) -> hub
hub.dispatch({ family, subcommand, args, cwd, raw }) -> Result

Result = { ok: true, data }
| { ok: false, errorKind, message, details? }

text
Load-bearing design properties:

- Pure result: the hub never prints to stdout/stderr, never calls process.exit, and never throws. All internal throws are caught and converted to { ok: false, errorKind: 'HandlerFailure' }.
- Mode fixed at construction: mode is set once when createHub is called; it is never re-evaluated per dispatch call. Each adapter (caller) computes mode based on its own env/sdk-load context before constructing the hub.
- No transparent fallback: an SDK-mode hub that encounters an SDK crash or load failure returns { ok: false, errorKind: 'SdkDispatchFailed' } or 'SdkLoadFailed' respectively. It does not silently retry via the CJS registry.
- Closed errorKind enum: the six error kinds (UnknownCommand, InvalidArgs, HandlerRefusal, HandlerFailure, SdkLoadFailed, SdkDispatchFailed) are exported as a frozen ERROR_KINDS object. Callers switch on ERROR_KINDS values, not bare string literals. Adding a new error kind requires amending this ADR.

The router adapter's responsibilities shrink to: determine mode from env, build stubs/registry, construct hub, dispatch, translate the pure Result to output()/error() calls. Each adapter remains a thin CLI-facing translation layer.

phase-command-router.cjs is migrated as the proof-of-concept for this PR. Remaining routers migrate in follow-up issues.

Consequences

- Positive: policy (mode decision, no-throw contract, error taxonomy) is concentrated in one module rather than duplicated across eight. Testing the policy requires only the hub unit tests; adapter tests verify translation correctness (args → dispatch, Result → output/error).
- Positive: future routers can be onboarded by wiring cjsRegistry entries rather than hand-replicating the SDK/CJS conditional block.
- Constraint: adding a new errorKind value requires updating ERROR_KINDS in command-routing-hub.cjs AND amending this ADR. The closed enum is the drift-prevention property; the amendment requirement makes scope of impact explicit.
- Constraint: each adapter must compute mode before hub construction (no lazy re-evaluation). This is intentional — mode ambiguity at dispatch time is a prior source of subtle test flakiness.

Known limitation: SDK-incomplete subcommands

The hub's mode is fixed at construction ('sdk' or 'cjs'). This works cleanly only when every subcommand in a family has an implementation in the active mode. Today some phase subcommands have divergent CJS and SDK implementations. phase.mvp-mode is present in the SDK catalog (command-static-catalog-domain.ts) but its CJS-native implementation (phase.cmdPhaseMvpMode) differs in ROADMAP scan behaviour and error reason codes from the SDK query layer. Routing mvp-mode through the SDK hub would silently change observable CLI behaviour (exit codes, JSON error shape).

The proof-of-concept adapter (phase-command-router.cjs) handles this with an early-return bypass: mvp-mode is intercepted before the dispatch call so it never reaches the hub. This preserves observable behavior but introduces a hub-level abstraction leak — the adapter now carries per-subcommand routing policy that the hub was meant to own.

Future direction (deferred): the hub should consult manifest to detect per-subcommand SDK coverage and route to CJS automatically for subcommands not present in the SDK manifest. That refinement stays inside the global-mode decision — the mode still applies to the family as a whole — and avoids the per-command policy ladder that was explicitly rejected during design. This work is tracked alongside SDK-CJS migration #3524 closure.

References

- Extends ADR-0001 (Dispatch Policy Module) — the hub implements the no-throw + structured-result contract ADR-0001 established for the SDK query layer, applying it to the CJS adapter layer.
- Issue: #3788

---

Adr/0174 Retire Gsd Sdk Package Boundary

ADR-0174: Retire @opengsd/gsd-sdk package boundary — single-runtime collapse

- Status: Accepted (2026-05-23); amended #1642 (2026-06-23) — §5 reconciled to as-built Result type + exitReason? field added on InvalidArgs
- Date: 2026-05-23
- Tracking issue: #174 — sub-issues #175–#197

Supersedes

| ADR | What it said | Why it is superseded |
|-----|-------------|----------------------|
| ADR-0005 | Established the SDK as a composition of explicit seam Modules with thin Adapters, with the SDK package itself as the composition root. | The SDK package boundary is being retired; the seam-Module vocabulary survives intact under a single src/ tree inside get-shit-done-cc. |
| ADR-0007 | Defined one explicit SDK Package Seam Module for the @opengsd/gsd-sdk@opengsd/get-shit-done-redux compatibility transition. | The transition scaffolding this Module owned (install-layout probing, legacy-asset discovery, compatibility diagnostics) is deleted when the SDK package boundary is retired. |
| ADR-0012 | Introduced CommandRoutingHub with a mode: 'sdk' \| 'cjs' parameter, sdkLoader, cjsRegistry, and the SdkDispatchFailed / SdkLoadFailed errorKinds as the dispatch seam for CJS command families. | The Hub survives but is simplified: mode, sdkLoader, cjsRegistry, and SDK-failure errorKinds are deleted because there is no second runtime to select. The four surviving cross-cutting concerns (errors, manifest, args, observability) remain in the Hub and are now unambiguously load-bearing. |
| ADR-3524 | Hardened the CJS↔SDK seam with a generator-based Shared-Module Source Policy (one source of truth per Module, generated artifacts, freshness checks, hand-sync pair lint) as the canonical Phase 5 engine. | The generator pattern solved drift within the dual-runtime world. Collapsing onto a single TypeScript source tree in src/ eliminates the seam these generators bridged; tsc replaces every .generated.cjs artifact. |

Context

The following forced-decisions explain why this consolidation is happening now rather than continuing the ADR-3524 trajectory:

1. Lived cost of dual-runtime exceeded the value of a separately-installable typed package. ADR-3524 was correct about the drift problem and correct that a generator pattern was better than hand-sync. But the generator pattern is infrastructure to maintain parity between two runtimes, not value delivered to users. The total scaffolding footprint reached ~120 files: worker pool, per-Module generators, freshness checks, parity tests, transition shims, two release pipelines, and the bridge that ran the async SDK handler synchronously via synckit for CJS callers. None of that scaffolding was visible to users as a feature.

2. CJS already provides the feature set in-process; the SDK package boundary added cost without user-visible capability. Every capability the SDK was being built to expose — typed dispatch, structured results, observability, command metadata — can be compiled out of a single TypeScript source tree and required directly. The separately-installable @opengsd/gsd-sdk package had no external programmatic API user requirement driving it. It was scaffolding for scaffolding.

3. The deletion test concentrated complexity rather than spreading it. Removing the SDK package as a whole eliminates an entire surface: the bridge, the generators, the release pipeline, the parity tests. Keeping the SDK package and merging only the bridge would have preserved parity tests, generators, and the second release pipeline indefinitely.

4. ADR-0012's mode parameter was the contested part of the Hub design. Review comments during the ADR-0012 PR identified mode-selection, sdkLoader injection, and the SdkDispatchFailed errorKind as the fragile parts of the Hub contract. These concerns disappear when there is one runtime. The four surviving concerns — uniform error contract, manifest-backed resolution, arg shape coercion, observability — are clearly load-bearing and uncontested. Stripping mode makes the Hub stronger, not weaker.

5. External programmatic API surface is not a user requirement. The user does not need @opengsd/gsd-sdk to be installable by external consumers. The typed contract is internal. Retaining the package boundary would mean paying for SDK scaffolding in perpetuity to support a use case that does not exist.

Decision

1. Single npm package

get-shit-done-cc is the sole npm package. @opengsd/gsd-sdk is retired. No external programmatic API is exposed. The typed contract is internal to get-shit-done-cc.

2. Source shape — TypeScript canonical in src/, compiled to CJS in dist/

src/ is the hand-authored source of truth for all Modules. tsc on prepublishOnly compiles src/ to CJS in dist/. bin/gsd-tools.cjs becomes a thin shim that requires from dist/. No synckit, no worker pool, no generator scripts replacing tsc. The .generated.cjs artifacts in bin/lib/ are deleted; their callers are updated to require from dist/.

3. Source tree — seam-aligned subdirectories under src/

Each subdirectory maps 1:1 to an architectural concern, preserving the seam-Module vocabulary from ADR-0005 under a single tree:

| Directory | Concern |
|-----------|---------|
| src/dispatch/ | The simplified Hub |
| src/handlers/ | Command implementations grouped by family (phase/, roadmap/, state/, init/, …) |
| src/errors/ | GSDError, errorKind enum, error classification |
| src/manifest/ | Command metadata, alias resolution |
| src/config/ | Configuration Module (was Shared CJS/SDK) |
| src/state/ | STATE.md Document Module |
| src/workstream/ | Workstream Inventory Module |
| src/runtime/ | Runtime Name Policy, project-root resolution |
| src/cli/ | gsd-tools entrypoint code |
| src/observability/ | DispatchLogger, redaction |

4. Hub simplified — four cross-cutting concerns, no mode parameter

CommandRoutingHub is retained with its no-throw contract and closed errorKind enum, but the following are deleted: mode: 'sdk' | 'cjs' parameter, sdkLoader, cjsRegistry, SdkDispatchFailed errorKind, SdkLoadFailed errorKind.

The Hub owns exactly four cross-cutting concerns:

1. Uniform error contract — all internal throws are caught and converted to structured Result values; the Hub never calls process.exit or prints to stdout/stderr.
2. Manifest-backed resolution — command lookup and alias resolution are routed through src/manifest/.
3. Arg shape coercion — incoming arg shapes are normalized before handler invocation.
4. Observability — DispatchLogger is injected; the Hub emits DispatchEvent records on every dispatch path.

5. Sync dispatch with tight-typed Result<T> per errorKind variant

Dispatch is synchronous: dispatch<T>(req: DispatchRequest): Result<T>.

Rationale: continuous stack traces, no async-boundary races in the logger, no orphaned side effects, SIGINT shows what is actually running. synckit dependency is removed.

The Result<T> type is a discriminated union per errorKind variant, not a flat string field. The as-built type (in src/command-routing-hub.cts) is:

ts
type Result<T> =
| { ok: true; data: T }
| { ok: false; kind: 'UnknownCommand'; command: string }
| { ok: false; kind: 'InvalidArgs'; arg: string; reason: string; exitReason?: string }
| { ok: false; kind: 'HandlerRefusal'; reason: string }
| { ok: false; kind: 'HandlerFailure'; message: string; cause?: Error };
text
Drift note (amendment #1642, 2026-06-23): the original §5 text specified a different planned shape — 'Unknown' / 'BadArgs' / 'ValidationFailed' / 'NotImplemented' / 'HandlerFailed'. The SDK retirement migration kept the ADR-0012 names (UnknownCommand / InvalidArgs / HandlerFailure) and never added the planned ValidationFailed or NotImplemented variants; HandlerRefusal was added during implementation but never back-filled into this ADR. This amendment reconciles the ADR to the as-built code so the contract documented here matches what consumers actually depend on. The drift was caught during architecture review (parent #1641).

Factories (src/command-routing-hub.cts):

ts
makeUnknownCommand(command: string) → Readonly<UnknownCommandResult>
makeInvalidArgs(arg: string, reason: string, exitReason?: string) → Readonly<InvalidArgsResult>
makeHandlerRefusal(reason: string) → Readonly<HandlerRefusalResult>
makeHandlerFailure(message: string, cause?: unknown) → HandlerFailureResult
text
The exitReason? field on InvalidArgs (added by this amendment) carries an ERROR_REASON enum value (e.g. ERROR_REASON.USAGE) separately from the existing reason explanation text. This lets routers that today call error(msg, ERROR_REASON.USAGE) directly — bypassing the Hub — preserve ERROR_REASON granularity when they migrate to returning makeInvalidArgs(...) Results through the Hub. The field is optional and additive; existing callers are unaffected.

Dispatcher translation contract: when an adapter translates an InvalidArgs Result whose exitReason is present, it passes exitReason as the second argument to error(message, exitReason) so the JSON-error envelope (GSD_JSON_ERRORS=1) preserves the typed reason for downstream consumers (CLI tests, integration harnesses).

Adding a new variant or adding a field to an existing variant requires amending this ADR (preserving the drift-prevention property from ADR-0012).

6. Observability seam — silent on success, structured JSON on error, opt-in audit

- Silent on success — no stdout/stderr output from the Hub on a successful dispatch.
- Structured JSON to stderr on error — every Result with ok: false emits a structured JSON line to stderr with traceId, kind, and the variant's typed payload.
- Opt-in file auditGSD_AUDIT=1 env var or config.audit.enabled: true writes DispatchEvent records to .planning/.gsd-trace.jsonl. Args are excluded by default (privacy); GSD_AUDIT_ARGS=1 opts in.
- Trace identity — every DispatchEvent carries a traceId. Composed dispatches set parentTraceId to link children to the parent invocation.
- Injected logger — the Hub accepts a DispatchLogger interface. The default implementation writes per the rules above. The test implementation is in-memory and carries no I/O side effects.

7. Init. family stays as a composer module

src/handlers/init/composer.ts composes N atomic dispatches. Each child dispatch receives the parent's traceId as its parentTraceId, linking the full init tree in the audit trail. The composer is the only caller that sets parentTraceId; all other dispatches are leaf dispatches.

Consequences

Positive

- Locality up. Composition concerns are concentrated in one place per seam. The seam-Module vocabulary from ADR-0005 is preserved; only the package boundary is retired.
- Leverage up. One build pipeline, one npm publish, one CI release workflow. The release-sdk.yml workflow and all SDK steps in release.yml, hotfix.yml, and install-smoke.yml are deleted.
- Test surface down ~35 files; character shifts from ~40% infrastructure / 60% behavior to ~5% / 95%. The parity tests, generator freshness checks, hand-sync pair lint, bridge unit tests, and worker-pool integration tests are deleted outright. Tests that were testing infrastructure (does the generator emit the right bytes?) become irrelevant. Tests that verify observable dispatch outcomes survive and are the dominant surface.
- Debuggability up. Continuous stack traces from the caller through the Hub to the handler. traceId trees link composed dispatches in the audit log. No async-boundary gaps in log entries. SIGINT shows the actual call in progress.
- synckit dependency removed. The in-process event-loop bridge that ran async SDK handlers synchronously for CJS callers is deleted with it.

Negative

- External programmatic API surface retires. Accepted by the user — it is not a requirement. Any future external API surface would be a new design decision, not a reversion.
- CONTEXT.md and ~20 doc files require updating across Phase 6 PRs. The "Shared CJS/SDK Module" qualifier, SDK seam descriptions, and references to the sdk/ directory structure are updated in Phase 6 (deferred; this ADR does not touch CONTEXT.md).
- ~15–18 PRs of implementation work across 7 phases. See Migration Plan below.
- tsc build step added to prepublishOnly. get-shit-done-cc currently has no TypeScript compilation at the root. The build step is net-simpler than the existing generator infrastructure, but it is a new step in the publish path.

Alternatives considered

Shape A — pure CJS + JSDoc, zero build step

Rejected. Loses TypeScript authoring ergonomics: no structural type checking, no discriminated-union narrowing, no intra-repo cross-Module type errors caught at compile time. The SDK was correct that TypeScript authoring is worth the compilation step; this alternative gives up the wrong thing.

Shape C — CJS + .d.ts overlays

Rejected. Recreates the parity problem at a smaller scale: hand-authored .d.ts files drift from the .cjs implementations unless a freshness check is added, which is the generator pattern again at half the scale. The root cause (two artifacts for one behavior surface) is not addressed.

Keep SDK package, merge only the bridge

Rejected. Deleting synckit without deleting the SDK package preserves parity tests, generator scripts, and the second release pipeline indefinitely. The bridge was not the sole source of complexity — it was the most visible symptom. Merging only the bridge trades a runtime dependency for an ongoing infrastructure maintenance burden without eliminating the dual-runtime cost.

Keep dual runtime indefinitely (status quo)

Rejected. Lived experience showed the cost ratio is wrong: ~120 files of scaffolding for a feature set CJS provides in-process, with no external programmatic API user requirement to justify the separately-installable package. The incremental fix cycle (each drift class fixed once in CJS, once in SDK, once in the generator) compounds with every new Module added to the Shared-Module table.

Migration plan

Seven phases, ~15–18 PRs total. Each phase is a coherent slice that leaves the codebase in a working state.

| Phase | Description | PRs |
|-------|-------------|-----|
| 1 — Simplify the Hub in place | Drop mode, introduce tight-typed Result<T>, add observability seam, add traceId. | ~4 |
| 2 — Move TS source from sdk/ to src/ | Per-Module migration: config, state, workstream, runtime, manifest, errors, observability, dispatch, cli, handlers. | ~8 |
| 3 — Retire parity layer | Delete parity tests; replace .generated.cjs generator scripts with tsc output in dist/; update callers. | ~3 |
| 4 — Collapse bridge | Inline bridge logic into Hub; delete synckit; delete bin/lib/cjs-sdk-bridge.cjs and sdk/src/runtime-bridge-sync/. | ~1 |
| 5 — Retire SDK package | Delete sdk/ directory; delete bin/gsd-sdk.js shim and bin/gsd-sdk wrapper; delete release-sdk.yml and SDK steps in release.yml, hotfix.yml, install-smoke.yml. | ~2 |
| 6 — Docs cleanup | Update CONTEXT.md, docs/, workflow markdown, and localized docs to remove SDK references. | ~4 |
| 7 — Land this ADR's PR | The PR for this ADR closes the umbrella tracking issue. | 1 (this PR) |

Implementation is tracked in #174 — sub-issues #175–#197.

---

Adr/0656 Research Module Seam

ADR-0656: Research Module — L2-hybrid seam for cached, curated-first research

- Status: Accepted
- Date: 2026-06-03

Context

Research in GSD was entirely prose-duplicated. Seven researcher agents each carried their own copy of the provider waterfall (Context7, Ref, Jina, Exa, Tavily, Perplexity, Brave, Firecrawl, websearch), their own confidence-tier definitions, and their own fallback policy. Every time a new provider was added or the ordering changed, all seven files drifted independently — the exact failure mode META.RULE.brief-no-paraphrase exists to prevent.

There was no research cache. Agents checked for an existing RESEARCH.md file but had no TTL, no content-addressing, and no notion of staleness. Identical queries re-fetched from live providers across phases and projects.

Package legitimacy was a pip-install slopcheck bolt-on. When the slopcheck binary was absent or crashed, every package was silently downgraded to [ASSUMED], removing the legitimacy gate entirely rather than degrading gracefully.

Context7 was prompt-only: agents mentioned it in prose but there was no code-level integration, no cache, and no structured verdict returned to the orchestrator.

Decision

Introduce an L2-hybrid seam: code owns cache, provider policy, legitimacy verdicts, and confidence classification; MCP owns the actual network fetch (a .cjs module cannot call MCP tools directly).

Three modules are introduced under src/ compiled to gsd-core/bin/lib/*.cjs per ADR-457 (generated-single-source):

Research Store (src/research-store.cts): content-addressed cache keyed by sha256(ecosystem + library + version + query + kind). getResearch() never throws — it returns { hit, stale } mirroring the graphify staleness tri-state pattern. TTL is per-source: curated-doc providers get 30 days (HIGH), medium-quality sources get 7 days (MED), web/synthesis gets 1 day (LOW). Two storage tiers: curated-doc kinds write to ~/.gsd/research-cache (cross-project reuse); web and synthesis results write to .planning/research/.cache (project-local, gitignored).

Research Provider (src/research-provider.cts): single source of truth for PROVIDER_WATERFALL. Docs waterfall: Context7 → Ref → Jina → websearch. Web waterfall: Exa → Tavily → Perplexity → Brave → websearch. Scrape: Firecrawl → Jina (Firecrawl is scrape-only, not in docs/web discovery). planResearch() returns cache hits plus a fetch plan for misses. classifyConfidence() stamps HIGH | MEDIUM | LOW by provider authority + verification evidence — the tier set is unchanged (ADR-consistent), but HIGH now requires code-computed ground-truth corroboration (e.g. legitimacyVerdict: 'OK'); provider authority alone caps at MEDIUM; SLOP caps at LOW. Provider availability is driven by config flags and _API_KEY env vars; context7, jina, and websearch are always available as the terminal fallback.

Package Legitimacy (src/package-legitimacy.cts): registry-API verdicts via injectable adapters for npm, PyPI, and crates.io. Thresholds: { minAgeDays: 30, minWeeklyDownloads: 1000, requireRepo: true }. Verdict per package: OK | SUS | SLOP. slopcheck is an optional escalate-only adapter — it can only raise a verdict, never lower it — and is not the install-or-degrade gate. Absence of slopcheck leaves registry-API verdicts intact rather than downgrading everything to [ASSUMED].

All three modules are reachable via gsd-tools query research-plan | research-store | package-legitimacy.

Agents return a RESEARCH.md path; they never return raw fetched content. This enforces context discipline: subagent isolation, compact provider output, fetches-to-disk, cache-returns-digest.

Consequences

Positive:
- Provider policy lives in one tested module. Adding or reordering a provider is a one-line change that propagates to all researcher agents.
- Content-addressed cache eliminates redundant fetches across phases and projects.
- Package legitimacy is registry-API-first and degrades gracefully; slopcheck enriches without gating.
- The gsd-tools query interface is the test surface — behavioral tests can assert typed JSON output without source-grep.

Deferred to #657:
- Collapsing the seven researcher agent .md files into generated-from-profiles agents (the prose waterfall duplication in those files is the primary DEFECT.RESEARCH-PROVIDER-PROSE-DRIFT site).
- The install.js MCP tool-mapping for tavily, ref, and jina (those land where the agents declare the tools they need).

Known constraint:
API context-editing primitives (clear_tool_uses, memory tool) are the conceptual model for context discipline, but they are not configurable through the Claude Code harness today. The current implementation achieves context discipline through subagent isolation and fetch-to-disk patterns.

---

Adr/15 Autonomous Cross Ai Convergence

Cross-AI Plan Convergence via Existing Orchestration Commands

- Status: Accepted — ratified 2026-07-17 (originally Proposed 2026-05-24); see "Ratification" below
- Date: 2026-05-24
- Issue: #15

Current orchestration commands (/gsd-autonomous and /gsd-progress --next --auto) route planning through gsd-plan-phase and only use local/Claude subagent review paths. The cross-AI convergence path already exists (/gsd-plan-review-convergence, /gsd-review, review.default_reviewers, review.models.*) but is not wired into these orchestrators. This creates a gap: users can configure cross-AI reviewers yet still get local-only planning in autonomous/auto-chain execution.

Ratification (2026-07-17): Proposed → Accepted

Ratified by explicit maintainer directive after the shipped implementation was independently re-verified; the Status field had read "Proposed" for roughly 8 weeks after the underlying decision had already landed.

Evidence the decision shipped:

- Primary, parity, and alias surfaces are present verbatim: commands/gsd/progress.md:4,28 (--next --converge, --cross-ai alias, reviewer flags, --max-cycles N) and commands/gsd/autonomous.md:4,40-41 (--converge, --cross-ai alias).
- The plan_strategy=local|converge seam is implemented in gsd-core/workflows/next.md:260-313 (PLAN_STRATEGY parsing, CONVERGENCE_ARGS build, feature-gate check, Route-3 override) and mirrored in gsd-core/workflows/autonomous.md:19-90,378-419.
- Fail-fast-on-disabled-gate behavior matches the ADR's Failure Policy exactly: next.md:279-292 and the equivalent block in autonomous.md check workflow.plan_review_convergence via config-get and abort with the exact gsd config-set workflow.plan_review_convergence true instruction — no silent downgrade to local.
- The config contract is shipped: gsd-core/bin/shared/config-schema.manifest.json:36 (workflow.plan_review_convergence), :54 (review.default_reviewers), :123,141 (review.models.*); documented identically in docs/CONFIGURATION.md:225,316 and docs/COMMANDS.md:620-622,850-852.
- Dedicated regression tests exist: tests/adr-15-progress-converge.test.cjs (179 lines, describe block titled 'ADR-15: /gsd:progress --next --auto --converge (#1190)') and tests/autonomous-converge.test.cjs (225 lines, covering the parity surface under 'autonomous --converge flag (#711)' — this file does not itself reference ADR-15 by name).
- Landing commits: 092340d18 (fix(#711): wire autonomous convergence flag, 2026-06-10, parity surface) and 0b3a2e5f9 (feat(#1190): wire --converge primary surface into /gsd:progress --next (ADR-15) (#1237), 2026-06-14) — the latter's commit body states "ADR-15 designates /gsd-progress --next --auto --converge as the PRIMARY plan-convergence surface" and confirms the wiring gap the ADR called out is closed.
- No later ADR references or supersedes ADR-15: grep -rl 'ADR-15' docs/adr/*.md returns only docs/adr/README.md's own index row (line 158), which still lists it as "Proposed" — the stale bookkeeping entry this ratification corrects.

Governance state: Issue #15 CLOSED — stateReason COMPLETED (closed 2026-05-25T03:12:26Z). Follow-up test-coverage issue #1190 ("test(coverage): fill Proposed-ADR test gaps") also CLOSED — stateReason COMPLETED (closed 2026-06-14T19:52:24Z).

Decision

Do not add a new command. Add convergence as an orchestration policy in existing commands, with /gsd-progress as the primary operator surface.

1. Add a shared plan strategy seam for orchestration workflows:
- plan_strategy=local|converge
- local maps to gsd-plan-phase
- converge maps to gsd-plan-review-convergence
2. Expose the strategy via existing entry points:
- /gsd-progress --next --auto --converge (primary)
- /gsd-autonomous --converge (parity path for users who prefer autonomous directly)
- keep --cross-ai as a compatibility alias for --converge
3. Reuse existing reviewer selection semantics from /gsd-review and /gsd-plan-review-convergence:
- explicit reviewer flags (--codex, --gemini, --claude, --opencode, --ollama, --lm-studio, --llama-cpp)
- --all
- review.default_reviewers and review.models.* config
4. Add pass-through flags (no new command surface):
- --converge (primary)
- --cross-ai (alias)
- reviewer selector flags listed above
- --max-cycles N (forwarded per phase)
5. Keep convergence behind existing feature gate:
- if workflow.plan_review_convergence=false and --converge (or alias) is requested, fail fast with actionable enable instructions.
6. Keep post-execution review behavior unchanged in this slice (gsd-code-review and gsd-ui-review stay as-is). Cross-AI code-review fanout is deferred.
7. Define convergence eligibility and allowed AIs via config (no new command):
- enable gate: workflow.plan_review_convergence=true
- allowed reviewer set: review.default_reviewers (for no-flag converge runs)
- per-reviewer model selection: review.models.*

Interface Contract

Existing CLI Surfaces (No New Command)

- /gsd-progress --next --auto [--converge|--cross-ai] [reviewer flags] [--max-cycles N]
- /gsd-autonomous [existing flags] [--converge|--cross-ai] [reviewer flags] [--max-cycles N]

Planning Step Routing

- plan_strategy=local:
- orchestrator step uses gsd-plan-phase (current behavior).
- plan_strategy=converge:
- orchestrator step uses gsd-plan-review-convergence.
- convergence workflow remains owner of HIGH counting (CYCLE_SUMMARY), stall detection, and escalation.

Failure Policy

- If --converge (or --cross-ai) is requested but convergence gate is disabled:
- stop before planning dispatch
- emit exact enable command:
- gsd config-set workflow.plan_review_convergence true
- no silent downgrade to local strategy.

Configuration Contract (Enable + Allowed AIs)

Convergence is configurable without introducing new config namespaces.

1. Enable convergence:
- workflow.plan_review_convergence: true
2. Define which AIs are allowed by default for convergence runs:
- review.default_reviewers: ["codex", "gemini"] (example)
3. Optionally pin models per allowed reviewer:
- review.models.codex, review.models.gemini, etc.

Precedence for reviewer selection in converge mode:

1. Explicit CLI reviewer flags (--codex, --gemini, --all, etc.)
2. review.default_reviewers
3. If neither resolves to any reviewer, fail fast with actionable message.

Example config:

json
{
"workflow": {
"plan_review_convergence": true
},
"review": {
"default_reviewers": ["codex", "gemini"],
"models": {
"codex": "gpt-5.4",
"gemini": "gemini-2.5-pro"
}
}
}
text

Flag Naming

Issue #15 asks for a flag such as --converge or --cross-ai on autonomous execution. --converge is the better primary term because it names the behavior (plan-review convergence loop), not the transport (external AI) or mode label (autonomous).

1. Primary: --converge
2. Alias: --cross-ai
3. Avoid: introducing --autonomous-* variants (the command already defines that mode)

Options Considered

1. Autonomous-only flag (/gsd-autonomous --cross-ai)
- Files: commands/gsd/autonomous.md, workflows/autonomous.md
- Problem: solves issue #15 directly but leaves /gsd-progress --next --auto inconsistent.
- Benefit: smallest blast radius.
- Drawback: two orchestration modes diverge in behavior.

2. Progress-primary + autonomous parity (Chosen)
- Files: commands/gsd/progress.md, workflows/progress.md, workflows/next.md, plus autonomous wiring
- Problem: must keep two orchestrators aligned.
- Solution: one shared plan-strategy seam consumed by both commands.
- Benefit: better locality; users who already drive from progress --next --auto get convergence without switching workflows.

3. Config-only global toggle (no per-run flag)
- Files: config schema + both orchestrators
- Benefit: minimal CLI syntax expansion.
- Drawback: less control per run; harder to do targeted high-cost convergence only when needed.
- Decision: defer; keep explicit runtime flag.

Rubber-Duck Design Notes

Expected behavior: the two existing orchestration entry points should be able to opt into cross-AI plan convergence without adding another top-level command.

Actual behavior: both orchestration entry points always take the local planning route, so external reviewers are never reached unless the user abandons orchestration flow and runs convergence manually.

Wrong assumptions surfaced:
1. "Enabling workflow.plan_review_convergence changes orchestration behavior." It does not unless the convergence command is explicitly routed.
2. "Cross-AI config propagates automatically into autonomous/next flows." It only applies where convergence/review workflows are invoked.
3. "Adding a separate command is required." Existing orchestration commands are sufficient if they expose a strategy seam and clear flag naming.

Root architectural gap: orchestration flows lack a plan strategy seam (local vs converge).

Scope

In scope

- Plan strategy seam shared by existing orchestration commands.
- --cross-ai pass-through contract on existing commands.
- --converge primary flag naming and --cross-ai compatibility alias.
- Feature-gate behavior contract for convergence strategy.
- Config contract for enabling convergence and selecting allowed AIs.
- Documentation updates tied to command/config behavior.

Out of scope

- New top-level command creation.
- Reworking gsd-code-review into cross-AI convergence loop.
- New reviewer config schema (reuse existing review.* keys).
- Changing default planning strategy without explicit opt-in.
- Altering gsd-plan-review-convergence internal loop semantics.

Consequences

- No new command tax on docs, routing, and long-term maintenance.
- Existing orchestration habits (progress --next --auto and autonomous) can opt into convergence consistently.
- Existing review configuration gets leverage without new schema.
- Backward compatibility is preserved by default.
- Explicit failure on disabled gate avoids silent false-confidence automation.

References

- Issue: #15
- commands/gsd/progress.md
- gsd-core/workflows/progress.md
- gsd-core/workflows/next.md
- commands/gsd/autonomous.md
- gsd-core/workflows/autonomous.md
- commands/gsd/plan-review-convergence.md
- gsd-core/workflows/plan-review-convergence.md
- commands/gsd/review.md
- docs/COMMANDS.md (/gsd-plan-review-convergence, /gsd-review)
- docs/CONFIGURATION.md (workflow.plan_review_convergence, review.default_reviewers, review.models.*)

---

Adr/22 Plan Drift Guard

Plan-vs-codebase drift guard: defaults and symbol-resolver seam

- Status: Accepted — ratified 2026-07-17 (originally Proposed 2026-05-29); see "Ratification" below
- Date: 2026-05-29
- Issue: open-gsd/gsd-core#22

Ratification (2026-07-17): Proposed → Accepted

Ratified by explicit maintainer directive; the Status field sat at Proposed for roughly 14 months against a decision that in fact shipped and closed within a day of the ADR being written (issue closed 2026-05-30, one day after the 2026-05-29 ADR date).

Evidence the decision shipped
- src/plan-drift-guard.cts implements the ADR's authority ladder and severity table as a pure decision module: AUTHORITY_RUNGS (grep=0…scip=4), getEffectiveAuthority() (auto-upgrades grepintel when intel.enabled), and classifyDriftSeverity() producing the exact table (VERIFIED→none, MISSING@rung<3→needs-acknowledgement, MISSING@rung>=3→HIGH/hardBlock, AMBIGUOUS→MEDIUM, UNCHECKABLE→INFO); compiled to gsd-core/bin/lib/plan-drift-guard.cjs (gitignored generated artifact, .gitignore:101).
- gsd-core/bin/shared/config-defaults.manifest.json:94-97 sets plan_review.source_grounding default true and plan_review.source_grounding_authority default 'grep' — the default-on verification pass from Part 1 point 1.
- capabilities/intel/capability.json keeps intel.enabled default false and wires its plan:pre step (intel api-surface) with onError: "skip"intel.enabled stays opt-in and the injection never blocks, per Part 1 points 2-3.
- gsd-core/workflows/plan-review-convergence.md's "Source-grounding pass" section (~lines 184-208) implements the four-valued resolver contract (VERIFIED/MISSING/AMBIGUOUS/UNCHECKABLE), excludes plan-declared "Artifacts this phase produces," delegates severity to the drift-guard CLI seam rather than inline reviewer reasoning, and appends a "Verification coverage" block to REVIEWS.md.
- gsd-core/workflows/plan-phase.md §7.9 ("Regenerate API-SURFACE.md (intel gate)") regenerates the surface only when the intel step hook is active and injects it into the planner prompt labeled "HINT ONLY... MAY BE INCOMPLETE... Never treat the surface as exhaustive" — matching Part 1 point 2 verbatim.
- gsd-core/workflows/settings.md and gsd-core/workflows/new-project.md surface plan_review.source_grounding as a "Drift Guard" toggle/setup question; docs/CONFIGURATION.md documents both config keys, explicitly marking authority rungs 2-4 (treesitter/lsp/scip) as reserved with no effect in the current release.

Governance: owning issue open-gsd/gsd-core#22 — CLOSED, stateReason COMPLETED, closed 2026-05-30T21:08:13Z, labeled enhancement + approved-feature.

Context

The planner regularly cites symbols that do not exist in the codebase — invented decorators, wrong dataclass fields, renamed CLI flags, mismatched signatures. The phenomenon is measured, not anecdotal: the Practical Code Generation hallucination taxonomy (arXiv:2409.20550) reports Dependency Conflicts (11.26%) and API Knowledge Conflicts (20.41%), which together describe exactly this failure. Today the drift is caught only at execution time by the executor (ImportError/AttributeError), at roughly 10–15 min/fix, a dozen per multi-wave phase.

/gsd:plan-review-convergence does not catch it because planner and reviewer both read the same channel (other plan files); the drift originates in that channel. The fix must introduce an out-of-band source of truth: the project's own source code.

GSD ships intel.cjs (api-map.json et al.), but: enabling intel.enabled populates nothing (the gsd-intel-updater LLM agent is never auto-spawned; population is a manual /gsd:map-codebase --query refresh); extraction is regex, JS/CJS/ESM only; intel is stale after 24h with no auto-refresh (intelUpdate() is a stub); useful intel therefore costs recurring LLM-agent token spend.

The feature has two halves with different dependencies: a verification pass that reads live source (needs no intel, works in any language) and a surface-injection step that renders api-map.json into API-SURFACE.md for the planner (needs intel).

Decision

Part 1 — Defaults

1. Ship the verification pass on by default, behind a new, additive config key plan_review.source_grounding (boolean, default true, opt-out). Surface it as one question in /gsd:new-project (default Y) and as a toggle in /gsd:settings. It stays on permanently with an easy off switch.
2. The API-SURFACE.md injection stays gated on the existing intel.enabled (default unchanged). It ships in this release but only activates for projects that opted into intel and populated it. Its planner instruction is a hint ("prefer symbols in API-SURFACE.md; it may be incomplete"), never a hard rule.
3. intel.enabled stays opt-in. Flip its default to true only once all three hold: (a) deterministic population (tree-sitter/CJS parse, not an LLM agent); (b) auto-refresh on staleness; (c) multi-language coverage. None of these block the drift guard, because the default-on half does not depend on intel.
4. Only new, additive config keys are introduced. No pre-existing default is changed.

Part 2 — Symbol-resolver seam

The reviewer pass depends on a resolver seam, not a hardcoded tool:

resolve(ref) -> VERIFIED{location, exported, signature?} | MISSING | AMBIGUOUS{candidates} | UNCHECKABLE{reason}

Resolution is three-valued, not boolean. UNCHECKABLE (the adapter cannot analyze this language or symbol kind) never blocks and never falsely blesses; it is recorded as a coverage gap. Only MISSING from a capable adapter is actionable.

Adapters form an authority ladder, selected by plan_review.source_grounding_authority (enum; default grep, auto-upgrades to intel when intel.enabled), with no prompt changes when climbing:

| Rung | Backend | Asserts | This release? |
|------|---------|---------|---------------|
| 0 | ripgrep / Read | name present in source | yes (default) |
| 1 | api-map.json (existing intel) | name in parsed export list | yes (when intel on) |
| 2 | tree-sitter | real declaration + kind | deferred (new dep) |
| 3 | LSP workspace/symbol | + resolved definition, signature | deferred (new dep) |
| 4 | SCIP / GitNexus (#3802) | + exported?, signature, references | deferred (new dep) |

Locked sub-decisions:
- Severity. MISSING from rung 0–1 -> needs-acknowledgement (the plan proceeds if the author confirms the symbol is new/dynamic, logged), not a hard block — because rung 0–1 produce false positives on dynamic dispatch, re-exports, metaprogrammed decorators, and generated code. Hard block (HIGH) is reserved for rung >=3 adapters that can prove absence. AMBIGUOUS -> MEDIUM. UNCHECKABLE -> INFO.
- New vs. existing. Plans declare created symbols in an "Artifacts this phase produces" section. The resolver only checks symbols not in that list, so greenfield work is never flagged MISSING.
- Extraction contract. The reviewer enumerates a fixed set of symbol kinds (@decorators, Class.method, module.function, --cli-flags, file paths, dataclass/struct fields) and must quote the plan line for each, so coverage is auditable.
- Signature-drift can only be asserted at rung >=3; rungs 0–1 return UNCHECKABLE for signatures (name-drift only this release).
- Cadence. Resolve once per unique symbol per convergence cycle (cache within the cycle); run the pass every cycle so cycle-N fixes are re-verified in cycle N+1.
- Coverage reporting. REVIEWS.md carries a "Verification coverage" INFO block listing every UNCHECKABLE/skipped symbol and why, so "the guard ran" can never silently mean "nothing was checked."

Consequences

Positive


- Every project gets drift protection on day one, in any language, with zero setup and zero token cost (live Grep/Read against ground truth).
- The high-authority, always-fresh check is the default; the low-authority, stale-able cache (intel) stays an explicit, paid opt-in.
- One interface, many backends: raising verification authority (grep -> intel -> tree-sitter -> LSP -> SCIP) is a config change, never a workflow rewrite — the durable lever against worsening hallucination.
- Three-valued resolution refuses the RAG trap where missing context is treated as permission to invent, and cannot be poisoned by stale/partial intel.
- needs-acknowledgement keeps the default-on gate tolerable (no blocking valid plans), preserving adoption.
- Honors the issue author's own sequencing ("treat the default flip as a separate proposal") and changes no established default.

Negative


- Two config surfaces (plan_review.source_grounding and intel.enabled) instead of one.
- This release's rung 0–1 catch name-drift but not signature-drift; signature coverage waits on a rung >=3 adapter.
- Requires planners to populate the "Artifacts this phase produces" section reliably; a missing list degrades precision (new symbols flagged for acknowledgement).
- The default-on pass adds reviewer tool calls per convergence cycle (bounded by the cadence rule above).

Alternatives considered

- Flip intel.enabled to default-on and gate the whole feature on it. Rejected: enabling intel populates nothing, so default-on intel makes API-SURFACE.md inject a near-empty surface; combined with a "use only these symbols" instruction it tells the planner the codebase is empty — strictly worse than no surface. Also imposes silent emptiness on non-JS projects, widens the stale-data window, and pulls solo devs toward unrequested agent-token spend.
- Ship the feature opt-in (default-off). Rejected: the protection is free in the default (live-source) configuration; opt-in would leave most projects unprotected for no benefit while hallucination worsens.
- Hardcode Grep/Read in the reviewer prompt. Rejected: binds the workflow to one tool, over-claims ("found in text" = "verified"), and forces a prompt rewrite to adopt a better backend later.
- Boolean resolution (verified / not-verified). Rejected: collapses "couldn't check" into "missing," producing false positives on every unsupported language and silently blessing nothing.
- Hard-block on any MISSING (as originally proposed). Rejected for rung 0–1: false positives from dynamic/re-exported/generated symbols would block valid plans and get the default-on guard switched off. Retained only for rung >=3.

References


- Issue: open-gsd/gsd-core#22 (migrated from open-gsd/gsd-core#3813)
- Relates to #3802 (GitNexus first-class code intelligence) — rung 4 backend
- arXiv:2409.20550 — hallucination taxonomy + RAG mitigation (modest gains)
- arXiv:2502.05111 — grammar-constrained decoding (soft vs hard constraints)
- SCIP: https://github.com/sourcegraph/scip · LSP 3.17 spec · tree-sitter.github.io

---

Adr/58 Runtime Install Policy Module

Runtime Install Policy Module owns the typed install-plan projection

- Status: Accepted
- Date: 2026-06-07
- Issue: #58
- Subsumed by: ADR-1239 (GSD as an Embeddable Orchestration Engine) — read it first; see the amendment below
- Subsumed by: ADR-857 (Capability system) — generalizes this module's install-plan projection; this seam remains live at src/runtime-artifact-install-plan.cts:82

Amendment (2026-07-16): subsumed by ADR-1239 (EoS)

ADR-1239GSD as an Embeddable Orchestration Engine (EoS), Accepted — subsumes this ADR as an adapter: the typed InstallPlan projection this module owns becomes one of the surfaces the host negotiates for, rather than the outermost seam at which GSD meets a host.

This ADR is not superseded and its status is unchanged. The InstallPlan seam is live and load-bearing. It is now a component of the EoS frame, not the top-level answer to "how does GSD meet a host?".

Read ADR-1239 first.

Recorded because ADR-1239 declared this subsumption while this file recorded nothing.

Amendment (2026-07-17): also subsumed by ADR-857 (Capability system)

ADR-857 was ratified Proposed → Accepted on 2026-07-17 and generalizes this module's install-plan projection into the unified Capability model (install composes active Features × the chosen Runtime at this ADR's InstallPlan seam).

This ADR remains Accepted and live. ADR-857's header originally read "Supersedes (generalizes)"; on ratification that was corrected to Subsumes, precisely because this seam is not dead — InstallPlan is live at src/runtime-artifact-install-plan.cts:82. This module is now a component of two broader frames: ADR-857 (what composes an install) and ADR-1239/EoS (how a host loads the engine at all).

Context

Runtime install logic is currently spread across one-off helper functions. getGlobalDir(runtime, explicitDir) in bin/install.js switch-dispatches to per-runtime helpers (getOpencodeGlobalDir, getKiloGlobalDir), and getAgentsDir lives separately in src/core.cts. These helpers resolve directories at ~11 call sites and are free to drift from the behavior that install and runtime-query paths actually expect, because nothing owns the composition of an install decision as a single value.

Two adjacent seams already exist:

- ADR-3660 (Runtime Artifact Layout Module) owns where per-runtime artifacts (commands, agents, skills) are placed.
- ADR-0009 (Shell Command Projection Module) owns runtime-aware command text rendering (quoting, path style, wrapper prefixes).

But no ADR owns composing those — placements + command text + per-runtime config intentions — into one unified, typed install-plan projection. That missing seam is why directory/config logic re-derives itself ad hoc at each call site.

Decision

Introduce a Runtime Install Policy Module as the seam that, given a runtime and an install context, projects a pure, typed InstallPlan value describing everything that should happen for that runtime. The projection:

- composes artifact placements by delegating to the Runtime Artifact Layout Module (ADR-3660),
- composes command text by delegating to the Shell Command Projection Module (ADR-0009),
- declares config intentions (which config files need which keys/values for that runtime),
- performs no filesystem IO and no format-specific serialization while resolving the plan.

Concrete execution is owned by runtime-specific adapters (made explicit as a registry in #60). Adapters consume the InstallPlan and perform the effectful work: file mutations, directory creation, and rendering format-specific config (TOML, JSON, Markdown) for their runtime.

This follows the repository's established pure-policy-projects / thin-adapters-execute pattern (ADR-0001, Dispatch Policy Module): the InstallPlan is the narrow waist, resolution stays free of IO, and callers become thin adapters over a stable interface rather than re-deriving directory logic.

What stays OUTSIDE the policy module

To keep the abstraction honest about the filesystem boundary, the following are explicitly not the policy module's responsibility and remain in the runtime adapters:

- Concrete TOML / JSON / Markdown read-modify-write and serialization.
- Merge semantics for pre-existing config files (preserving user keys, ordering, formatting).
- Filesystem effects: directory creation, atomic write/rename, existence/permission checks.
- Any path resolution that requires touching the disk.

The policy module resolves intent as data; adapters turn that intent into bytes on disk.

Consequences

- Install logic becomes testable as pure data: assert the projected InstallPlan for a runtime without a filesystem.
- The scattered directory helpers (getGlobalDir, getOpencodeGlobalDir, getKiloGlobalDir, getAgentsDir) gain a single projection to migrate onto, retiring or narrowing them (tracked in #56).
- The plan/adapter contract becomes a stability surface that must be held narrow; drift there reintroduces the very divergence this seam removes.
- Rollout is incremental, not big-bang: this ADR establishes the boundary (#58); the explicit Runtime Adapter Registry lands next (#60); legacy helper retirement follows (#56); downstream cleanup in #57.

Implementation (2026-06-11)

The InstallPlan is realized as the exported resolveInstallPlan(runtime) in runtime-config-adapter-registry (co-located with adapter-selection, not a standalone module). It collects the install-level descriptor axes — installSurface, writesSharedSettings, finishPermissionWriter, hookEvents, extendedHookEvents, and hooksSurface — into one typed InstallPlan value. install() and finishInstall() in bin/install.js consume it directly. The spatial axes (configHome, artifactLayout, commandStyle) remain behind their self-resolving adapter modules (runtime-homes, runtime-artifact-layout, runtime-slash) as the execution adapters — consistent with this ADR's adapters-execute boundary.

References

- ADR-0001 — Dispatch Policy Module (pure-policy-projects / thin-adapters-execute precedent).
- ADR-3660 — Runtime Artifact Layout Module (per-runtime artifact placement; delegated to by this projection).
- ADR-0009 — Shell Command Projection Module (runtime-aware command text; delegated to by this projection).
- ADR-0008 — Installer Migration Module (adjacent installer seam).
- CONTEXT.md § Glossary — Domain modules and seams (the architecture seam map / glossary this module is registered in).
- Installer-refactor chain: #58 (this ADR) → #60 (explicit adapter registry) → #56 (retire legacy directory helpers) → #57.

---

Adr/218 Release Version Validation

ADR-218: Harden release-workflow version validation — reject leading zeros and pre-check npm

- Status: Accepted (2026-05-24)
- Date: 2026-05-24

Context

The leading-zero normalization incident

The release workflow's validate-version job used the regex ^[0-9]+\.[0-9]+\.0$ to gate the version input. This regex accepts leading zeros in any segment (1.01.0, 01.0.0, etc.) because [0-9]+ matches one or more digits without anchoring against a leading zero.

A maintainer triggered the workflow with version=1.01.0. The validator accepted it. Downstream steps called npm version 1.01.0, which silently normalized the string to 1.1.0 per the semver specification (leading zeros are stripped). From that point the pipeline had irreconcilably divergent state:

| Artifact | Value |
|---|---|
| npm registry (@opengsd/get-shit-done-redux, @opengsd/gsd-sdk) | 1.1.0 — published, immutable |
| git tag | v1.01.0 |
| GitHub release title | v1.1.0 |
| GitHub release tag | v1.01.0 |
| Release branch | release/1.01.0 |

The workflow run ultimately failed downstream. A subsequent attempt to re-run finalize for version=1.1.0 failed at the Dry-run publish validation step because npm refuses to republish an already-published version.

A second typo (version=1.03.0) produced a similar orphan tag and branch but never published to npm.

The duplicate-version late-failure hole

The validator only checked format. It did not check whether the requested version was already live on npm. A duplicate-version request (e.g. re-running finalize for an already-published version, or mistyping a version that normalizes to one already published) ran through the full checkout → install → build → test cycle — roughly 10 minutes — before failing at the Dry-run publish validation step.

Decision

1. Tighten the format regex to forbid leading zeros

Replace ^[0-9]+\.[0-9]+\.0$ with ^(0|[1-9][0-9])\.(0|[1-9][0-9])\.0$.

Each segment now matches either 0 exactly or a string starting with a non-zero digit followed by zero or more digits. 1.01.0 fails because the minor segment 01 matches neither alternative. The error message includes the offending value so maintainers can correct it immediately.

The IS_MAJOR detection regex is updated in parallel: ^[0-9]+\.0\.0$^(0|[1-9][0-9]*)\.0\.0$.

2. Add a duplicate-version precheck against npm at validation time

A new Reject already-published versions step runs immediately after format validation in the validate-version job. It calls npm view "$pkg@$VERSION" version for both @opengsd/get-shit-done-redux and @opengsd/gsd-sdk. If either resolves, the job fails in under 5 seconds with a clear error. No build or test cycle is wasted.

Recovery sequence

The following steps recover the production state left by the 1.01.0 and 1.03.0 incidents. Include them here so future maintainers do not need to re-derive them.

1. Cancel any failing in-flight runs

sh
gh run cancel <run-id> --repo open-gsd/get-shit-done-redux
text

2. Fix the v1.1.0 divergence (npm published, tag/release/branch on wrong name)

sh

Fetch the release branch that holds the correct package.json at 1.1.0


git fetch origin release/1.1.0

Create a proper v1.1.0 tag at the head of release/1.1.0


git tag v1.1.0 <sha-of-release/1.1.0-head>
git push origin v1.1.0

Retarget the existing GitHub release from the typo tag to the correct one


gh release edit v1.01.0 --repo open-gsd/get-shit-done-redux --tag v1.1.0
gh release edit v1.1.0 --repo open-gsd/get-shit-done-redux --verify-tag

Delete the typo tag and branch


git push origin :refs/tags/v1.01.0
git push origin --delete release/1.01.0
text

3. Clean the unpublished v1.03.0 orphan

sh
git push origin :refs/tags/v1.03.0
git push origin --delete release/1.03.0
text

4. Resume releases at 1.2.0

1.1.0 is consumed on npm and the workflow only allows .0 patch-component versions. The next valid release is 1.2.0.

Consequences

- Valid inputs are unaffected. The new regex accepts every version that the old regex accepted minus leading-zero forms. All existing release runs used well-formed versions; no regression for normal use.
- Leading-zero inputs fail in under 5 seconds at the validate-version job before any branch, install, or publish operation runs.
- Duplicate-version inputs fail in under 5 seconds at validate-version rather than after a full install-and-test cycle.
- The late dry-run check in finalize is unchanged. It remains a belt-and-suspenders guard; the new precheck does not remove it.

See also

- ADR 227 (docs/adr/227-input-validation-shape-not-just-type.md) generalises the principle this ADR documents in the narrower release-validation context: input validation at trust boundaries must check both type and semantic shape, with silent coercion on failure.

---

Adr/227 Input Validation Shape Not Just Type

ADR 227: Input validation must check semantic shape, not just type

- Status: Accepted (2026-05-24)
- Date: 2026-05-24

Context

Defensive normalization at trust boundaries typically starts with a type check:

js
if (typeof value !== 'string') return undefined;
text
This stops non-string values but accepts any string — including the empty string, garbage payloads, and values that are structurally correct (a string) but contractually invalid (not a UUID v4, not a semver, not a file path). The remaining attack surface is the gap between "is a string" and "satisfies the field's contract."

The PR #225 trigger

PR #225 (refactor/178-trace-id-propagation, P1.4 of ADR-0174 SDK retirement) introduced parentTraceId on DispatchEvent. The initial implementation normalized the field with a type-only guard:

js
parentTraceId: typeof raw.parentTraceId === 'string' ? raw.parentTraceId : undefined,
text
Codex adversarial-review (commit range fb94ba8d338d0951) flagged that this propagated:

- empty strings ("") — a correlation key that matches nothing
- garbage strings ("not-a-uuid", ";", "<script>...") — log bloat, downstream parser confusion
- oversized strings — potential high-cardinality index explosion in tracing back-ends

The field's contract is UUID v4. The fix was a strict regex with silent coercion:

js
const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
parentTraceId: UUID_V4.test(raw.parentTraceId) ? raw.parentTraceId : undefined,
text

The generalizing precedent

ADR 218 (docs/adr/218-release-version-validation.md) documents the same two-layer pattern applied earlier to the release-workflow version input: a type-level format regex was tightened to reject leading-zero segments (semantic-shape enforcement), and a duplicate-version precheck was added at validation time. That ADR is narrowly scoped to the release workflow; this ADR captures the general principle so future contributors can cite it during code review without needing to derive it from the release-workflow incident.

Decision

Defensive normalization at any trust boundary MUST validate two layers:

1. Typetypeof, instanceof, Array.isArray, schema-type check
2. Semantic shape — regex, schema, range, or enum check that proves the value satisfies the field's contract

On failure of either layer, the value MUST be silently coerced to the contract's safe default (typically undefined or null). It MUST NOT be propagated. Throw only if the surrounding codebase treats throws as a normal-flow signal (it usually does not — dispatch pipelines must remain continuous).

Both layers are required. A type check alone is necessary but not sufficient.

Consequences

Bug classes avoided

- Correlation poisoning — a garbage parentTraceId propagated into a trace back-end creates phantom spans that never match a real root.
- High-cardinality log bloat — unsanitized free-form strings as structured-log field values balloon index size in Elasticsearch, Datadog, etc.
- Downstream parser crashes — a field typed as UUID v4 but containing ";" or a 2 KB payload can crash a consumer that assumed a bounded, well-formed value.

Cost

One additional regex or predicate per trust-boundary field. For most fields this is a one-liner compiled once at module load. The maintenance burden is low.

Tradeoff

Silent coercion hides invalid inputs from callers — a buggy upstream component may send garbage and never learn it was rejected. Mitigate with an opt-in debug log (process.env.GSD_DEBUG) that surfaces the coercion without affecting production behavior.

Concrete cases

Case 1 — parentTraceId on DispatchEvent (PR #225)

| | Detail |
|---|---|
| Field | parentTraceId: string \| undefined |
| Contract | UUID v4 |
| Initial impl | typeof raw.parentTraceId === 'string' — type check only |
| Codex finding | Commit range fb94ba8d338d0951; flagged correlation poisoning and log bloat |
| Fix | UUID_V4.test(raw.parentTraceId) ? raw.parentTraceId : undefined |

Case 2 — release-workflow version input (ADR 218)

| | Detail |
|---|---|
| Field | version: string (GitHub Actions workflow input) |
| Contract | Semver MAJOR.MINOR.0, no leading zeros |
| Initial impl | ^[0-9]+\.[0-9]+\.0$ — type regex only; 1.01.0 accepted |
| Semantic fix | ^(0\|[1-9][0-9])\.(0\|[1-9][0-9])\.0$ — rejects leading zeros |
| Duplicate precheck | Added npm view call at validation time to reject already-published versions early |

See ADR 218 for the full incident account.

Alternatives considered

Type-only checks (status quo before this ADR)

Rejected. The cases above prove that type-only checks leave the harder bug class open. A string is not a UUID; a string is not a semver. The type check is the floor, not the ceiling.

Schema validation library (Zod, ajv)

Rejected for the current CJS phase. Adding a runtime dependency to the core library violates the project's no-external-dependencies policy for gsd-tools.cjs. Project-internal patterns prefer surgical regex/predicate functions. The src/ TypeScript phase may revisit if Zod is adopted broadly — that is a separate decision.

Throwing on invalid input

Rejected. Throwing breaks dispatch pipeline reliability. The dispatch pipeline must be continuous; a bad parentTraceId must not abort event dispatch. Silent coercion preserves continuity; an opt-in debug warn preserves visibility. Fields where an invalid value is genuinely fatal (not just malformed) may throw — that is a per-field decision, not the general rule.

- Issue: #227
- PR introducing: this PR
- Cross-references: ADR 218 (docs/adr/218-release-version-validation.md)

---

Adr/230 Introduce Next Integration Branch

Introduce next as a long-lived integration branch

- Status: Proposed
- Date: 2026-05-23

Filename note. This ADR uses the placeholder (now resolved to 230) per

CONTRIBUTING.md §Proposing an ADR. Before merging,

open a chore: issue, replace XXXX with the assigned issue number, and

rename the file accordingly.

Why this is still Proposed (audited 2026-07-17)

The architectural shift is real and operating: the live default branch is
next (gh api repos/open-gsd/gsd-core --jq .default_branch), .github/workflows/auto-backmerge.yml
runs unconditionally (if: true, not the Phase-1 if: false stub) and has
produced real, merged main → next back-merge PRs across multiple releases
(#671, #1337, #1673, and others), release.yml cherry-picks from
origin/next with an origin/main fallback per the Phase-3 patch,
pr-target-validator.yml enforces (WARN_ONLY: 'false'), and
auto-branch.yml branches from next with a main fallback.

The blocker. The Decision section requires differentiated branch
protection: main — "strict: 2 reviewer approvals, all CI green, ...
restrict push to maintainers via PR only"; next — "loose: ... 'require
branches up to date' OFF." Live settings invert this. main's classic
branch protection (verified via gh api repos/open-gsd/gsd-core/branches/main/protection
and its required_pull_request_reviews / required_status_checks
sub-resources) shows required_approving_review_count: 1 (spec: 2),
required_status_checks returns 404 "not enabled" (spec: all CI green
required — there is no CI gate on main at all), and
allow_force_pushes.enabled: true (spec: restrict push to maintainers via
PR only). next's protection, by contrast, has required_status_checks.strict: true
across 7 contexts and allow_force_pushes.enabled: false — stricter than
main, not looser. The two GitHub Rulesets that might have compensated
(main-protection id 16752567, release-branches id 16752568) are both
enforcement: "evaluate" (dry-run, non-blocking) and were never promoted
to active; main-protection's condition further targets ~DEFAULT_BRANCH,
a dynamic alias that now resolves to next (the current default branch),
so even if activated it would apply to the wrong branch. Migration Phase 2
step 3 ("Apply branch protection: bash scripts/setup-branch-protection.sh")
was evidently run for next but never durably applied to main.

Issue #230's own closure (state_reason: completed) certifies only Phase 1
(additive infrastructure) — its body scopes itself explicitly to Phase 1
and defers branch-protection application, the default-branch flip, and
workflow-enforcement flags to a "Phase 2 follow-up (separate PR)"; that
follow-up evidently landed for next but not for main's protection.
Separately, next's "require branches up to date OFF (this is the whole
point)" was reversed five days later by ADR-415 (Accepted, 2026-05-28),
which set required_status_checks.strict = true on next after a real
stale-base regression (#406/#411/#412) — so the specific rebase-treadmill
relief this ADR promises for next no longer holds exactly as written,
though the broader architectural decision (integration branch, isolated
main, automated back-merge) is unaffected. Migration Phase 4 cleanup
(drop develop from branch-naming.yml's alwaysValid; drop the || main
fallbacks in release.yml/auto-branch.yml) is also still open, gated on
"2-3 successful releases" per the ADR's own text — cosmetic, not blocking.

Unblock condition. Ratify once main's live branch protection matches
this ADR's Decision section — required_approving_review_count: 2,
required_status_checks enabled and required, allow_force_pushes: false
— applied via scripts/setup-branch-protection.sh (or an equivalent gh api
call), and the two evaluate-mode Rulesets are either activated with
corrected ref_name conditions or removed as redundant with classic
protection. Verify with:


gh api repos/open-gsd/gsd-core/branches/main/protection/required_pull_request_reviews --jq .required_approving_review_count # expect 2
gh api repos/open-gsd/gsd-core/branches/main/protection/required_status_checks # expect 200, not 404
gh api repos/open-gsd/gsd-core/branches/main/protection --jq .allow_force_pushes.enabled # expect false
text
Until then, either bring main's protection into line with the Decision
section, or amend this ADR (as ADR-415 did for one next parameter) to
record the protection posture actually in force.

Context

Today every contributor branch — feat/, fix/, chore/, docs/,
refactor/, test/, perf/, ci/, revert/ — is cut from main and PR'd
back to main. Release branches (release/X.Y.0) and hotfix branches
(hotfix/X.Y.Z) are also cut from main. As a result:

1. main moves on every merge. With ~315 unreleased changesets queued
and multiple PRs in flight at any time, main advances multiple times a
day.
2. GitHub branch protection on main requires "branches up to date before
merging" (the dominant pattern across mature OSS projects with linear
history). Every time another PR lands, every in-flight PR must rebase
before its own merge button enables.
3. release/X.Y.0 accumulates RC-cycle fixes that drift from main.
When finalize opens the merge-back PR, the diff is large and
contributors who PR'd to release/* can't be sure their fix is also
queued for the next minor.
4. hotfix.yml cherry-picks fix:/chore: commits from main since the
prior tag. This works today only because every fix lands on main.
The pattern is fragile — any deviation (e.g. fix landing on release/*)
is invisible to the picker. v1.42.3 (#3621) shipped a half-state for
exactly this class of reason.

The maintainer's stated pain: *"every update doesn't mean the next pr needs
a rebase"* — i.e. the rebase treadmill from (2), driven by (1).

Decision

Introduce next as a long-lived integration branch.

- All work that today targets main instead targets next, with the sole
exceptions of release/X.Y.0 and hotfix/X.Y.Z branches, which still
merge to main.
- next is always at-or-ahead of main. Any push to main (release or
hotfix merge) triggers an automated back-merge PR main → next to keep
next aligned.
- Branch protection rules differ:
- main — strict: 2 reviewer approvals, all CI green, "require
branches up to date" ON, signed commits, restrict push to maintainers
via PR only.
- next — loose: 1 reviewer approval, all CI green, "require branches
up to date" OFF, auto-delete source branches.
- Default branch (in repo Settings) becomes next. gh pr create and the
GitHub web UI then default new PRs to the correct target without a flag.

Where each branch type goes

| Branch prefix | Today's target | New target | Rationale |
|---|---|---|---|
| feat/ | main | next | Features ship in minor releases |
| fix/ | main | next | Regular fixes ship in minor (or get cherry-picked by hotfix.yml) |
| chore/, docs/, refactor/, test/, perf/, ci/, revert/ | main | next | All same-flow as fixes |
| fix/critical-* | main | main | Production-down only, auto-back-merges to next |
| release/X.Y.0 | main | main (cut from next) | Promoted to production on finalize |
| hotfix/X.Y.Z | main | main (cut from prior tag, cherry-picks from next) | Patch releases |

Mechanical changes summary

| Component | Change |
|---|---|
| release.yml (create) | Branch from next, not main |
| release.yml (finalize) | Open merge-back PR to both main and next (was just main) |
| hotfix.yml (cherry-pick step) | Cherry-pick from origin/next, not origin/main |
| hotfix.yml (finalize) | Open merge-back PRs to both main and next (was just main) |
| branch-naming.yml | Add next to alwaysValid list |
| auto-branch.yml | Branch from next HEAD instead of main HEAD for issue-labeled branches |
| New pr-target-validator.yml | Block PRs targeting main from branches that aren't release/, hotfix/, or fix/critical-* |
| New auto-backmerge.yml | On push to main, open main → next PR |
| Repo settings | Default branch = next; squash-merge only on next; merge-commit on main (preserve tag context) |
| scripts/setup-branch-protection.sh | New: idempotent script to apply both branch protection rule sets via gh api |

Consequences

Positive

- Rebase treadmill ends. PRs targeting next are not gated on
"up-to-date before merge". Concurrent PRs to next merge in any order as
long as they don't conflict on the same lines.
- main becomes a stable reference. It changes only on release/hotfix
merges — a handful of times per week, not multiple times per day. CI on
main runs less; downstream consumers (linked CI, npm tag watchers) see
fewer transient states.
- Hotfix cherry-pick base is unambiguous. All fix:/chore: commits
candidate for a hotfix live on next. The cherry-pick filter (today
hardcoded against origin/main) becomes correct-by-construction once
retargeted to origin/next.
- RC-only fixes flow back to next automatically. Today a fix that
lands on release/1.28.0 to unblock RC2 only makes it to next-equivalent
(i.e. main) when finalize back-merges. Under the new model finalize
back-merges to both main and next, so an RC fix is never accidentally
dropped from the next minor.
- Default branch switch is one click. Cost is low; setting takes effect
for every new PR and clone immediately.

Negative

- One more concept to teach contributors. Mitigated by docs/branching.md
+ CONTRIBUTING update + PR-target validator that says "retarget to next"
with a one-line fix instruction.
- Hotfix and release workflows need updates. Patches are inlined below.
Both are reversible — if a patch causes pain, revert and re-target the
workflows back to main. No on-disk state migration required.
- The auto-backmerge PR is a new background-noise source. It opens
silently after each release/hotfix push to main. Mitigated by labeling
the PR automation and auto-merging if CI passes (configurable in
auto-backmerge.yml).
- Existing 315-changeset queue. Doesn't strictly block this change but
the next release will be a large one. Recommend cutting 1.28.0 from
main (current behavior, last time) before flipping the default branch
to next — see "Migration" below.

Risks not worth the trade-off

We considered and rejected:

- develop instead of next. The git-flow nomenclature is established
but the gitflow model itself is heavier than this project needs (no
long-lived release/ branches per-major, no support/ for old majors).
next matches the existing npm dist-tag (@next) and is the convention
for Angular, Next.js, React Native, and others. Use the name that already
appears in VERSIONING.md.
- Merge queue. GitHub's merge queue (GA in 2023) addresses the same
pain by serializing merges and rebasing+testing automatically. Rejected
because (a) it doesn't address the parallel work-stream separation that a
next branch gives, (b) it still requires "branches up to date" which we
want to relax, and (c) the maintainer is a git beginner and merge queue's
failure modes (split commits, requeued PRs) are harder to debug than a
conventional model.
- Pure trunk-based with feature flags. Rejected because the project
publishes to npm and doesn't have a runtime feature-flag system. Feature
flags would be a larger separate investment.

Migration

This is a phase-gated rollout. Each phase is reversible.

Phase 0 — Decide

1. Open a chore: issue: "Introduce next integration branch". Get the
issue number. Rename this ADR file from XXXX- to <issue#>-.
2. Review this ADR. Decide on the merge-commit-vs-squash policy for next
(recommended: squash) and for main (recommended: merge commit on
release back-merges, to preserve the tag-commit relationship).

Phase 1 — Additive infrastructure (no behavior change)

The following land on main (current model, one last time) before flipping:

- docs/branching.md (new)
- docs/adr/<issue#>-introduce-next-integration-branch.md (this file)
- scripts/setup-branch-protection.sh (new)
- .github/workflows/auto-backmerge.yml (new, disabled with
if: false until phase 2)
- .github/workflows/pr-target-validator.yml (new, in "warning only" mode)
- .github/workflows/branch-naming.yml (update: add next to alwaysValid)
- CONTRIBUTING.md update: "Where do I open my PR?" section

Phase 2 — Flip

When the next release is ready to start its RC cycle:

1. Cut the current planned release (e.g. 1.28.0) using release.yml as
today — this drains the 315-changeset queue from main cleanly.
2. After v1.28.0 finalizes and back-merges to main, run:

bash
git checkout main && git pull --ff-only
git checkout -b next && git push -u origin next
text
3. Apply branch protection: bash scripts/setup-branch-protection.sh.
4. Settings → Branches → change default branch to next.
5. Re-enable auto-backmerge.yml (remove the if: false).
6. Flip pr-target-validator.yml from warning-only to enforcing.

Phase 3 — Retarget release/hotfix workflows

Apply these patches once next is established and the team has run at
least one feature PR through it.

release.yml — branch from next (create step):

diff
@@ create:
- name: Create release branch
env:
BRANCH: ${{ needs.validate-version.outputs.branch }}
VERSION: ${{ inputs.version }}
IS_MAJOR: ${{ needs.validate-version.outputs.is_major }}
run: |
+ git fetch origin next:next || git fetch origin main:main
- git checkout -b "$BRANCH"
+ git checkout -b "$BRANCH" next 2>/dev/null || git checkout -b "$BRANCH" main
text
The || main fallback is for the transition window where next may not
yet exist. After Phase 2 the fallback can be removed.

release.yml — back-merge to both branches (finalize step):

diff
@@ Create PR to merge release back to main
- name: Create PR to merge release back to main
...
+ - name: Create PR to merge release back to next
+ if: ${{ !inputs.dry_run }}
+ continue-on-error: true
+ env:
+ GH_TOKEN: ${{ github.token }}
+ BRANCH: ${{ needs.validate-version.outputs.branch }}
+ VERSION: ${{ inputs.version }}
+ run: |
+ EXISTING_PR=$(gh pr list --base next --head "$BRANCH" --state open --json number --jq '.[0].number' 2>/dev/null || echo "")
+ if [ -n "$EXISTING_PR" ]; then
+ gh pr edit "$EXISTING_PR" \
+ --title "chore: merge release v${VERSION} to next" \
+ --body "Merge release branch back to next after v${VERSION} stable release (picks up RC-only fixes)." \
+ || echo "::warning::Could not update next merge-back PR. Open it manually."
+ else
+ gh pr create \
+ --base next \
+ --head "$BRANCH" \
+ --title "chore: merge release v${VERSION} to next" \
+ --body "Merge release branch back to next after v${VERSION} stable release (picks up RC-only fixes)." \
+ || echo "::warning::Could not create next merge-back PR. Open it manually."
+ fi
text
hotfix.yml — cherry-pick from next (with main fallback):
diff
@@ Cherry-pick fix/chore commits from origin/main since base tag
- - name: Cherry-pick fix/chore commits from origin/main since base tag
+ - name: Cherry-pick fix/chore commits from origin/next since base tag
...
run: |
set -euo pipefail
- git fetch origin main:refs/remotes/origin/main
+ # Prefer next; fall back to main during the transition window or
+ # for production-down emergencies that landed directly on main.
+ if git ls-remote --exit-code origin next >/dev/null 2>&1; then
+ git fetch origin next:refs/remotes/origin/next
+ SOURCE="origin/next"
+ else
+ git fetch origin main:refs/remotes/origin/main
+ SOURCE="origin/main"
+ fi

- CANDIDATES=$(git cherry "$BASE_TAG" origin/main | awk '/^\+ / {print $2}')
+ CANDIDATES=$(git cherry "$BASE_TAG" "$SOURCE" | awk '/^\+ / {print $2}')
...
- ORDERED=$(git log --reverse --format='%H' "$BASE_TAG..origin/main" \
+ ORDERED=$(git log --reverse --format='%H' "$BASE_TAG..$SOURCE" \
| grep -F -f <(echo "$CANDIDATES") || true)

text
hotfix.yml — back-merge to both branches (finalize step):
diff
@@ Create PR to merge hotfix back to main
- name: Create PR to merge hotfix back to main
...
+ - name: Create PR to merge hotfix back to next
+ if: ${{ !inputs.dry_run }}
+ env:
+ GH_TOKEN: ${{ github.token }}
+ BRANCH: ${{ needs.validate-version.outputs.branch }}
+ VERSION: ${{ inputs.version }}
+ run: |
+ EXISTING_PR=$(gh pr list --base next --head "$BRANCH" --state open --json number --jq '.[0].number')
+ if [ -n "$EXISTING_PR" ]; then
+ gh pr edit "$EXISTING_PR" \
+ --title "chore: merge hotfix v${VERSION} back to next" \
+ --body "Merge hotfix changes back to next after v${VERSION} release."
+ else
+ gh pr create \
+ --base next \
+ --head "$BRANCH" \
+ --title "chore: merge hotfix v${VERSION} back to next" \
+ --body "Merge hotfix changes back to next after v${VERSION} release."
+ fi
text
auto-branch.yml — branch from next:
diff
- // Create branch from main HEAD
- const mainRef = await github.rest.git.getRef({
+ // Create branch from next HEAD (fall back to main if next missing)
+ let baseRef;
+ try {
+ baseRef = await github.rest.git.getRef({
owner: context.repo.owner,
repo: context.repo.repo,
- ref: 'heads/main',
- });
+ ref: 'heads/next',
+ });
+ } catch (e) {
+ if (e.status !== 404) throw e;
+ baseRef = await github.rest.git.getRef({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ ref: 'heads/main',
+ });
+ }

await github.rest.git.createRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: refs/heads/${branch},
- sha: mainRef.data.object.sha,
+ sha: baseRef.data.object.sha,
});
``

Phase 4 — Cleanup

After 2-3 successful releases under the new model:

- Remove the || main fallbacks from release.yml and hotfix.yml.
- Remove
develop from branch-naming.yml alwaysValid (it was vestigial;
the project never used it).
- Drop warning-only mode from
pr-target-validator.yml.

References

- docs/branching.md — contributor-facing how-to-use-it guide
-
VERSIONING.md — semver tiers and npm dist-tag mapping
-
.github/workflows/release.yml, .github/workflows/hotfix.yml
release/hotfix automation that this ADR adjusts
-
scripts/setup-branch-protection.sh — bootstrap script for branch
protection rules
- Angular branching model
— closest analogue (
main + <version>-next)
- Next.js release flow
— uses
canary as the integration branch with the same shape

---

Adr/415 Prevent Stale Base Token Reintroduction

ADR 415: Prevent stale-base reintroduction of retired runtime tokens

- Status: Accepted (2026-05-28)
- Date: 2026-05-28
- Tracking issue: #415 (incident #411; fix #412; culprit #406; rename #373/#379)

Context

The $GSD_SDKgsd_run rename (#373/#379)

PRs #373 and #379 renamed the runtime resolver from the unquoted $GSD_SDK shell variable to a single-line, space-safe gsd_run launcher. The launcher is defined in gsd-core/workflows/_runtime-launcher.snippet.sh, propagated to all workflow .md files by scripts/sync-runtime-launcher.cjs, and enforced by tests/runtime-launcher-parity.test.cjs (which forbids any $GSD_SDK token in workflow markdown).

The silent regression (#406)

During a multi-PR merge sweep, PR #406 (fix(#160)) — branched before #379 — re-introduced 5 $GSD_SDK occurrences into gsd-core/workflows/next.md. Because it edited a different region of the file than #379, the merge produced no textual conflict and Git accepted it silently.

#406's own CI was green because its base predated the parity test, and nothing re-checked the merge result against current next. #406 also carried a stale companion assertion (tests/policy-160-route0-resume.test.cjs) that required $GSD_SDK to be present.

Discovery and fix

The regression surfaced only when all PRs co-resided on next and the parity gate went red. Fixed in #411 and #412.

Root cause

A green PR on a stale base can still regress the integration branch via a semantic change that has no textual conflict. Textual conflicts are loud; semantic regressions are silent. Only a test run against the merge result catches them — which never happened because the base was stale and up-to-date-with-base was not required before merge.

Decision

1. Require up-to-date base before merge. Enable required_status_checks.strict = true on next (already applied). Every PR must be up to date with the base before merging, forcing CI — including runtime-launcher-parity and the full suite — to run against the actual merge result, catching silent semantic regressions before they land.

2. The canonical propagator is the single source of truth for the runtime launcher. Never hand-author or hand-edit the launcher token in workflow .md files. Changes to the launcher form go through _runtime-launcher.snippet.sh + scripts/sync-runtime-launcher.cjs, gated by runtime-launcher-parity.test.cjs. The retired $GSD_SDK token must never reappear.

3. Companion tests must track the canonical form. A test asserting the presence of a resolver token must assert the current canonical token (gsd_run), never a retired one; update propagated files and token-pinning tests in the same change.

4. Admin-override caveat (process). enforce_admins remains false so maintainers keep --admin for routine flow. But because this incident was caused by --admin batch-merging stale-base PRs, maintainers must not --admin-bypass the up-to-date requirement for any PR touching workflow .md files (or other parity-gated, propagated artifacts): rebase and re-run the gate first. When batch-merging, merge structural-rename/propagation PRs last, or re-run the parity gate on the integration branch after the batch.

Consequences

Positive

- Silent stale-base semantic regressions (not just $GSD_SDK) are caught pre-merge for normal merges.
- The canonical-propagator rule and parity test give a single testable source of truth and an unambiguous reviewer/agent rule.
- Decision 4 names the exact failure mode for admin-bypass merges.

Negative

- strict = true adds rebase/CI churn: PRs behind next must update before merging.
- The guard is not absolute.
--admin can still bypass strict (enforce_admins = false), so admin merges rely on the Decision-4 discipline rather than a hard block.
- Making it absolute would require
enforce_admins = true, intentionally not adopted — it would block the maintainer's routine --admin flow.

Alternatives Considered

(a) Documentation and discipline only — rejected. Discipline alone proved insufficient under batch merges.

(b) enforce_admins = true — rejected. Too heavy for a solo-maintainer repo dependent on --admin. Decision 4 addresses the admin path instead.

(c) A bespoke "retired-token" CI check diffing sync-script output — rejected as redundant. runtime-launcher-parity already forbids the token; the real gap was running it against the merge result, which Decision 1 fixes generally.

References

- Incident: #411
- Fix: #412
- Culprit PR: #406 (
fix(#160))
- Rename PRs: #373, #379 (
gsd_run)
- Propagator:
scripts/sync-runtime-launcher.cjs
- Parity test:
tests/runtime-launcher-parity.test.cjs
- Launcher snippet:
gsd-core/workflows/_runtime-launcher.snippet.sh
- Tracking issue: #415

---

Adr/443 Opus48 Unified Effort And Fast Mode Routing

ADR 443: Unified cross-provider effort controls and fast-mode-aware routing

- Status: Proposed (2026-05-28)
- Date: 2026-05-28
- Tracking issue: #443

Why this is still Proposed (audited 2026-07-17)

The audit confirmed the cross-provider resolver/renderer/CLI machinery genuinely shipped: resolveEffortInternal, resolveEffortForTier, renderEffortForRuntime, RUNTIMES_WITH_FAST_MODE, and cmdResolveExecution (src/model-resolver.cts:534,654; src/commands.cts) implement the cascade and clamping exactly as Decision items 1–3, 5, and 6 describe, and static install-time propagation is real and end-to-end tested — tests/install-runtime-artifacts.test.cjs's describe('#443 Claude install: effort: injected into frontmatter') runs the actual install() function and reads the resulting agent .md files off disk, confirming gsd-planner gets effort: xhigh, gsd-codebase-mapper gets effort: low, and gsd-executor gets effort: high. That test predates the QA audit below (landed 2026-05-29 in the original #443 PR, commit 5ca646f01), so the "resolver-only, nothing reaches the runtime" framing of the original flavor-text problem this ADR set out to fix is fixed for the static path.

The blocker. Decision item 1's cascade names an "(1) orchestrator invocation override" as the highest-precedence layer, and Decision item 6 adds a dynamic escalation path ("effort steps up the ladder on a failed attempt"). Both exist only as CLI-callable resolver code — resolveEffortInternal's invocation-override step (src/model-resolver.cts:535) and resolveEffortForTier's attempt-based escalation (src/model-resolver.cts:654) — exercised solely by unit/CLI tests. Nothing in the shipped orchestration actually calls them: a search across every file in gsd-core/workflows/.md and agents/.md for resolve-execution or CLAUDE_CODE_EFFORT_LEVEL returns zero hits; the only workflow-level mentions of "effort" are documentation of the config keys in settings-advanced.md's confirmation table. The only propagation channel actually wired into a real GSD flow is the static one (config → install() → frontmatter, baked once at install time) — the ADR's own decided design promises more than that, and the more-than-static-baking part has no consumer. Separately, the repo's own dated QA test-architecture audit (docs/issueevidence/1192-adr-test-audit-2026-06-13.md, produced under issue #1192, closed COMPLETED) rated ADR-443 "partial ... end-to-end effort propagation untested" and named it in its action plan ("Strengthen ... ADR-443 end-to-end effort propagation," line 220); that action item was never converted into a tracked follow-up issue, and no commit since 2026-06-13 addresses it. That audit's blanket "untested" framing overstates the gap — the static path is tested — but the underlying signal (a decided mechanism with no live caller) is real and independently confirmed here.

Unblock condition. Either (a) wire the orchestrator-invocation-override and attempt-based-escalation paths into an actual GSD workflow or agent dispatch (so resolveEffortForTier's escalation and resolveEffortInternal's invocation-override step have a real caller outside src/commands.cts's CLI surface and tests), and add a test exercising that live path the way tests/install-runtime-artifacts.test.cjs exercises the static one; or (b) if the ADR's intended scope is in fact limited to static install-time propagation, amend Decision items 1 and 6 to say so explicitly and close out audit issue #1192's action-plan item 18 with a note pointing at the shipped install-wiring tests. Either is a maintainer call this file records but does not make.

Amendment (2026-07-21): path (a) chosen; audit corrected (#2481)

The maintainer call above has been made: path (a). Raised by #2475 — reviewer CLIs invoked as subprocesses by the review workflow silently inherit whatever reasoning effort sits in the user's own global CLI config, because no shipped orchestration resolves effort at invocation time. That is this ADR's blocker surfacing as a user-visible defect, not a new problem.

Two deferrals recorded above are closed by this change — resolved, not re-tracked:

1. Audit issue #1192's action-plan item 18 ("Strengthen … ADR-443 end-to-end effort propagation") — which the blocker text notes "was never converted into a tracked follow-up issue" — is satisfied by this change, which supplies the end-to-end propagation and the live-path tests it asked for. It is closed out, not converted into another follow-up.
2. The choice between (a) and (b) — which this file previously recorded without making — is resolved as (a). Scope is not limited to static install-time propagation. Choosing the path is not the same as completing it; see the status table below for what remains.

How path (a) is being satisfied. The consumer is defined through the Host-Integration Interface rather than by hard-coding per-CLI effort syntax into a workflow: ADR-1239 gains an effortSurface axis declaring how each host accepts reasoning effort (argv | none), so a universal effort value resolved by this ADR's cascade is rendered per host through the negotiated descriptor. EFFORT_RENDERING (src/model-catalog.cts) — whose channel vocabulary is frontmatter | api, both install-time — collapses into that descriptor data rather than growing a parallel per-runtime table. Its callers today are exactly the two channels this ADR already ships: the static install-time renderer (bin/install.js, via src/install-effort-resolver.cts) and the manual query resolve-execution / effort-sync CLI surface (src/commands.cts). No workflow or agent dispatch calls it — which is the blocker restated in terms of the renderer rather than the resolver.

What this change actually delivers — and what it does not. Path (a) names two mechanisms needing a live caller. This change delivers neither of them; it delivers a third thing the blocker did not anticipate, and the audit of the other two turns out to have been stale.

| Path (a) mechanism | Status |
|---|---|
| Decision item 1 —
resolveEffortInternal's invocation-override step (--effort) | Still no live caller. No workflow, reference, or agent passes --effort to resolve-execution. This change does not add one. |
| Decision item 6 —
resolveEffortForTier's attempt-based escalation | Already satisfied — by #2296, not by this change. gsd-core/references/execute-phase-quota-recovery.md calls resolve-execution gsd-executor --attempt "${QUOTA_ATTEMPT:-1}" --failure-class quota-exceeded, and that reference is @-included into gsd-core/workflows/execute-phase.md, so it executes as part of the live workflow. |
| New here: the resolved effort cascade reaches a spawned host as an invocation argument | Delivered.
gsd-core/workflows/review.md calls resolve-execution … --host <id> per reviewer and appends the rendered argument, gated by the host's negotiated effortSurface. |

The blocker's grep was stale in two ways. It searched only gsd-core/workflows/.md and agents/.md; references/*.md is @-included into workflows and is therefore just as live — that is where #2296's escalation caller sits. And the blocker text was written 2026-07-17, three days before #2296 landed (455ad49ae, 2026-07-20), so its "zero hits" finding was correct on the day and has since been overtaken.

This ADR therefore remains Proposed. Decision item 6's condition is met (by #2296); Decision item 1's is not. The corpus rule for ratifying a stale Proposed requires the decided mechanism to demonstrably exist in the tree, and the invocation-override step still has no caller outside the CLI surface and tests. Status flips when item 1 gains a live caller and a test exercises that path through a workflow rather than through gsd-tools directly.

Boundary. #2313 owns the static/install-time effort channel for Codex (model_reasoning_effort in generated ~/.codex/agents/<agent>.toml, plus a sync path) and explicitly places orchestrator effort-override drift outside its scope. That is the static channel this ADR already ships; the work above is the invocation-time channel it does not.

Context

Effort control and fast mode in Claude Opus 4.8

Claude Opus 4.8 introduced two orthogonal execution controls relevant to GSD's agent orchestration:

1. Effort control — API request field output_config.effort (string enum). Anthropic levels: low, medium, high, xhigh, max; Opus 4.8 defaults to high. In Claude Code it is exposed as /effort, the --effort CLI flag, the CLAUDE_CODE_EFFORT_LEVEL env var, the effortLevel settings.json key (accepts low/medium/high/xhigh; max is session-only), and — critically for orchestration — a per-subagent effort frontmatter key (shipped per anthropics/claude-code issue #31536, CLOSED/COMPLETED).

2. Fast mode — API request field speed (standard|fast); fast enables high output-tokens-per-second inference. Pricing for Opus 4.8 fast mode is $10/$50 per MTok in/out vs $5/$25 standard. In Claude Code it is the interactive /fast toggle ONLY — there is no settings.json key, env var, or subagent-frontmatter mechanism to enable fast mode for a spawned subagent.

GSD already routes WHICH model runs a task (routingTier heavy/standard/light, model_profile quality/balanced/budget/adaptive/inherit, model_overrides, and dynamic_routing escalation). It had no way to control HOW HARD the model reasons or WHICH speed tier it uses.

The "flavor text" problem: issue #2517

Issue #2517 added resolveReasoningEffortInternal and made query resolve-model emit a reasoning_effort field derived from the Codex runtime's per-tier catalog values (model-catalog.json runtimeTierDefaults.codex.*.reasoning_effort). However, a codebase audit found that NO orchestrator, workflow, or agent ever consumes that emitted field — it is never passed to an actual Codex invocation. The resolver computed a value and a test asserted the computed JSON, but the value reached no runtime. The feature was inert ("flavor text, no code"): asserting a resolver's return value is not the same as asserting the control reaches the model.

Cross-provider effort enum mismatch

The two providers' effort enums are NOT identical:

- Anthropic/Claude (Opus 4.8): low, medium, high, xhigh, max (has max; no minimal)
- OpenAI/Codex (
model_reasoning_effort / Responses API reasoning.effort; SDK ReasoningEffort ranks none=0, minimal=1, low=2, medium=3, high=4, xhigh=5): minimal, low, medium, high, xhigh (has minimal; no max)

Common core: low, medium, high, xhigh.

Decision

1. Introduce a single universal effort config knob (and an orthogonal fast_mode knob) that compose with model selection rather than replace it. Resolution precedence mirrors the existing model cascade: (1) orchestrator invocation override, (2) effort.agent_overrides[agent], (3) effort.routing_tier_defaults[routingTier], (4) effort.default, (5) built-in default high. Same cascade for fast_mode with built-in default false. Invalid enum values at any level are ignored and fall through (mirrors the VALID_TIERS gate in resolveModelInternal) so a typo never silently breaks resolution.

2. The universal effort value is provider-agnostic; a per-runtime renderer maps it to each runtime's wire parameter, clamping the genuinely-unique tail levels:

- Claude / API: param output_config.effort (Claude Code: subagent effort frontmatter / CLAUDE_CODE_EFFORT_LEVEL env). minimal clamps to low (Claude has no minimal); low/medium/high/xhigh/max pass through.
- Codex: param
model_reasoning_effort (Responses API reasoning.effort). max clamps to xhigh (Codex has no max); minimal/low/medium/high/xhigh pass through.

| Universal level | Claude rendering | Codex rendering |
| --- | --- | --- |
|
minimal | low (clamped) | minimal |
|
low | low | low |
|
medium | medium | medium |
|
high (default) | high | high |
|
xhigh | xhigh | xhigh |
|
max | max | xhigh (clamped) |

3. Fold the inert reasoning_effort output into this unified model. query resolve-model is preserved for back-compat; a NEW query resolve-execution is the superset that emits: model, effort (universal), the per-runtime rendered effort, the wire param name, the propagation channel, fast_mode, and fast_mode_supported. Each config key ships help text naming exactly which runtime field/invocation it drives.

4. Make effort actually reach the runtime (close the flavor-text gap). Claude is first-class: the resolved effort propagates to spawned subagents via the effort frontmatter / CLAUDE_CODE_EFFORT_LEVEL env. Tests assert end-to-end propagation, not just resolver return values.

5. Fast mode honesty: because Claude Code has no per-subagent fast-mode mechanism, fast_mode is resolved and surfaced (with a fast_mode_supported flag, false for the claude runtime's subagents) but is NEVER emitted as a fake frontmatter key — doing so would be a silent no-op. It propagates only where the runtime supports it (API speed:"fast").

6. Dynamic-routing integration is additive: a new effort-escalation path (effort steps up the ladder on a failed attempt BEFORE model-tier escalation) is gated on the same dynamic_routing.enabled / escalate_on_failure switches and does NOT modify resolveModelForTier (so existing feat-3024 behavior is unchanged).

Consequences

Positive

- One coherent effort policy across all runtimes; Claude effort is first-class and actually wired.
- The dead
reasoning_effort field becomes meaningful; finer-grained cost/quality control (a light-tier scanning agent can run low effort; a heavy planning agent xhigh) without changing model class.
- Effort-first escalation reduces unnecessary model upgrades.
- Cross-provider clamping is explicit and documented.

Negative

- The universal enum is the union of two providers' ladders, so two levels (max, minimal) are runtime-specific and clamp when rendered to the other provider — users must understand the mapping (mitigated by help text and the table above).
- Fast mode remains asymmetric: it cannot be forced per-subagent on Claude Code, only at session level or on API-direct runtimes.
- Updating issue-2517's tests to assert real wiring is a deliberate behavior/contract change (the old "null on claude" assertion encoded the now-false premise that Claude has no effort control).

Alternatives Considered

(a) Global effort env override (e.g. a single CLAUDE_CODE_EFFORT_LEVEL for the whole session) — rejected: caps cost but starves heavy agents that legitimately need deep reasoning; static global breaks the per-tier design.

(b) Model selection alone (status quo) — rejected: choosing Haiku for light tasks reduces cost, but within one model class there is no way to tune reasoning depth; a quality profile pays full reasoning cost even for scanning.

(c) Static per-agent effort only — rejected: loses context sensitivity; the same agent doing trivial vs complex work should not always get the same effort.

(d) A separate effort field kept fully parallel to Codex's existing reasoning_effort (two independent lanes) — rejected: produces two overlapping fields that can diverge and confuse; Codex's reasoning_effort is better modeled as one rendering of the single universal effort.

(e) Overloading the existing reasoning_effort field to also carry Claude effort — rejected: it would conflate a Codex-specific wire name with the universal concept and break the clean per-runtime rendering.

References

- Tracking issue: #443
- Prior art (inert reasoning_effort): #2517;
tests/issue-2517-runtime-aware-profiles.test.cjs
- dynamic_routing escalation: #3024;
tests/model-profiles.test.cjs (folds former feat-3024-dynamic-routing, consolidation epic #1969)
- phase-type tiers: #3023
- Anthropic effort API:
output_config.effort (low/medium/high/xhigh/max); fast mode: speed (standard/fast)
- Claude Code effort:
/effort, --effort, CLAUDE_CODE_EFFORT_LEVEL, effortLevel setting, subagent effort frontmatter (anthropics/claude-code #31536, completed); fast mode: /fast (interactive only)
- OpenAI Codex effort:
model_reasoning_effort config key; Responses API reasoning.effort; ReasoningEffort enum none<minimal<low<medium<high<xhigh`

---