diff --git a/.changeset/patient-dogs-sprint.md b/.changeset/patient-dogs-sprint.md
new file mode 100644
index 000000000..00690c723
--- /dev/null
+++ b/.changeset/patient-dogs-sprint.md
@@ -0,0 +1,5 @@
+---
+type: Fixed
+pr: 3325
+---
+**Known-defect warnings that lived only in docs are now enforced checks** — six failure modes that `CONTEXT.md` merely described are now caught automatically, including unbounded subprocesses that could hang a run indefinitely and an unscoped frontmatter read that could pick up a body line. Writing the checks surfaced nine live instances, all fixed. (#2896)
diff --git a/.github/workflows/default-flip-documentation.yml b/.github/workflows/default-flip-documentation.yml
new file mode 100644
index 000000000..f572ee967
--- /dev/null
+++ b/.github/workflows/default-flip-documentation.yml
@@ -0,0 +1,42 @@
+name: Default Flip Documentation
+
+# DEFECT.DEFAULT-FLIP-DOCUMENTATION (CONTEXT.md): a PR that changes an
+# existing default value in gsd-core/bin/shared/config-defaults.manifest.json
+# must document the migration semantics in a `## Breaking Changes` PR-body
+# section (when the new default takes effect, the opt-back-in command,
+# effect on in-flight artifacts) — see scripts/lint-default-flip-documentation.cjs
+# for the full rationale and scope notes.
+
+on:
+ pull_request:
+ types: [opened, synchronize, reopened, edited]
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+ pull-requests: read
+
+jobs:
+ default-flip-documentation:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
+ with:
+ # Intentionally shallow — see tests/policy-lint-shallow-checkout.test.cjs.
+ fetch-depth: 50
+ - name: Fetch base ref for diff
+ # git show origin/: needs the base ref present locally;
+ # the shallow checkout above only guarantees PR-branch ancestry.
+ run: git fetch origin "${BASE_REF}:refs/remotes/origin/${BASE_REF}"
+ env:
+ BASE_REF: ${{ github.event.pull_request.base.ref }}
+ - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
+ with:
+ node-version: '24'
+ - name: Check default-flip documentation
+ env:
+ GITHUB_BASE_REF: ${{ github.base_ref }}
+ run: node scripts/lint-default-flip-documentation.cjs
diff --git a/.github/workflows/version-gate.yml b/.github/workflows/version-gate.yml
index 44cf4ebf4..e093550a4 100644
--- a/.github/workflows/version-gate.yml
+++ b/.github/workflows/version-gate.yml
@@ -3,9 +3,13 @@ name: Issue Version Gate
on:
issues:
types: [opened]
+ pull_request:
+ types: [opened, reopened, synchronize, edited]
+ branches:
+ - main
concurrency:
- group: ${{ github.workflow }}-${{ github.event.issue.number }}
+ group: ${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number }}
cancel-in-progress: false
permissions:
@@ -14,6 +18,7 @@ permissions:
jobs:
gate:
+ if: github.event_name == 'issues'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -45,3 +50,17 @@ jobs:
owner, repo, issue_number: issue.number,
state: 'closed', state_reason: 'not_planned',
});
+
+ # DEFECT.CANARY-VERSION-LEAK: package.json .version must never carry a
+ # -canary. suffix on main — that suffix is a dev-branch marker. Explicit
+ # base-branch condition below (not just the trigger's `branches: [main]`
+ # filter) so the gate is unambiguous even if the trigger is ever widened.
+ canary-version-leak:
+ if: github.event_name == 'pull_request' && github.event.pull_request.base.ref == 'main'
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - name: Reject a -canary. version landing on main
+ run: node scripts/lint-canary-version-leak.cjs
diff --git a/CONTEXT.md b/CONTEXT.md
index 02370623e..eb68d7d64 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -21,7 +21,7 @@ Module owning the pure phase-id parsing and matching helpers: phase-name normali
Module owning phase create, rename, complete, remove, list, and plan-index operations, plus phase-dir prefix validation, STATE.md staleness detection, and auto-prune behaviour. Entry point: `gsd-core/bin/lib/phase.cjs` (CJS surface). Typed phase events: `GSDPhaseStartEvent`, `GSDPhaseStepStartEvent`, `GSDPhaseStepCompleteEvent`, `GSDPhaseCompleteEvent`. (The SDK native-query surface, the `types.ts` event definitions, `phase-runner.ts`, and `phase-prompt.ts` were retired with the SDK package per ADR-0174.)
### Phase Estimation Module
-Module owning phase-effort estimation and its calibration against measured reality (ADR-2629, epic #1952). Pure — no I/O, no config reads; the CLI seam (`src/estimate-cli.cts`, verbs `estimate-check` / `estimate-calibration`) owns reading `.planning/config.json` and `.planning/estimation-calibration.json`. Interface: `parseEstimate`/`renderEstimate` (the PLAN.md `estimate: {tokens, tasks, confidence}` block), `parseActuals`/`renderActuals` (the SUMMARY.md `actuals: {tokens, tasks, commits}` block), `deriveConfidence(sampleCount) → low|med|high`, `classifyAgainstBudget(estimate, budget) → {overBudget, ratio, recommendation, budgetValid}`, `computeCalibration(samples) → {factor, sampleCount, applied, confidence, clamped}`, `applyCalibration`, `parseCalibrationDocument`/`renderCalibrationDocument`, `extractFrontmatterBlock` (leading-`---`-anchored scalar-block reader; hand-rolled because core ships no external deps), `calibrationBasis` (returns `estimate.raw_tokens` when present, else `tokens` — calibration must measure actual/raw or the loop un-corrects itself), and `measureTokens` (a re-export of `prompt-budget`'s `estimateTokens`). **Domain terms: _raw_ vs _calibrated_ tokens** — the same token count in two mutually incompatible states, carried by the compile-time brands `RawTokens` (the planner's uncorrected projection, and the only legal calibration denominator) and `CalibratedTokens` (the projection with the project's factor applied, and the only figure meaningful against the budget), constructed at trust boundaries via `asRawTokens` / `asCalibratedTokens` (#2671). The brands erase at compile time — the emitted `.cjs`, the CLI JSON, and both frontmatter schemas are unchanged — and exist because mixing the two states was NOT catchable at runtime: both are positive integers of the same magnitude, and the mix-up shipped twice past a green ~26,800-test suite (#2631 factor², #2632 self-defeating loop). `asRawTokens` refuses a `CalibratedTokens` by design; the single legitimate crossover (a pre-#2632 plan whose `tokens` IS the raw projection) lives behind one commented assertion in `calibrationBasis`. Compile fixtures: `tests/fixtures/brand-typing/`. **_smart zone_** — the usable prefix of a model's context window before output quality degrades, expressed as the configurable `workflow.smart_zone_tokens` budget (default 100000, a *policy default* rather than a benchmark constant since the effective ceiling is model/task-dependent); **_estimate/actuals_** — a projected phase cost recorded at plan time and the measured cost recorded at completion, both on the **same `estimateTokens` scale** so their ratio measures the miss rather than a difference between two measurement methods. Two invariants: (1) every signal is **exogenous** — the correction routes on a measured actual/estimate ratio and `confidence` routes on a calibration sample count, never on a model's self-assessment (this project measured self-rated confidence and found it weak — `gsd-core/references/honest-verifier.md:25-29`; see `.out-of-scope/general-purpose-agent-prompt-skills.md`); (2) the over-budget flag is **advisory** — a warning plus a split recommendation, never a block. Calibration is median-of-ratios, clamped to `[0.5, 3.0]`, and inert below 3 samples. CLI seam verbs: `estimate-check` (classify one figure; `--calibrated` when the input already has the factor applied — omitting it squares the correction), `estimate-calibration` (report the current factor), `estimate-calibrate` (#2632 — pair every completed phase's PLAN `estimate` with its SUMMARY `actuals`, rebuild `.planning/estimation-calibration.json` idempotently, and report the result; this is what closes the loop). Source of truth: `gsd-core/bin/lib/phase-estimation.cjs` (generated from `src/phase-estimation.cts`) and `src/estimate-cli.cts`. Test anchors: `tests/phase-estimation.test.cjs`, `tests/estimate-calibrate.test.cjs`.
+Module owning phase-effort estimation and its calibration against measured reality (ADR-2629, epic #1952). Pure — no I/O, no config reads; the CLI seam (`src/estimate-cli.cts`, verbs `estimate-check` / `estimate-calibration`) owns reading `.planning/config.json` and `.planning/estimation-calibration.json`. Interface: `parseEstimate`/`renderEstimate` (the PLAN.md `estimate: {tokens, tasks, confidence}` block), `parseActuals`/`renderActuals` (the SUMMARY.md `actuals: {tokens, tasks, commits}` block), `deriveConfidence(sampleCount) → low|med|high`, `classifyAgainstBudget(estimate, budget) → {overBudget, ratio, recommendation, budgetValid}`, `computeCalibration(samples) → {factor, sampleCount, applied, confidence, clamped}`, `applyCalibration`, `parseCalibrationDocument`/`renderCalibrationDocument`, `extractFrontmatterBlock` (leading-`---`-anchored scalar-block reader; hand-rolled because core ships no external deps), `calibrationBasis` (returns `estimate.raw_tokens` when present, else `tokens` — calibration must measure actual/raw or the loop un-corrects itself), and `measureTokens` (a re-export of `prompt-budget`'s `estimateTokens`). **Domain terms: _raw_ vs _calibrated_ tokens** — the same token count in two mutually incompatible states, carried by the compile-time brands `RawTokens` (the planner's uncorrected projection, and the only legal calibration denominator) and `CalibratedTokens` (the projection with the project's factor applied, and the only figure meaningful against the budget), constructed at trust boundaries via `asRawTokens` / `asCalibratedTokens` (#2671). The brands erase at compile time — the emitted `.cjs`, the CLI JSON, and both frontmatter schemas are unchanged — and exist because mixing the two states was NOT catchable at runtime: both are positive integers of the same magnitude, and the mix-up shipped twice past a green ~26,800-test suite (#2631 factor², #2632 self-defeating loop). `asRawTokens` refuses a `CalibratedTokens` by design; the single legitimate crossover (a pre-#2632 plan whose `tokens` IS the raw projection) lives behind one commented assertion in `calibrationBasis`. Compile fixtures: `tests/fixtures/brand-typing/`. **_smart zone_** — the usable prefix of a model's context window before output quality degrades, expressed as the configurable `workflow.smart_zone_tokens` budget (default 100000, a *policy default* rather than a benchmark constant since the effective ceiling is model/task-dependent); **_estimate/actuals_** — a projected phase cost recorded at plan time and the measured cost recorded at completion, both on the **same `estimateTokens` scale** so their ratio measures the miss rather than a difference between two measurement methods. Two invariants: (1) every signal is **exogenous** — the correction routes on a measured actual/estimate ratio and `confidence` routes on a calibration sample count, never on a model's self-assessment (this project measured self-rated confidence and found it weak — `gsd-core/references/honest-verifier.md:25-29`; see `.out-of-scope/general-purpose-agent-prompt-skills.md`); (2) the over-budget flag is **advisory** — a warning plus a split recommendation, never a block. Calibration is median-of-ratios, clamped to `[0.5, 3.0]`, and inert below 3 samples. CLI seam verbs: `estimate-check` (classify one figure; `--calibrated` when the input already has the factor applied — omitting it squares the correction), `estimate-calibration` (report the current factor), `estimate-calibrate` (#2632 — pair every completed phase's PLAN `estimate` with its SUMMARY `actuals`, rebuild `.planning/estimation-calibration.json` idempotently, and report the result; this is what closes the loop). Source of truth: `gsd-core/bin/lib/phase-estimation.cjs` and `src/estimate-cli.cts`. Test anchors: `tests/phase-estimation.test.cjs`, `tests/estimate-calibrate.test.cjs`.
### Verification Module
Module owning the canonical phase-verification status projection shared by phase transition, progress, manager, autonomous, and closeout readiness paths. `readVerificationStatus(phaseDir, opts?)` reads the first `*-VERIFICATION.md` frontmatter `status`, maps it through `VERIFICATION_ROUTING_TABLE`, and fail-closes — only `{passed}` satisfies the canonical gate; `missing`/`unknown`/`gaps_found`/`human_needed`/`stale` all route away from "complete" (#1522). `findStaleVerificationSummary` flags a SUMMARY newer than the VERIFICATION file (status `stale`). Both honor a no-throw, degrade-to-safe contract (any FS error → `missing` / not-stale) and an injectable `opts.fs` seam. `isPhaseComplete(phaseDir, deps?)` is the single canonical owner of "is phase P complete?" (ADR-3180 §7.4, issue #3186, disk-strict per #2957): it wraps `readVerificationStatus`, calling it UNCONDITIONALLY — plan count is never a precondition, so a zero-plan phase with a passing `*-VERIFICATION.md` is complete (#3168) — and returns `{ value: { complete, verification }, scope }`; `complete` is exactly `verification.status === 'passed'`. A ROADMAP checkbox carries no machine authority and is never consulted. `cmdPhaseComplete`, `buildPhaseCompletionProjection`, and `buildStateFrontmatter` all route through it. Source of truth: `gsd-core/bin/lib/verification.cjs` (generated from `src/verification.cts`).
@@ -120,7 +120,7 @@ Module owning project-root resolution from any starting directory. Walks the anc
Module owning projection from project/workstream context to concrete `.planning` paths. Policy precedence is `explicit workstream > env workstream > env project > root`. Invalid workspace context is a validation error at this seam rather than a silent fallback.
### Reviewer Lane Descriptor Module
-Module owning the single declared contract for a **reviewer lane** — one external CLI or model endpoint that `/gsd:review` hands a plan to for independent review (ADR-2782 Phase 1, #2794; subsumes #2690). Before it, a lane was declared across three unrelated surfaces — the roster in `src/review-reviewer-selection.cts`, ~640 lines of hand-authored per-CLI bash in the `invoke_reviewers` step of `gsd-core/workflows/review.md`, and the hardcoded section headings in `write_reviews` — so cross-cutting fixes landed per-leg (#2494 and #2605 were the same empty-output defect filed twice; #2475/#2295/#2272 are the same shape). Interface: `REVIEWER_LANES` (frozen table of the 11 shipped lanes, each declaring `slug`, `flags`, `probe`, `invoke`, `timeoutFloorMs`, `emptyOutput`, `reviewsSection`, `evidenceClass`, `requiresBinaries`, `promptBudgetKey`, `handler`), `PARITY_VIOLATION` (frozen reason enum — adding a reason is three coordinated changes: enum + emitting site + the test locking `Object.keys(...).sort()`), and `checkReviewerLaneParity({descriptor, roster, workflowText}) → {ok, violations[]}`. **The module DECLARES; it does not execute** — `invoke_reviewers` still runs hand-authored legs until Phase 5b (#2799) makes it iterate; what Phase 1 guarantees is that a leg cannot be added, removed, or renamed without the table and the `REVIEWS.md` section moving with it. Field names, **nesting**, and enum members track ADR-2782 D1/D2/D6/D7 so Phase 2 (#2795) harvests the shape into the capability manifest with no translation layer — including `transport` at the **lane level** (a sibling of `probe`/`invoke`, as D1's manifest example places it) rather than nested inside `invoke`, which would read more naturally as a TS discriminated union and is exactly the convenience Phase 2 would have to translate away. **Four** vocabulary widenings were forced by surveying the eleven shipped legs, all additive widenings of closed enums, each forced by a lane that exists today — and **ADR-2782 was amended in the same PR** (its Amendments section, 2026-07-29) rather than left diverging, so Phase 2 implements the validator against the amended vocabulary: `promptChannel: 'none'` (CodeRabbit is fed no prompt — it reviews the working-tree diff), `outputChannel: 'file-arg'` (Codex captures the review through its own `-o/--output-last-message` and discards stdout, #1698), `outputArg` (its companion — knowing the review lands in a file is useless without the argument naming the file), and `flags: string[]` where D1 shows a singular `flag` (Antigravity is selected by both `--antigravity` and `--agy`, which one field cannot express; this also widens D8's uniqueness invariant, enforced here over the flattened flag set). `LANE_SLUG_RE` (`^[a-z0-9][a-z0-9_-]*$`) pins the slug grammar because `LEG_MARKER_RE` can only capture `[a-z0-9_-]`: a slug outside that class is **unmatchable** — its marker can be present and correct and the scan still never sees it — so a violating slug is reported `INVALID_SLUG` rather than reported missing forever. A loud named violation beats a silent miss. The descriptor deliberately does **not** promise uniformity — lane divergence is real and frequently correct (three lanes are HTTP endpoints with no binary; timeout floors genuinely differ; Antigravity needs a three-layer fallback for an upstream stdout bug) — so behavior data cannot express is delegated to a named `handler` (closed first-party enum: `null` | `antigravity` | `openai-compatible`), never to conditionals inside the table. `checkReviewerLaneParity` is the `DEFECT.GENERATIVE-FIX` assertion the roster has never had, and it is **bidirectional**: a forward-only check misses the failure it exists to catch (#2718 added a lane leg, #2781 was the drift that followed), so an undeclared leg fails too. Legs are identified by an explicit `` marker rather than inferred from prose shape, because five non-lane bold labels in `invoke_reviewers` share the bold-then-fence shape a heuristic would key on. Section matching is anchored at h2 with an exact ` Review` suffix and no parenthetical, so ADR-1517 reviewer-instance headings (`## OpenCode Review (opencode-deepseek)`) are exempt — ADR-2782 D8: instances are not lanes. Pure and total: no filesystem access, CRLF-insensitive, and **never throws on any input** — every field is validated before use (`MALFORMED_LANE` / `INVALID_SLUG`) rather than trusted, because Phase 2 feeds this same function manifest-derived data from third-party overlays, and a parity gate that crashes on bad input is indistinguishable from one that was never run. Empty input degrades to violations so a read failure is never mistaken for a clean bill of health. Totality, determinism (no leaked regex `lastIndex`), and the invalid-slug contract are `fast-check` property-tested with a pinned seed. Phase 6 (#2800) adds a SECOND, deliberately separate pure gate in the same module — `checkReviewerDocsParity({descriptor, docs}) → {ok, violations, skipped}` with its own frozen `DOCS_PARITY_VIOLATION` enum — answering *what is documented* rather than *what runs*, so a stale doc can never make the runtime checker look red and the seven dependents of `checkReviewerLaneParity` never move. It is the `DEFECT.GENERATIVE-FIX` parity assertion this roster had always required and never had (only the Cursor lane ever carried one). Three independent arms: declared flags appear delimited (backticked or bracketed, so a bare flag in a fenced example cannot satisfy the gate) in `docs/COMMANDS.md`, `docs/FEATURES.md` and their four locale mirrors; a `Command:` signature line is held to the full roster and rejects undeclared bracketed lane flags; and the Purpose paragraph beneath it must name every declared `reviewsSection` — the arm that catches the class where a `Command:` line is updated and the line below it is not. Section titles are matched LITERALLY (`llama.cpp` would otherwise let `llamaXcpp` pass). A mirror carrying no `/gsd:review` surface is reported in `skipped`, never failed, so a partial translation is not misread as drift. Its totality is property-tested too, which is how the non-callable-`toString` coercion crash was found before it shipped. Source of truth: `src/review-lane-descriptor.cts`. Test anchors: `tests/review-lane-descriptor.test.cjs`, `tests/reviewer-docs-parity.test.cjs`. See `docs/adr/2782-reviewer-lane-capability-surface.md`.
+Module owning the single declared contract for a **reviewer lane** — one external CLI or model endpoint that `/gsd:review` hands a plan to for independent review (ADR-2782 Phase 1, #2794; subsumes #2690). Before it, a lane was declared across three unrelated surfaces — the roster in `src/review-reviewer-selection.cts`, ~640 lines of hand-authored per-CLI bash in the `invoke_reviewers` step of `gsd-core/workflows/review.md`, and the hardcoded section headings in `write_reviews` — so cross-cutting fixes landed per-leg (#2494 and #2605 were the same empty-output defect filed twice; #2475/#2295/#2272 are the same shape). Interface: `REVIEWER_LANES` (frozen table of the 11 shipped lanes, each declaring `slug`, `flags`, `probe`, `invoke`, `timeoutFloorMs`, `emptyOutput`, `reviewsSection`, `evidenceClass`, `requiresBinaries`, `promptBudgetKey`, `handler`), `PARITY_VIOLATION` (frozen reason enum — adding a reason is three coordinated changes: enum + emitting site + the test locking `Object.keys(...).sort()`), and `checkReviewerLaneParity({descriptor, roster, workflowText}) → {ok, violations[]}`. **The module DECLARES; it does not execute** — `invoke_reviewers` still runs hand-authored legs until Phase 5b (#2799) makes it iterate; what Phase 1 guarantees is that a leg cannot be added, removed, or renamed without the table and the `REVIEWS.md` section moving with it. Field names, **nesting**, and enum members track ADR-2782 D1/D2/D6/D7 so Phase 2 (#2795) harvests the shape into the capability manifest with no translation layer — including `transport` at the **lane level** (a sibling of `probe`/`invoke`, as D1's manifest example places it) rather than nested inside `invoke`, which would read more naturally as a TS discriminated union and is exactly the convenience Phase 2 would have to translate away. **Four** vocabulary widenings were forced by surveying the eleven shipped legs, all additive widenings of closed enums, each forced by a lane that exists today — and **ADR-2782 was amended in the same PR** (its Amendments section, 2026-07-29) rather than left diverging, so Phase 2 implements the validator against the amended vocabulary: `promptChannel: 'none'` (CodeRabbit is fed no prompt — it reviews the working-tree diff), `outputChannel: 'file-arg'` (Codex captures the review through its own `-o/--output-last-message` and discards stdout, #1698), `outputArg` (its companion — knowing the review lands in a file is useless without the argument naming the file), and `flags: string[]` where D1 shows a singular `flag` (Antigravity is selected by both `--antigravity` and `--agy`, which one field cannot express; this also widens D8's uniqueness invariant, enforced here over the flattened flag set). `LANE_SLUG_RE` (`^[a-z0-9][a-z0-9_-]*$`) pins the slug grammar because `LEG_MARKER_RE` can only capture `[a-z0-9_-]`: a slug outside that class is **unmatchable** — its marker can be present and correct and the scan still never sees it — so a violating slug is reported `INVALID_SLUG` rather than reported missing forever. A loud named violation beats a silent miss. The descriptor deliberately does **not** promise uniformity — lane divergence is real and frequently correct (three lanes are HTTP endpoints with no binary; timeout floors genuinely differ; Antigravity needs a three-layer fallback for an upstream stdout bug) — so behavior data cannot express is delegated to a named `handler` (closed first-party enum: `null` | `antigravity` | `openai-compatible`), never to conditionals inside the table. `checkReviewerLaneParity` is the `RULESET.GENERATIVE-FIX` assertion the roster has never had, and it is **bidirectional**: a forward-only check misses the failure it exists to catch (#2718 added a lane leg, #2781 was the drift that followed), so an undeclared leg fails too. Legs are identified by an explicit `` marker rather than inferred from prose shape, because five non-lane bold labels in `invoke_reviewers` share the bold-then-fence shape a heuristic would key on. Section matching is anchored at h2 with an exact ` Review` suffix and no parenthetical, so ADR-1517 reviewer-instance headings (`## OpenCode Review (opencode-deepseek)`) are exempt — ADR-2782 D8: instances are not lanes. Pure and total: no filesystem access, CRLF-insensitive, and **never throws on any input** — every field is validated before use (`MALFORMED_LANE` / `INVALID_SLUG`) rather than trusted, because Phase 2 feeds this same function manifest-derived data from third-party overlays, and a parity gate that crashes on bad input is indistinguishable from one that was never run. Empty input degrades to violations so a read failure is never mistaken for a clean bill of health. Totality, determinism (no leaked regex `lastIndex`), and the invalid-slug contract are `fast-check` property-tested with a pinned seed. Phase 6 (#2800) adds a SECOND, deliberately separate pure gate in the same module — `checkReviewerDocsParity({descriptor, docs}) → {ok, violations, skipped}` with its own frozen `DOCS_PARITY_VIOLATION` enum — answering *what is documented* rather than *what runs*, so a stale doc can never make the runtime checker look red and the seven dependents of `checkReviewerLaneParity` never move. It is the `RULESET.GENERATIVE-FIX` parity assertion this roster had always required and never had (only the Cursor lane ever carried one). Three independent arms: declared flags appear delimited (backticked or bracketed, so a bare flag in a fenced example cannot satisfy the gate) in `docs/COMMANDS.md`, `docs/FEATURES.md` and their four locale mirrors; a `Command:` signature line is held to the full roster and rejects undeclared bracketed lane flags; and the Purpose paragraph beneath it must name every declared `reviewsSection` — the arm that catches the class where a `Command:` line is updated and the line below it is not. Section titles are matched LITERALLY (`llama.cpp` would otherwise let `llamaXcpp` pass). A mirror carrying no `/gsd:review` surface is reported in `skipped`, never failed, so a partial translation is not misread as drift. Its totality is property-tested too, which is how the non-callable-`toString` coercion crash was found before it shipped. Source of truth: `src/review-lane-descriptor.cts`. Test anchors: `tests/review-lane-descriptor.test.cjs`, `tests/reviewer-docs-parity.test.cjs`. See `docs/adr/2782-reviewer-lane-capability-surface.md`.
### Reviewer Lane Invocation Module
Module owning the projection from a **declared reviewer lane** plus resolved configuration to a concrete **invocation plan** — the value a lane is actually run from (ADR-2782 Phase 5b, #2799). Pure: no filesystem, network, subprocess or clock; configuration arrives through a `configGet` seam. Interface: `resolveLanePlan({lane, configGet, runDir, repoRoot, effortArgs}) → {ok, plan} | {ok:false, reason, detail}`, `LANE_UNAVAILABLE` (frozen reason enum — a lane that will not run reports WHY, because the ambiguity between "failed" and "ran cleanly with nothing to report" is the defect class this epic closes), plus `isEmptyReview`, `normalizeHost` and `fileRefPrompt`. TOTAL — a malformed lane yields an unavailable result, never a throw, because third-party overlay manifests reach this seam. `invoke.args` is an **argv template** over a closed four-member placeholder vocabulary (`{{model}}`, `{{effort}}`, `{{output}}`, `{{prompt}}`), not a prefix: the injected pieces do not all go in the same place — `codex` injects the model after its `exec` subcommand and the output file later still, while five lanes end with a bare `-` that must stay last. Each lane's plan was derived FROM its former bash leg, and a golden table asserts all twelve; that table is the strangler-fig substitute for a parallel run.
@@ -135,7 +135,7 @@ Cross-seam principle (ADR-1411, epic #1411): context resolution — config loadi
Diagnostic-output convention for the Resolution Provenance principle (ADR-1411 P3, #1416). Config-interpreting read verbs expose `Resolution { value, configured, reason, warnings }` (`src/resolution.cts`); agent-skills is the first adopter, where `value = { block, skills_count }` and `source`/`degraded` remain config-provenance extras outside the envelope. Other read verbs expose at least `warnings[]` (e.g. capability-state `{ runtimeConfigDir, capabilities, warnings? }`) without `configured`/`reason`, which are meaningful only for config-interpreting verbs. Mutation verbs expose `warnings[]` (advisory) PLUS `errors[]` (operation-not-applied), e.g. capability-writer `{ capabilities, warnings, errors }`. The shared seam across all shapes is `warnings: string[]`; a single generic `Resolution` across read+write verbs was rejected by the deletion test (`configured`/`reason` are meaningless for capability verbs; `errors[]` cannot fold into `warnings[]`) — ADR-1411 P3 amendment. Recurrence prevention is delivered by P4's CI guard (a configured input resolving empty must carry a `reason`), not by a shared envelope. A CI guard (`scripts/lint-resolution-provenance.cjs`, wired into `lint:ci`) enforces that every registered config-interpreting read verb keeps a `configured_empty`/`not_configured` contract test; the registry in that script is the registration point for future verbs (ADR-1411 P4 / #1417).
### Unusable Input Diagnostic Module
-Leaf module owning the **out-of-band** half of ADR-1411's "corrupt is not absent" amendment (epic #1879). Where a read already returns a provenance envelope the cause is named in-band (`ConfigResolution.reason`, #1880); where a read returns a bare sentinel or a plausible default it cannot extend, the return value is preserved exactly and the cause is surfaced here instead. Interface: `UNUSABLE_REASON` (frozen reason enum — one entry per condition that has an emitting call site; adding a reason is three coordinated changes: enum + call site + the test locking `Object.keys(...).sort()`), `warnUnusableInput({reason, source?, content?}) → boolean` (returns whether this call actually wrote, so tests assert emission *counts* on a typed surface rather than scraping stderr), plus the `_resetUnusableInputWarningsForTests` / `_unusableInputWarningCountForTests` seams. Dedup key is `\0` — **both halves are load-bearing**: keying on the path alone would let a second, different fault on the same file go unreported, and keying on message prose would couple the guard to wording (ADR-1411 dedup clause). Path separators are deliberately **not** normalized: an earlier revision folded backslashes to `/` so two spellings of one Windows path would not double-report, but a backslash is a legal filename character on Linux and macOS, so that folding collapsed two genuinely distinct POSIX files onto one key and swallowed the second file's diagnostic. The trade is now one-directional — two spellings of one Windows path may report twice (noise), but two distinct files can never silence each other (lost signal), and ADR-1411 ranks the swallow the worse failure; ASCII control characters are stripped from the source before it is keyed or written, because the key separator is NUL (a crafted path could otherwise forge a collision) and because a path carrying ANSI escapes would replay into the operator's terminal. Callers with no path (in-memory content) fall back to a short content digest so *different* bad inputs still key differently. The diagnostic is **unconditional** — a deliberate divergence from ADR-227's never-implemented `GSD_DEBUG` opt-in, since "an opt-in nobody sets is indistinguishable from the silence #1879 is about" — and **never throws**: a failed stderr write is swallowed so a degraded read is never escalated into a crash. Adopted by `extractFrontmatter` (#1882, `frontmatter_unterminated`) and by `getRoadmapPhaseInternal`/`getMilestoneInfo` (#1881, `roadmap_unreadable`); `planning-workspace`/`verify` (#1883) follow. #1881 detects on the errno alone: `platformReadSync` returns `null` for ENOENT and its callers convert that to an errno-less Error, so reporting unconditionally in those catches would flag every project without a ROADMAP.md as corrupt. Exists as a shared seam rather than a per-site copy because four sites need identical behavior and four hand-rolled copies is `DEFECT.GENERATIVE-FIX` by construction. Source of truth: `gsd-core/bin/lib/unusable-input.cjs` (generated from `src/unusable-input.cts`). Test anchor: `tests/unusable-input.test.cjs`. See Resolution Provenance, Config Loader Module.
+Leaf module owning the **out-of-band** half of ADR-1411's "corrupt is not absent" amendment (epic #1879). Where a read already returns a provenance envelope the cause is named in-band (`ConfigResolution.reason`, #1880); where a read returns a bare sentinel or a plausible default it cannot extend, the return value is preserved exactly and the cause is surfaced here instead. Interface: `UNUSABLE_REASON` (frozen reason enum — one entry per condition that has an emitting call site; adding a reason is three coordinated changes: enum + call site + the test locking `Object.keys(...).sort()`), `warnUnusableInput({reason, source?, content?}) → boolean` (returns whether this call actually wrote, so tests assert emission *counts* on a typed surface rather than scraping stderr), plus the `_resetUnusableInputWarningsForTests` / `_unusableInputWarningCountForTests` seams. Dedup key is `\0` — **both halves are load-bearing**: keying on the path alone would let a second, different fault on the same file go unreported, and keying on message prose would couple the guard to wording (ADR-1411 dedup clause). Path separators are deliberately **not** normalized: an earlier revision folded backslashes to `/` so two spellings of one Windows path would not double-report, but a backslash is a legal filename character on Linux and macOS, so that folding collapsed two genuinely distinct POSIX files onto one key and swallowed the second file's diagnostic. The trade is now one-directional — two spellings of one Windows path may report twice (noise), but two distinct files can never silence each other (lost signal), and ADR-1411 ranks the swallow the worse failure; ASCII control characters are stripped from the source before it is keyed or written, because the key separator is NUL (a crafted path could otherwise forge a collision) and because a path carrying ANSI escapes would replay into the operator's terminal. Callers with no path (in-memory content) fall back to a short content digest so *different* bad inputs still key differently. The diagnostic is **unconditional** — a deliberate divergence from ADR-227's never-implemented `GSD_DEBUG` opt-in, since "an opt-in nobody sets is indistinguishable from the silence #1879 is about" — and **never throws**: a failed stderr write is swallowed so a degraded read is never escalated into a crash. Adopted by `extractFrontmatter` (#1882, `frontmatter_unterminated`) and by `getRoadmapPhaseInternal`/`getMilestoneInfo` (#1881, `roadmap_unreadable`); `planning-workspace`/`verify` (#1883) follow. #1881 detects on the errno alone: `platformReadSync` returns `null` for ENOENT and its callers convert that to an errno-less Error, so reporting unconditionally in those catches would flag every project without a ROADMAP.md as corrupt. Exists as a shared seam rather than a per-site copy because four sites need identical behavior and four hand-rolled copies is `RULESET.GENERATIVE-FIX` by construction. Source of truth: `gsd-core/bin/lib/unusable-input.cjs` (generated from `src/unusable-input.cts`). Test anchor: `tests/unusable-input.test.cjs`. See Resolution Provenance, Config Loader Module.
### Worktree Safety Policy Module
CJS Module owning worktree lifecycle safety policy for the GSD orchestration layer. Interface: `resolveWorktreeContext(cwd, deps) → WorktreeContext` (linked-worktree root mapping), `parseWorktreePorcelain(output) → WorktreeEntry[]` (porcelain parser, skips detached HEAD), `planWorktreePrune(repoRoot, opts, deps) → PrunePlan` (metadata-prune plan, never destructive by default), `executeWorktreePrunePlan(plan, deps) → PruneResult` (executes prune; degrades gracefully on git timeout), `listLinkedWorktreePaths(repoRoot, deps) → LinkedPathsResult`, `inspectWorktreeHealth(repoRoot, opts, deps) → HealthResult` (orphan + stale detection), `snapshotWorktreeInventory(repoRoot, opts, deps) → InventoryResult`, `planWorktreeWaveCleanup(repoRoot, manifest) → CleanupPlan` (manifest-scoped, fail-closed), `executeWorktreeWaveCleanupPlan(plan, deps) → CleanupResult` (per-entry gauntlet: branch → base → deletions → **advisory scope conformance (#2596)** → SUMMARY-rescue → clean-worktree → merge → remove; the scope check compares the branch's committed diff against the entry's declared `files_modified` and appends `WAVE_CLEANUP_WARNING`-coded entries to a `warnings` channel WITHOUT touching `ok` — an advisory, not a gate, and skipped entirely with no git call when no scope was declared), `planWaveScopeConformance(changedPaths, declaredFiles, branch) → WaveCleanupWarning[]` (pure; literal-prefix path coverage deliberately mirroring the submodule-intersection gate's glob-prefix rule rather than introducing a second matcher; over-accepts by design because a false alarm costs an advisory more than a miss), `isSummaryArtifactRelPath(relPath) → boolean` (the single definition of "executor-written SUMMARY artifact", shared with `defaultFindSummaryFiles` so the rescue walker and the scope exemption cannot drift), `WAVE_CLEANUP_WARNING` (frozen advisory-code enum: `scope_out_of_declared`, `scope_check_unavailable`), `planWorktreeRecordAgent(manifestRaw, fields) → RecordAgentPlan` (write-strict per-agent manifest append; validates each field at write time via the same `normalizeCleanupManifestEntry` rules the reader enforces; fail-closed on a missing/garbled field or a duplicate `(worktree_path, branch)` the reader would dedup away), `cmdWorktreeRecordAgent(cwd, args, deps) → RecordAgentCmdResult` (thin deps-injectable IO wrapper for the `worktree record-agent` verb), `planWorktreeCreate(fields) → WorktreeCreatePlan` (write-strict `worktree create` planner — same missing-field-hint and `normalizeCleanupManifestEntry` validation as `planWorktreeRecordAgent`, pure/no-git), `executeWorktreeCreatePlan(plan, repoRoot, deps) → WorktreeCreateResult` (bounded `git rev-parse --verify` base check THEN `git worktree add -b `; fail-closed `base_unresolved`/`git_timeout`/`worktree_add_failed`; returns `cwd` — the working directory an executor spawn would use), `cmdWorktreeCreate(cwd, args, deps) → WorktreeCreateCmdResult` (CLI verb: requires `--root` — confinement is mandatory, not opt-in; omitting it fails closed with `reason:'root_required'` before any git side effect, rather than silently creating an unconfined worktree (#3050); plans, creates the worktree, then appends the manifest entry so it is immediately manageable by cleanup-wave/reap-orphans; dedupes by `(worktree_path, branch)`). #2584 ADR-1239 Codex-binding amendment, Phase 2: `worktree create` is the git-worktree-creation primitive for `dispatch.isolation: orchestrator-worktree` hosts — declared and testable but UNCONSUMED (no scheduler calls it yet; Phase 3 wires it). `worktree record-agent` / `worktree create` accept an optional `--files` recording the plan's declared scope, consumed by the advisory scope-conformance check above; a blank or omitted value leaves the 4-field on-disk entry shape unchanged. Source of truth: `gsd-core/bin/lib/worktree-safety.cjs`. Timeout path: all git subprocess calls are bounded; callers receive `ok:false, reason:'git_timed_out'` rather than a thrown exception. Test anchor: `tests/worktree-safety.test.cjs`. The `core.cjs` re-export spine was retired in epic #1267: this module absorbed the two thin compositional wrappers that squatted in Core — `resolveWorktreeRoot(cwd, deps) → {root, reason}` (a projection over `resolveWorktreeContext`; returns the `reason` alongside `root` — a `git_timed_out` reason means `root` is a best-effort cwd fallback, not a confirmed resolution, and callers must surface that risk rather than trust it silently, #3050) and `pruneOrphanedWorktrees(...)` (sequences `planWorktreePrune` + `executeWorktreePrunePlan` with a timeout warning) — so callers reach this single worktree-lifecycle seam directly. `gitWorktreeInfoInternal` did NOT move here — worktree-info detection belongs to the Git Query Module.
@@ -180,13 +180,13 @@ Primary installer for all runtimes. Single production file: `bin/install.js` (ha
Module owning the tool's CLI I/O primitives: `output()` result emission (with large-payload temp-file spillover via `GSD_TEMP_DIR`/`ensureGsdTempDir`/`reapStaleTempFiles`), `error()` stderr emission with exit-code mapping, and the JSON-error-mode toggle (`setJsonErrorMode`/`getJsonErrorMode`, `ERROR_REASON`). **Degraded result vs fault (ADR-2980, #2980):** the two emitters are a deliberate two-channel failure contract, not a drift. A **fault** is `error(message, reason)` — stderr, exit **1**, structured `{ok:false,reason,message}` envelope under `--json-errors`. A **degraded result** is `output({ error: … })` — stdout, exit **0**, `--json-errors` does not apply — and means the command ran to completion and is reporting a condition (absent artifact, and in practice also missing-argument and unusable-input cases) through its result; a caller detects it by inspecting the payload, never by exit code. Ratified across **60 sites in 9 modules** (`state` 25, `verify` 8, `workstream` 7, `frontmatter` 6, `commands` 5, `template` 3, `gsd2-import` 2, `phase` 2, `roadmap` 2) because normalizing them to exit 1 is a Hyrum's Law break over a CRITICAL radius (`get_impact(cmdStateSnapshot)`; `output` has 170 direct callers). #2966/#2980 record "42 sites" — that counts only literals whose FIRST key is `error` (the `output\(\{\s*error:` regex); 18 more put another key first (`{found:false, error}`) and are identical in contract, so 60 is the population and 42 is a subset. New code prefers the fault path or a named-field result (`{updated:false, reason}`), not a 61st site. Known cost carried by the decision: the exit code does not distinguish absent from unusable, which is ADR-1411's "corrupt is not absent" open edge. Docs: `docs/json-errors.md` → "Degraded results vs faults". Extracted from the Core module per ADR-857 rollout phase 1 (#859) so feature modules (`graphify`, `intel`, `audit`, `profile-pipeline`) depend on a small I/O seam instead of the core god-module; the `core.cjs` re-export spine was retired in epic #1267, so callers import this leaf directly. Source of truth: `gsd-core/bin/lib/io.cjs` (generated from `src/io.cts`).
### Markdown Sectionizer
-Canonical markdown-structure parsing seam (`gsd-core/bin/lib/markdown-sectionizer.cjs`, generated from `src/markdown-sectionizer.cts`). Pure functions, Node built-ins only. Exports: `stripFencedCode(content) → { text, unterminatedFence }` (CommonMark-correct state machine, CRLF-safe, signals unterminated fences); `stripInlineCode(content) → string` (per-line CommonMark inline-code-span stripper — removes `` `code` `` spans while leaving fenced blocks to `stripFencedCode`; #2365); `scanInlineCodeSpans(content) → InlineCodeSpan[]` (locates every inline code span as `{ start, end, content }`, offsets into the full string and spans never crossing a `\n`; callers that need the span CONTENT (e.g. api-coverage's package-name evidence, #2365) use this, callers that just want spans gone use `stripInlineCode`); `tokenizeHeadings(content) → HeadingToken[]` (ATX headings outside fenced blocks, `{ level, text, line, offset }`); `collectSections(content, stopPredicate) → Section[]` (line-by-line section collection driven by a heading predicate); `collectSection(content, headingPredicate, { levelBounded, stripFences }) → Section | null` (single named section with level-bounded stop); `iterateBullets(sectionText) → BulletItem[]` (dash/checkbox/numbered markers with indented continuation); `extractTaggedBlocks(content, tagName) → string[]` (inner text of every `…` block in document order, tagName regex-escaped, caller decides fence-stripping — generalises `decisions.cts`'s bespoke extractor for T1); `replaceSection(content, section, newBody) → string` (pure character-offset splice using `Section.bodyStart`/`bodyEnd` for read-modify-write callers — eliminates T6 `state.cts`'s 7× inline `content.replace` pattern); `withSection(content, target, edit) → string` (resolve the section whose heading matches `target` — exact heading text or a `HeadingToken` predicate — and run `edit(body)` against ONLY that section's body before splicing the result back; bounded no-op when no heading matches or `edit` returns the same/non-string body; ADR-2143 §4 structurally retires the #2130/#2067/#2080 boundary-crossing class by confining any regex the caller runs to the matched section). `Section` carries `bodyStart`/`bodyEnd` offsets for `replaceSection`. ADR-1372 (epic #1372) establishes this seam and a tiered migration plan (T0–T7) to retire the 8+ ad-hoc markdown parsers and ~20 inline section-collects across `src/*.cts`. New `src/*.cts` modules must import this seam instead of hand-rolling fence strippers or heading-regex section walks (enforced by the `no-adhoc-markdown-parsing` ESLint rule landing in tier T7).
+Canonical markdown-structure parsing seam (`gsd-core/bin/lib/markdown-sectionizer.cjs`). Pure functions, Node built-ins only. Exports: `stripFencedCode(content) → { text, unterminatedFence }` (CommonMark-correct state machine, CRLF-safe, signals unterminated fences); `stripInlineCode(content) → string` (per-line CommonMark inline-code-span stripper — removes `` `code` `` spans while leaving fenced blocks to `stripFencedCode`; #2365); `scanInlineCodeSpans(content) → InlineCodeSpan[]` (locates every inline code span as `{ start, end, content }`, offsets into the full string and spans never crossing a `\n`; callers that need the span CONTENT (e.g. api-coverage's package-name evidence, #2365) use this, callers that just want spans gone use `stripInlineCode`); `tokenizeHeadings(content) → HeadingToken[]` (ATX headings outside fenced blocks, `{ level, text, line, offset }`); `collectSections(content, stopPredicate) → Section[]` (line-by-line section collection driven by a heading predicate); `collectSection(content, headingPredicate, { levelBounded, stripFences }) → Section | null` (single named section with level-bounded stop); `iterateBullets(sectionText) → BulletItem[]` (dash/checkbox/numbered markers with indented continuation); `extractTaggedBlocks(content, tagName) → string[]` (inner text of every `…` block in document order, tagName regex-escaped, caller decides fence-stripping — generalises `decisions.cts`'s bespoke extractor for T1); `replaceSection(content, section, newBody) → string` (pure character-offset splice using `Section.bodyStart`/`bodyEnd` for read-modify-write callers — eliminates T6 `state.cts`'s 7× inline `content.replace` pattern); `withSection(content, target, edit) → string` (resolve the section whose heading matches `target` — exact heading text or a `HeadingToken` predicate — and run `edit(body)` against ONLY that section's body before splicing the result back; bounded no-op when no heading matches or `edit` returns the same/non-string body; ADR-2143 §4 structurally retires the #2130/#2067/#2080 boundary-crossing class by confining any regex the caller runs to the matched section). `Section` carries `bodyStart`/`bodyEnd` offsets for `replaceSection`. ADR-1372 (epic #1372) establishes this seam and a tiered migration plan (T0–T7) to retire the 8+ ad-hoc markdown parsers and ~20 inline section-collects across `src/*.cts`. New `src/*.cts` modules must import this seam instead of hand-rolling fence strippers or heading-regex section walks (enforced by the `no-adhoc-markdown-parsing` ESLint rule landing in tier T7).
### Markdown Table Model
-Canonical GFM table parsing + schema registry seam (`gsd-core/bin/lib/markdown-table.cjs`, generated from `src/markdown-table.cts`; ADR-2143, epic #2143). Pure functions, Node built-ins only, string-in/value-out, no I/O. Exports: `parseMarkdownTable(sectionText) → Result` (parses the first GFM pipe table found; typed `{ok:false,reason}` parse errors for no-table, missing/misaligned delimiter row, and ragged data rows — never silently drops or coerces a malformed row); `MarkdownTable` (`{columns: string[], rows: Record[]}`, rows addressed by column name, not position); `Result` (`{ok:true,value}\|{ok:false,reason}` — re-exported from the Write-Set Module, the ADR-2143 §5 single source of truth for this shape, so existing importers of `Result` from `markdown-table.cjs` are unaffected; deliberately distinct from command-routing-hub's dispatch `Result` `{ok,data\|kind}`; the two never mix); `TABLE_SCHEMAS` (`Record` — the canonical column-header variants for every GFM table GSD parses or generates: `RoadmapProgress` flat/milestone-grouped, `RequirementsTraceability`, `QuickTasks` no-status/with-status, `Security` trust-boundaries/threat-register/accepted-risks/audit-trail); `matchTableSchema(columns) → {id,label}\|null` (resolves a parsed header back to its canonical schema by exact column-name/order match). This registry is the single source of truth for ROADMAP/STATE/SECURITY canonical tables — a parity test (`tests/markdown-table.test.cjs`) asserts every variant's header appears verbatim in the template/workflow file that generates it, so the registry and templates can never silently drift (ADR-2143 §3 Generative-Fix-Divergence guard). `phase-lifecycle.cts`'s `deriveProgressFromRoadmap` is the first consumer: it locates the Progress section via the Markdown Sectionizer's `collectSection` and reads cells by column NAME through this seam, fixing #2137 (the prior position-anchored regex assumed `Status` was always the 3rd cell, which broke for the 5-column milestone-grouped `Milestone` variant).
+Canonical GFM table parsing + schema registry seam (`gsd-core/bin/lib/markdown-table.cjs`; ADR-2143, epic #2143). Pure functions, Node built-ins only, string-in/value-out, no I/O. Exports: `parseMarkdownTable(sectionText) → Result` (parses the first GFM pipe table found; typed `{ok:false,reason}` parse errors for no-table, missing/misaligned delimiter row, and ragged data rows — never silently drops or coerces a malformed row); `MarkdownTable` (`{columns: string[], rows: Record[]}`, rows addressed by column name, not position); `Result` (`{ok:true,value}\|{ok:false,reason}` — re-exported from the Write-Set Module, the ADR-2143 §5 single source of truth for this shape, so existing importers of `Result` from `markdown-table.cjs` are unaffected; deliberately distinct from command-routing-hub's dispatch `Result` `{ok,data\|kind}`; the two never mix); `TABLE_SCHEMAS` (`Record` — the canonical column-header variants for every GFM table GSD parses or generates: `RoadmapProgress` flat/milestone-grouped, `RequirementsTraceability`, `QuickTasks` no-status/with-status, `Security` trust-boundaries/threat-register/accepted-risks/audit-trail); `matchTableSchema(columns) → {id,label}\|null` (resolves a parsed header back to its canonical schema by exact column-name/order match). This registry is the single source of truth for ROADMAP/STATE/SECURITY canonical tables — a parity test (`tests/markdown-table.test.cjs`) asserts every variant's header appears verbatim in the template/workflow file that generates it, so the registry and templates can never silently drift (ADR-2143 §3 Generative-Fix-Divergence guard). `phase-lifecycle.cts`'s `deriveProgressFromRoadmap` is the first consumer: it locates the Progress section via the Markdown Sectionizer's `collectSection` and reads cells by column NAME through this seam, fixing #2137 (the prior position-anchored regex assumed `Status` was always the 3rd cell, which broke for the 5-column milestone-grouped `Milestone` variant).
### Write-Set Module
-Shared fail-loud `Result` and per-surface write-set contracts (`gsd-core/bin/lib/write-set.cjs`, generated from `src/write-set.cts`; ADR-2143 §5/§6, epic #2143). Pure, Node built-ins only, no I/O. Exports: `Result` (`{ok:true,value}\|{ok:false,reason}` — ADR-2143 §5 fail-loud parse shape, never a bare `null` a caller can mistake for "empty but fine"; the single source of truth `markdown-table.cjs` re-exports so its existing importers are unaffected; deliberately distinct from command-routing-hub's dispatch `Result` `{ok,data\|kind}`); `WriteOutcome` (`{surface: string, applied: boolean}` — one surface's outcome within a multi-surface write); `WriteSet` (`WriteOutcome[]`); `writeSetComplete(ws) → boolean` (true only when the set is non-empty AND every surface applied — ADR-2143 §6's "no OR-into-one-flag" rule: a command that mutates more than one surface must not collapse independent surface outcomes into a single boolean, the anti-pattern that let a checkbox-only partial write (#2140) report full success). `milestone.cts`'s `requirements mark-complete` handler is the first consumer: it reports a `write_set` (`checkbox`/`traceability` surfaces) and `write_set_complete` alongside its existing `updated`/`marked_complete`/`already_complete`/`not_found`/`table_unmatched` fields, which remain computed exactly as before — the write-set is additive, structured ADR-2143 documentation of the same per-surface facts #2140's tactical fix already exposed via `table_unmatched`.
+Shared fail-loud `Result` and per-surface write-set contracts (`gsd-core/bin/lib/write-set.cjs`; ADR-2143 §5/§6, epic #2143). Pure, Node built-ins only, no I/O. Exports: `Result` (`{ok:true,value}\|{ok:false,reason}` — ADR-2143 §5 fail-loud parse shape, never a bare `null` a caller can mistake for "empty but fine"; the single source of truth `markdown-table.cjs` re-exports so its existing importers are unaffected; deliberately distinct from command-routing-hub's dispatch `Result` `{ok,data\|kind}`); `WriteOutcome` (`{surface: string, applied: boolean}` — one surface's outcome within a multi-surface write); `WriteSet` (`WriteOutcome[]`); `writeSetComplete(ws) → boolean` (true only when the set is non-empty AND every surface applied — ADR-2143 §6's "no OR-into-one-flag" rule: a command that mutates more than one surface must not collapse independent surface outcomes into a single boolean, the anti-pattern that let a checkbox-only partial write (#2140) report full success). `milestone.cts`'s `requirements mark-complete` handler is the first consumer: it reports a `write_set` (`checkbox`/`traceability` surfaces) and `write_set_complete` alongside its existing `updated`/`marked_complete`/`already_complete`/`not_found`/`table_unmatched` fields, which remain computed exactly as before — the write-set is additive, structured ADR-2143 documentation of the same per-surface facts #2140's tactical fix already exposed via `table_unmatched`.
### Roadmap Parser Module
Module owning ROADMAP.md parsing: shipped-milestone slicing, current-milestone extraction, milestone/phase lookups, and milestone-phase filtering (`stripShippedMilestones`, `extractCurrentMilestone`, `replaceInCurrentMilestone`, `getRoadmapPhaseInternal`, `getMilestoneInfo`, `getMilestonePhaseFilter`, `isMilestoneShippedInRoadmap`, `withPhaseSection`). Milestone shipped/active heading classification is owned here (#2562): `isMilestoneShippedInRoadmap(content, version)` answers "does the ROADMAP mark THIS milestone shipped" from heading and `` lines only — never a bullet that merely names the version — with the version token boundary-matched so `v2.0` does not match inside `v2.0.1`. `extractCurrentMilestone` and `getMilestonePhaseFilter` take an optional trailing workstream name so their `planningDir` resolution targets `.planning/workstreams//`; omitted, it resolves exactly as before (including the `GSD_WORKSTREAM` fallback). `getMilestonePhaseFilter` exposes `versionScoped`, true only when the returned phase set really is one milestone's — consumers must not read `phaseCount` as a current-milestone denominator otherwise — and `versionSectionFound`, true whenever the requested version's section was located at all. The two differ precisely for a located-but-EMPTY section: it falls through to the zero-count pass-all degrade, which resets `versionScoped` to false, leaving `versionSectionFound` the only surviving evidence that the milestone exists rather than being absent. `missingExplicitVersion` covers the complementary shape (versioned roadmap, no section for this version). `withPhaseSection(content, phaseId, edit)` resolves a phase's `### Phase N` detail-section heading via the #2121 phase-id source (`phaseMarkdownRegexSource`) and delegates to the markdown-sectionizer seam's `withSection`, so a per-phase ROADMAP edit is bounded to that phase's own section (ADR-2143 §4). Depends only on leaf modules (`phase-id`, `planning-workspace`, `shell-command-projection`, `markdown-sectionizer`, and — since #1881 — `unusable-input` for the out-of-band diagnostic) — no `loadConfig`, no other core dependency. An unreadable ROADMAP.md is reported rather than collapsed into the same sentinel as a genuinely absent one; absence itself stays silent, and neither lookup gains a throw (the #2245 audit records that `src/state.cts` removed its defensive try/catch on the strength of `getMilestoneInfo` never throwing). Milestone WINDOWING — which headings bound a milestone — is owned here as of #3184 (epic #3180 Phase 2, ADR-3180 Decision 1): `computeMilestoneSectionEnd` (the section-end walk, formerly duplicated as two distinct nested `computeSectionEnd` functions plus an inline third copy in `getMilestonePhaseFilter`'s `versionOverride` branch), `locateMilestoneHeadings` (heading location, version token boundary-matched with `\b`, **NOT** the stricter `(?![\w.-])`: `v2.0` therefore DOES match inside `v2.0.1`, and a milestone STATE of `v8.0` legitimately selects a live `## v8.0-B …` over a closed `v8.0-A` sibling — deliberate, load-bearing #730 behavior that ADR-3180 Amendment 2 tried to tighten and then reverted; the earlier text here described that reverted alternative as if it had shipped, corrected by #3216), `listMilestoneHeadings` (#3216 — the version-AGNOSTIC enumeration of every milestone heading in document order, sharing ONE grammar source with `locateMilestoneHeadings` so the two cannot drift; `locateMilestoneHeadings` is now a version-filtered view over it rather than a second expression of the pattern). Milestone IDENTITY — which milestone is current and what it is CALLED — is owned by `getMilestoneInfo`, which since #3216 binds to that same locator instead of its own heading regexes and returns a `ScopedResult`: a name retains parentheses and drops a trailing `✅`/`📋`/`🚧` marker, a `### Phase N …` heading is never the milestone heading (#3197), and an identity that cannot be determined returns a non-`COMPLETE` scope rather than the former `{version:'v1.0', name:'milestone'}` default, which was output-identical to a successful read of a genuine v1.0 project. `buildStateFrontmatter` and `archivePhaseDirectories` branch on that scope, so a fabricated identity is never persisted to `STATE.md` nor used as a `milestones/-phases/` path component, `sliceMilestoneWindow` (the one composition of locate → prefer-non-closed → section-end, so a consumer cannot re-assemble its own window from the primitives), and `isMilestoneBoundedInRoadmap` (the named predicate replacing two byte-identical re-derivations in `state.cts`). `hasMilestoneSectioning(content)` is the sibling predicate answering the WEAKER question `buildStateFrontmatter` actually asks — "could a whole-document phase count conflate two different milestones?" — and since #3185 it is decided by milestone VOCABULARY, not by heading position: a heading is a milestone heading iff it is a non-Phase heading (level 1-3) carrying a version token, a shipped/active marker, or the word `Milestone`, and sectioning means two or more of them, since one section cannot conflate siblings. Three position-based models were tried and each shipped a defect — "any non-Phase heading" over-detects, so a flat ROADMAP carrying an ordinary `## Progress` was called sectioned and its declared phase count discarded for the on-disk directory count (#3204, #2828 regressing at 1.9.1 via #3184's own consolidation); strict nesting misses same-level siblings (regressing #1761) and false-positives on the bundled `templates/roadmap.md` shape, where a `## Phases` wrapper holds a single nested milestone; adjacency false-positives whenever a structural heading merely precedes a phase heading. Known limit: two milestone sections carrying none of the three signals are not detected. `extractCurrentMilestoneScoped` is the real extractor and returns the Planning Scope Module's `ScopedResult`; `extractCurrentMilestone` remains a one-line wrapper over `.value` because its blast radius is CRITICAL (200+ affected symbols, 20 direct callers) and its signature must not move. `getMilestonePhaseFilter` gains a `scope` field: its pass-all degrade is PRESERVED where its premise holds (a genuinely-empty, freshly-declared milestone reports `SCOPE.COMPLETE`) and is now labelled where it does not (`SCOPE.TRUNCATED` when the window reached no phase entries while the document has them), so the destructive consumer — `milestone.complete`, which MOVES phase directories — can refuse instead of archiving every phase directory on disk (#3166). The filter's function behavior is deliberately unchanged: making it deny-all on a non-COMPLETE scope would trade a silent over-inclusive answer for a silent under-inclusive one on the read paths that count with it. `findRoadmapProgressTable(content)` (#1956) locates the `## Progress` table — scoped to that heading via the markdown-sectionizer seam, falling back to the whole document for a headingless milestone slice — so a differently-headed table sharing the `Phase | Plans Complete | Status | Completed` columns cannot be read instead (the #2012 decoy class). `phase-lifecycle.cts`'s `deriveProgressFromRoadmap` expresses the same scope independently for the completion RATIO; the two are held in agreement by a parity test rather than by a shared call, because that symbol's blast radius does not justify a refactor. Extracted from the Core module per ADR-857 rollout phase 2b (#870), resolving the ROADMAP.md parse/write straddle so the Roadmap module (`roadmap.cjs`, which owns ROADMAP.md mutation) imports parsing directly instead of through Core; the `core.cjs` re-export spine was retired in epic #1267, so callers import this leaf directly. Source of truth: `gsd-core/bin/lib/roadmap-parser.cjs` (generated from `src/roadmap-parser.cts`).
@@ -234,10 +234,10 @@ Pure, no-I/O `when=` evaluator over `InvocationFacts`, mapping a document-order
Module owning the per-runtime mapping from artifact kind to filesystem placement. ADR-3660 defines the typed `kinds` per runtime (`commands`, `agents`, `skills`) with destination subpath, prefix, and stage adapter (with per-runtime converters in `bin/install.js`: `convertClaudeCommandToClaudeSkill`, `…CodexSkill`, `…CopilotSkill`, `…AntigravitySkill`). Owns the per-runtime `nested` skill-bundle decision (#69): a `skillsKind` flag in `src/runtime-artifact-layout.cts` drives whether a runtime receives the nested router layout (6 `gsd-ns-*` routers + concrete skills under `/skills//`) or the flat `skills/gsd-/` layout; the evidence/doc-link matrix is recorded in a comment above `resolveRuntimeArtifactLayout`. Phase 1 applies this seam to the Runtime Surface Module (`surface.cjs:applySurface`); as of #813, `applySurface` applies the same per-runtime skill-body path rewrites as `installRuntimeArtifacts` for `skills` kinds — re-surfacing no longer overwrites installed SKILL.md bodies with converter-default `~/.claude` paths. Per ADR-1508 / #1511 the former `getInstallExports`/`loadInstallExports` relay (a `GSD_TEST_MODE`-guarded `require('bin/install.js')` by which `surface.cjs` reached `computePathPrefix`/`applyRuntimeContentRewritesInPlace`) was DELETED from this module; content rewriting now lives in the Runtime Artifact Conversion Module and `surface.cjs:applySurface` calls its `rewriteStagedSkillBodies` directly. The resolved `scope` is still carried on the `Layout` object so `applySurface` derives the same `pathPrefix` (global `$HOME` form vs. absolute) as a fresh install. Phase 2 is planned to migrate install/uninstall in `bin/install.js` so all lifecycle sites iterate one shared layout table instead of re-encoding runtime layout logic. This design is intended to remove the #3659 class of omissions. Migrations remain under the Installer Migration Module (ADR-0008). The `.gsd-source` marker (#1477) is a two-party provisioning contract that lets source resolution succeed on the Claude global skills layout, which ships `gsd-core/{bin,contexts,references,templates,workflows}` but no `commands/gsd` source tree for `findInstallSourceRoot` to walk up to: the writer is `bin/install.js`, which writes `/.gsd-source` (content: the absolute path to its own `commands/gsd`, terminated by a newline) when `runtime === 'claude' && isGlobal`, guarded by `fs.existsSync` so a half-published package never writes a dangling marker; the reader is `findInstallSourceRoot(configDir)`, which prefers the marker over its walk-up but falls through to the walk-up if the marker is absent, dangling, or empty/whitespace-only. #2871 Phase 2 widens the Module from placement to placement **+** trigger resolution: `resolveTriggerSurface(runtime, scopes, { stems, routerStems?, childToRouters?, registry? }) -> TriggerSurface[]` answers "what does a user type" rather than "where does a file land" — a new, pure function alongside `resolveRuntimeArtifactLayout` (untouched, still 7 callers), never a widened signature. Only `commands` and `skills` are trigger-bearing; `agents`/`kimi-agents` are excluded entirely — an agent is invoked through the Agent/Task tool's `subagent_type`, a separate dispatch interface point, never a `/gsd-` a user types (ADR-2866 amendment below). Each `TriggerSurface` names its `trigger`, `kind`, `scope`, `destPath` (computed through the SAME `namespacedByDir` branch `_copyStaged` uses), `registration` (`'direct'` | `'via-router'`, the latter naming the owning router's `routerTrigger` for a nested-router runtime's concrete child skill — #69), and `shadowedBy` (the winning sibling entry, or `null`). The winner across scopes and kinds is decided by scope rank first (Install Scope Module's `scopeRank`, consumed not re-derived — global outranks local) then by the runtime's new `runtime.triggerPrecedence` descriptor axis (ordered kind names, highest priority first; default `['skills', 'commands']`, required-with-default so a pre-#2871 `capability.json` keeps validating). `shadowedBy` ships unread this phase — Phase 4 (#2873) is its first consumer. See ADR-3660.
### Runtime Artifact Conversion Module
-Sibling Module to Runtime Artifact Layout Module. Owns projection from canonical Claude-authored command/agent/skill markdown into runtime-specific artifact bodies, including converter selection, frontmatter/body normalization, runtime path rewrites, and staged artifact generation. Runtime Artifact Layout remains responsible for filesystem placement (`kind`, destination subpath, prefix, nesting); Runtime Artifact Conversion owns the content Implementation behind that placement seam so install, uninstall/surface parity, and future plugin/package projections stop reaching back through `bin/install.js` for converter functions or `GSD_TEST_MODE`-guarded installer exports. Chosen direction: sibling Module, not an expanded Layout Module, to preserve ADR-3660's narrow placement responsibility while deepening artifact content locality. First slice: relocate only the layout-reached conversion family (`convertClaudeCommandTo*Skill`, converted command-file emitters, `buildKimiAgentArtifacts`) plus the minimal helper closure they need; do not leave helper dependencies in `bin/install.js` because that would preserve the same shallow seam under a new filename. Installer integration decision: `bin/install.js` imports the conversion Module at top level and re-exports the moved names for compatibility; the conversion Module must not import `bin/install.js` or Runtime Artifact Layout, so the dependency direction becomes installer/layout Adapters -> conversion Module, never conversion -> installer. First-slice Interface decision: export the existing compatibility names only; do not introduce a grouped `convertRuntimeArtifact` Interface until after relocation proves byte-for-byte behavior. SHIPPED (ADR-1508): the converter family relocated in #1510 Phase 1 (`getDirName`→runtime-name-policy, `processAttribution` here); #1511 Phase 2 moved the content-rewrite engine here in full — `_applyRuntimeRewrites` (per-runtime switch, injected attribution), the staged-content walkers `applyRuntimeContentRewritesInPlace`/`applyRuntimeContentRewritesForCommandsInPlace`, `computePathPrefix` (private; `_computePathPrefix` for tests), and the deep public seam `rewriteStagedSkillBodies`/`rewriteStagedCommandBodies({runtime,configDir,scope,homedir?,platform?,resolveAttribution?})`. `bin/install.js` binds these back (single owner, exports preserved); `getCommitAttribution` stays in `bin/install.js` (impure install-time config I/O) and is injected. The `getInstallExports` relay in Runtime Artifact Layout Module was deleted; the dependency direction installer/layout → conversion (never upward) is now enforced. Exception: opencode and kilo path-prefix rewriting is a deliberate `bin/install.js`-owned pre-conversion step (`applyOpencodeFamilyPathPrefix`) per #784, not a violation of the single-owner rule. Source: `gsd-core/bin/lib/runtime-artifact-conversion.cjs` (generated from `src/runtime-artifact-conversion.cts`). Also exports `resolveVersionFrom(libDir)` — a lazy, defensive GSD-version resolver (installed-tree `gsd-core/VERSION` first, then the source/npm `package.json` three dirs up, both validated against the repo's shared semver-prefix shape, degrading to `''` on failure) that replaced a module-load-time `require('../../../package.json')` which crashed on runtimes whose root carries no `package.json` (e.g. Codex) (#1383).
+Sibling Module to Runtime Artifact Layout Module. Owns projection from canonical Claude-authored command/agent/skill markdown into runtime-specific artifact bodies, including converter selection, frontmatter/body normalization, runtime path rewrites, and staged artifact generation. Runtime Artifact Layout remains responsible for filesystem placement (`kind`, destination subpath, prefix, nesting); Runtime Artifact Conversion owns the content Implementation behind that placement seam so install, uninstall/surface parity, and future plugin/package projections stop reaching back through `bin/install.js` for converter functions or `GSD_TEST_MODE`-guarded installer exports. Chosen direction: sibling Module, not an expanded Layout Module, to preserve ADR-3660's narrow placement responsibility while deepening artifact content locality. First slice: relocate only the layout-reached conversion family (`convertClaudeCommandTo*Skill`, converted command-file emitters, `buildKimiAgentArtifacts`) plus the minimal helper closure they need; do not leave helper dependencies in `bin/install.js` because that would preserve the same shallow seam under a new filename. Installer integration decision: `bin/install.js` imports the conversion Module at top level and re-exports the moved names for compatibility; the conversion Module must not import `bin/install.js` or Runtime Artifact Layout, so the dependency direction becomes installer/layout Adapters -> conversion Module, never conversion -> installer. First-slice Interface decision: export the existing compatibility names only; do not introduce a grouped `convertRuntimeArtifact` Interface until after relocation proves byte-for-byte behavior. SHIPPED (ADR-1508): the converter family relocated in #1510 Phase 1 (`getDirName`→runtime-name-policy, `processAttribution` here); #1511 Phase 2 moved the content-rewrite engine here in full — `_applyRuntimeRewrites` (per-runtime switch, injected attribution), the staged-content walkers `applyRuntimeContentRewritesInPlace`/`applyRuntimeContentRewritesForCommandsInPlace`, `computePathPrefix` (private; `_computePathPrefix` for tests), and the deep public seam `rewriteStagedSkillBodies`/`rewriteStagedCommandBodies({runtime,configDir,scope,homedir?,platform?,resolveAttribution?})`. `bin/install.js` binds these back (single owner, exports preserved); `getCommitAttribution` stays in `bin/install.js` (impure install-time config I/O) and is injected. The `getInstallExports` relay in Runtime Artifact Layout Module was deleted; the dependency direction installer/layout → conversion (never upward) is now enforced. Exception: opencode and kilo path-prefix rewriting is a deliberate `bin/install.js`-owned pre-conversion step (`applyOpencodeFamilyPathPrefix`) per #784, not a violation of the single-owner rule. Source: `gsd-core/bin/lib/runtime-artifact-conversion.cjs`. Also exports `resolveVersionFrom(libDir)` — a lazy, defensive GSD-version resolver (installed-tree `gsd-core/VERSION` first, then the source/npm `package.json` three dirs up, both validated against the repo's shared semver-prefix shape, degrading to `''` on failure) that replaced a module-load-time `require('../../../package.json')` which crashed on runtimes whose root carries no `package.json` (e.g. Codex) (#1383).
### Runtime Artifact Install Plan Module
-Module owning install-time staging and content-rewrite selection for a pre-resolved Runtime Artifact Layout. Interface: `createRuntimeArtifactInstallPlan({ layout, resolvedProfile, homedir?, platform?, resolveAttribution?, deps? }) -> { ok:true, plan:{ items, cleanupDirs } } | { ok:false, kind:'stage_failed'|'rewrite_failed', message, cleanupDirs, failedKind? }`. It iterates `layout.kinds` in order, calls each kind's `stage(resolvedProfile)`, delegates `commands` to Runtime Artifact Conversion `rewriteStagedCommandBodies`, delegates `skills` and `kimi-agents` to `rewriteStagedSkillBodies`, leaves non-rewritten kinds unchanged, and projects copy items as `{ kind, sourceDir, destDir }`. It deliberately does not prune, copy, run legacy migrations, print output, or execute cleanup; those remain Installer Module adapter responsibilities until later slices wire the plan into `bin/install.js`. **Write-confinement (ADR-1239 Phase B / #1679):** the exported pure `assertDestWithinConfigHome(configDir, destSubpath) -> resolvedDest` is the security gate — every kind's `destDir` is computed through it on both the install and uninstall plan paths, so a `destSubpath` that escapes `configHome` (`../../etc`, a NUL byte, etc.) is rejected at plan-build time with a clear error; `surface.cjs:applySurface` and `bin/install.js:installOpencodeFamilySkills` route their joins through the same helper, and `_copyStaged` carries a defense-in-depth containment check. This is security-load-bearing for the Phase C third-party-descriptor loader (which is where an untrusted `destSubpath` could arrive). Source: `gsd-core/bin/lib/runtime-artifact-install-plan.cjs` (generated from `src/runtime-artifact-install-plan.cts`). See Runtime Artifact Layout Module and Runtime Artifact Conversion Module.
+Module owning install-time staging and content-rewrite selection for a pre-resolved Runtime Artifact Layout. Interface: `createRuntimeArtifactInstallPlan({ layout, resolvedProfile, homedir?, platform?, resolveAttribution?, deps? }) -> { ok:true, plan:{ items, cleanupDirs } } | { ok:false, kind:'stage_failed'|'rewrite_failed', message, cleanupDirs, failedKind? }`. It iterates `layout.kinds` in order, calls each kind's `stage(resolvedProfile)`, delegates `commands` to Runtime Artifact Conversion `rewriteStagedCommandBodies`, delegates `skills` and `kimi-agents` to `rewriteStagedSkillBodies`, leaves non-rewritten kinds unchanged, and projects copy items as `{ kind, sourceDir, destDir }`. It deliberately does not prune, copy, run legacy migrations, print output, or execute cleanup; those remain Installer Module adapter responsibilities until later slices wire the plan into `bin/install.js`. **Write-confinement (ADR-1239 Phase B / #1679):** the exported pure `assertDestWithinConfigHome(configDir, destSubpath) -> resolvedDest` is the security gate — every kind's `destDir` is computed through it on both the install and uninstall plan paths, so a `destSubpath` that escapes `configHome` (`../../etc`, a NUL byte, etc.) is rejected at plan-build time with a clear error; `surface.cjs:applySurface` and `bin/install.js:installOpencodeFamilySkills` route their joins through the same helper, and `_copyStaged` carries a defense-in-depth containment check. This is security-load-bearing for the Phase C third-party-descriptor loader (which is where an untrusted `destSubpath` could arrive). Source: `gsd-core/bin/lib/runtime-artifact-install-plan.cjs`. See Runtime Artifact Layout Module and Runtime Artifact Conversion Module.
### Install Scope Module
Owns the two-value install-scope axis (`'global' | 'local'`) as a typed value, replacing the bare `isGlobal ? 'global' : 'local'` string re-derived at 12 sites in `bin/install.js` plus several downstream re-derivations (#2870, ADR-2866). Interface: `resolveScope({ id, runtime, explicitDir?, env?, home?, existsSync? }) -> { id, configHome, settingsFile, consentRequired, hostPrecedenceRank }` — pure (no writes, never mutates `input`) and the returned value is frozen so a caller cannot corrupt a subsequent resolution. Owns the `InstallScope` type name: previously a private, non-exported `TypeAlias` inside Runtime Artifact Install Plan Module; that module now `import type`s it from here instead of re-declaring it, so the codebase does not grow a fifth spelling of the axis alongside the layout module's `'local' | 'global'`, `capability-lifecycle.cts`'s `'global' | 'project'`, and `capability-consent.cts`'s single `'project'` literal. `configHome` for `global` composes `resolveConfigHomeFromDescriptor` (Runtime Homes Module) unmodified rather than adding a `scope` parameter to it — that function is CRITICAL blast radius (60 dependents across 13 files); for `local` it joins the capability registry's per-runtime `localConfigDir` onto the real process cwd (the project you are standing in — not injectable via `home`, by design). `explicitDir` short-circuits both scopes identically to `getGlobalConfigDir`'s existing override, and every returned `configHome` is normalized to forward slashes UNCONDITIONALLY (`.replace(/\\/g,'/')`, never gated on `path.sep`). `settingsFile` reads the registry's `hostBehaviors.settingsFileByScope[id]` and is `null` for the 18 of 19 registered runtimes that declare none — absence is a value, not an invented Claude-shaped default; the one caller that legitimately wants a Claude fallback (`bin/install.js:550`) still applies it itself. `consentRequired` is `false` for `global` (nothing is recorded — matches Capability Registry Overlay's rule that a GLOBAL-scope capability is trusted without a consent record) and `true` for `local`; it reports the requirement only; it does not perform or waive consent. `hostPrecedenceRank` (`global` outranks `local`) is carried as data only this phase — unread until Phase 2 (#2871) defines precedence semantics. Throws `TypeError` for an invalid `id` (wrong case, empty, missing, or any non-string value — never coerced), an unknown `runtime`, or a runtime whose `configHome.kind === 'none'` (vscode — non-installable, #2103) — all three share one `instanceof TypeError` catch shape with Runtime Artifact Layout Module's existing unknown-runtime contract. **The `local`/`project` boundary is documented, not unified:** this module's `'local'` spelling — chosen because it is the CLI's own vocabulary (`--local`) and what the layout module and manifest already use — is deliberately NOT reconciled with Capability Consent Store's `ConsentRecord.scope: 'project'` or Capability Lifecycle's `'global' | 'project'` operations. `ConsentRecord.scope` is a value persisted on disk in user-owned consent records outside any repository; renaming that literal to match would silently invalidate every existing project-scoped consent record on a user's machine the next time it is read back — a far worse defect than the vocabulary split. The mapping instead lives here as a fact: install scope `'local'` ⇄ consent scope `'project'`; install scope `'global'` ⇄ no consent record at all. Source: `gsd-core/bin/lib/install-scope.cjs` (generated from `src/install-scope.cts`). See Runtime Homes Module, Runtime Artifact Layout Module, Runtime Artifact Install Plan Module, Capability Consent Store, Capability Lifecycle.
@@ -285,10 +285,10 @@ ADR-1244 D3 fetch-and-stage seam (`gsd-core/bin/lib/capability-source.cjs`). Pri
ADR-1244 D4 per-runtime install manifest (`gsd-core/bin/lib/capability-ledger.cjs`). Leaf module (only `node:fs`/`node:path` plus `shell-command-projection`'s `platformWriteSync`). Records `{ id, version, source, integrity, files[], sharedEdits[{file,marker}] }` per installed capability in `.gsd-capabilities.json` at the runtime config dir root. Exports: `readLedger` (structural-validated, never throws), `writeLedger` (atomic via `platformWriteSync`), `recordInstall` (idempotent, prototype-pollution-guarded), `removeEntry`, and `reconcile` (reports orphans whose `files[]` are missing on disk; hardened against non-string/`..` members; never mutates). Serves as the atomic commit point for Phase-4 upgrade/remove and the reconciliation basis for detecting stale entries after out-of-band deletions.
### Capability Consent Store
-Issue #1459 user-owned consent seam (`gsd-core/bin/lib/capability-consent.cjs`, generated from `src/capability-consent.cts`). Leaf module (`node:fs`/`node:path`/`node:os`/`node:crypto` + the ledger's shared bounded `readSmallRegularFile`/`readSmallRegularFileBuffer` + the shared `capability-lock` primitive). Stores `{ version:"1", records: { "": { projectRoot, id, scope:'project', integrity, disclosureSignature, contentHash, consentedAt } } }` at `${GSD_HOME||homedir()}/.gsd/consent.json` — a USER-OWNED file OUTSIDE any repository. Exports: `consentStorePath(gsdHome?)`, `readConsentStore(gsdHome?)` (bounded via `readSmallRegularFile` + 8 MiB cap, NON-THROWING — missing/corrupt/oversized/FIFO/wrong-shape → empty `{records:{}}`; caps records at `MAX_RECORDS=4096`), `bundleContentHash(capDir)` (THE security binding — a `sha512-` over a DETERMINISTIC, INJECTIVE, LOSSLESS serialization of EVERY regular file AND directory under the bundle: length-FRAMED entry COUNT + per-entry TYPE tag + uint32 path-byte-len + RAW path bytes from a `{encoding:'buffer'}` dir walk [finding 4] + for files uint64 content-byte-len + RAW content bytes via `readSmallRegularFileBuffer` [finding 1b], plus typed DIR markers binding empty directories [finding 2]; symlinks/non-regular rejected; size+count bounded), `hasProjectConsent({gsdHome,projectRoot,id,contentHash})` (true iff a record for `${realpath(projectRoot)}\x00` exists AND its stored `contentHash` equals the supplied recomputed hash — the binding is `contentHash`, NOT `integrity` and NOT `disclosureSignature` (those remain on the record purely for the human disclosure + re-consent-on-executable-change UX); unsafe ids → false; prototype-pollution-safe NUL-joined keys + `Object.prototype.hasOwnProperty`), `recordProjectConsent({gsdHome,projectRoot,id,integrity,disclosureSignature,contentHash})` (LOCKED, atomic+durable write — tmp `wx`/fsync/rename/dir-fsync mirroring `writeLedger`; enforces the record cap at write time) and `revokeProjectConsent({gsdHome,projectRoot,id})` (LOCKED atomic delete, no-op if absent) — BOTH **THROW** rather than perform an UNLOCKED read-modify-write when the consent-store lock cannot be acquired (finding 3; the lifecycle treats a consent-write failure as non-fatal, and the `trust revoke` CLI catches the throw and emits a clean error). This is the authoritative consent signal the loader recomputes (`bundleContentHash(capDir)`) and checks at load before activating a PROJECT-scope third-party overlay (declarative surfaces AND command dispatch): a forged/cloned in-repo project ledger, OR any post-consent tamper (swapped declarative manifest, edited hook script, empty-integrity local install — all change the recomputed hash), leaves the cap DISCOVERED-BUT-INACTIVE until the user consents on THIS machine to the EXACT bundle (the lifecycle records the consent on a consented project install/upgrade and revokes it on remove; install/lookup/revoke share one canonical `consentProjectRoot` root key). GLOBAL-scope overlays (under the user's own home) need no record; and when `GSD_HOME` resolves (via realpath, defeating symlink aliasing — finding 1) to a genuine project root the in-repo bundle still requires a record. The consent lock is the SHARED hardened primitive (below), so it never stale-steals a slow-but-live writer (finding 4). See `docs/explanation/capability-trust-model.md` "project-scope trust boundary".
+Issue #1459 user-owned consent seam (`gsd-core/bin/lib/capability-consent.cjs`). Leaf module (`node:fs`/`node:path`/`node:os`/`node:crypto` + the ledger's shared bounded `readSmallRegularFile`/`readSmallRegularFileBuffer` + the shared `capability-lock` primitive). Stores `{ version:"1", records: { "": { projectRoot, id, scope:'project', integrity, disclosureSignature, contentHash, consentedAt } } }` at `${GSD_HOME||homedir()}/.gsd/consent.json` — a USER-OWNED file OUTSIDE any repository. Exports: `consentStorePath(gsdHome?)`, `readConsentStore(gsdHome?)` (bounded via `readSmallRegularFile` + 8 MiB cap, NON-THROWING — missing/corrupt/oversized/FIFO/wrong-shape → empty `{records:{}}`; caps records at `MAX_RECORDS=4096`), `bundleContentHash(capDir)` (THE security binding — a `sha512-` over a DETERMINISTIC, INJECTIVE, LOSSLESS serialization of EVERY regular file AND directory under the bundle: length-FRAMED entry COUNT + per-entry TYPE tag + uint32 path-byte-len + RAW path bytes from a `{encoding:'buffer'}` dir walk [finding 4] + for files uint64 content-byte-len + RAW content bytes via `readSmallRegularFileBuffer` [finding 1b], plus typed DIR markers binding empty directories [finding 2]; symlinks/non-regular rejected; size+count bounded), `hasProjectConsent({gsdHome,projectRoot,id,contentHash})` (true iff a record for `${realpath(projectRoot)}\x00` exists AND its stored `contentHash` equals the supplied recomputed hash — the binding is `contentHash`, NOT `integrity` and NOT `disclosureSignature` (those remain on the record purely for the human disclosure + re-consent-on-executable-change UX); unsafe ids → false; prototype-pollution-safe NUL-joined keys + `Object.prototype.hasOwnProperty`), `recordProjectConsent({gsdHome,projectRoot,id,integrity,disclosureSignature,contentHash})` (LOCKED, atomic+durable write — tmp `wx`/fsync/rename/dir-fsync mirroring `writeLedger`; enforces the record cap at write time) and `revokeProjectConsent({gsdHome,projectRoot,id})` (LOCKED atomic delete, no-op if absent) — BOTH **THROW** rather than perform an UNLOCKED read-modify-write when the consent-store lock cannot be acquired (finding 3; the lifecycle treats a consent-write failure as non-fatal, and the `trust revoke` CLI catches the throw and emits a clean error). This is the authoritative consent signal the loader recomputes (`bundleContentHash(capDir)`) and checks at load before activating a PROJECT-scope third-party overlay (declarative surfaces AND command dispatch): a forged/cloned in-repo project ledger, OR any post-consent tamper (swapped declarative manifest, edited hook script, empty-integrity local install — all change the recomputed hash), leaves the cap DISCOVERED-BUT-INACTIVE until the user consents on THIS machine to the EXACT bundle (the lifecycle records the consent on a consented project install/upgrade and revokes it on remove; install/lookup/revoke share one canonical `consentProjectRoot` root key). GLOBAL-scope overlays (under the user's own home) need no record; and when `GSD_HOME` resolves (via realpath, defeating symlink aliasing — finding 1) to a genuine project root the in-repo bundle still requires a record. The consent lock is the SHARED hardened primitive (below), so it never stale-steals a slow-but-live writer (finding 4). See `docs/explanation/capability-trust-model.md` "project-scope trust boundary".
### Capability Lock
-Issue #1459 finding 4 shared cross-process lock primitive (`gsd-core/bin/lib/capability-lock.cjs`, generated from `src/capability-lock.cts`). Leaf module (`node:fs`/`node:path`/`node:os`/`node:crypto` + the ledger's bounded `readSmallRegularFile` + `shell-command-projection`'s `execTool` for the rare start-time shell-out). THE single hardened lockfile protocol shared by BOTH `capability-lifecycle` (the `.gsd/capabilities/.lock` mutation lock) and `capability-consent` (the consent-store `.consent.lock`) — extracted so the two locks cannot diverge (mirrors the shared-validator / shared bounded-reader lessons). Exports: `acquireLock(lockPath, opts?)` (O_EXCL create with a JSON `{token,pid,hostname,startTime,ts}` body; steal protocol binds age to the body's own `ts`, never stale-steals a VERIFIED-LIVE same-host holder — pid alive AND recorded start-time matches the pid's current start-time, defeating pid-reuse without ever stealing a live holder — and reclaims only a dead/unverifiable holder via the dead-pid fast path or the hard `LOCK_DEADMAN_MS` deadman; `opts.maxAttempts` raises the bounded retry budget and `opts.waitForFresh` makes a contended fresh/live holder be WAITED FOR rather than failed-fast so genuinely-racing consent writers serialize), `releaseLock(handle)` (token + inode owner-safe — never deletes a successor's lock), `getProcessStartTime`, and the `_setLockProbes`/`_resetLockProbes` test seams. Carries the #1462 lifecycle-lock invariants (process-start-time liveness, TOCTOU-safe pre-rename identity recheck, bounded iterative loop).
+Issue #1459 finding 4 shared cross-process lock primitive (`gsd-core/bin/lib/capability-lock.cjs`). Leaf module (`node:fs`/`node:path`/`node:os`/`node:crypto` + the ledger's bounded `readSmallRegularFile` + `shell-command-projection`'s `execTool` for the rare start-time shell-out). THE single hardened lockfile protocol shared by BOTH `capability-lifecycle` (the `.gsd/capabilities/.lock` mutation lock) and `capability-consent` (the consent-store `.consent.lock`) — extracted so the two locks cannot diverge (mirrors the shared-validator / shared bounded-reader lessons). Exports: `acquireLock(lockPath, opts?)` (O_EXCL create with a JSON `{token,pid,hostname,startTime,ts}` body; steal protocol binds age to the body's own `ts`, never stale-steals a VERIFIED-LIVE same-host holder — pid alive AND recorded start-time matches the pid's current start-time, defeating pid-reuse without ever stealing a live holder — and reclaims only a dead/unverifiable holder via the dead-pid fast path or the hard `LOCK_DEADMAN_MS` deadman; `opts.maxAttempts` raises the bounded retry budget and `opts.waitForFresh` makes a contended fresh/live holder be WAITED FOR rather than failed-fast so genuinely-racing consent writers serialize), `releaseLock(handle)` (token + inode owner-safe — never deletes a successor's lock), `getProcessStartTime`, and the `_setLockProbes`/`_resetLockProbes` test seams. Carries the #1462 lifecycle-lock invariants (process-start-time liveness, TOCTOU-safe pre-rename identity recheck, bounded iterative loop).
### Capability Trust Gate
ADR-1244 Phase 4 (D5) PURE policy module (`gsd-core/bin/lib/capability-trust.cjs`). Computes *what* a capability would do and *whether* policy permits it; performs no mutation and no I/O beyond existence-checking declared artifacts. Exports: `discloseExecutableSurfaces(manifest, stagedDir?, resolveHost?)` (enumerates the four executable surfaces — `hooks`, command modules, `mcpServers`, and reviewer lanes (ADR-2782 D5) — plus a fifth, non-executable class, instruction surfaces (`skills` stems only, ADR-2363 D5, #3248), returned as `instructionSurfaces`; flags `hasExecutable` from the four executable classes only — instruction surfaces deliberately do NOT contribute to it; a reviewer lane is the one class that *receives* data, so it discloses its binary + full args (spawn) or destination host + `hostConfigKey` (openai-http) together with the egress payload classes); `evaluateInstallTrust(args)` (composes source policy + reserved-namespace + engines gate + disclosure into `{ allowed, requiresConsent, disclosure, engines, blockReasons }`); `evaluateSourceAllowed(parsed, strictKnownRegistries)` enforcing `capabilities.strict_known_registries` (unset/null → permissive-with-consent; `[]` → block all external; non-empty → host-based allowlist, never substring); `checkEngines(manifest, hostVersion)` (engines.gsd hard gate via `semverSatisfies` + `compatVersions` graceful-downgrade picking the newest working version); `executableSetChanged(old, new)` (auto-update re-consent trigger); `checkReservedNamespace` (`gsd-`/`gsd-core-`/`anthropic-`); `collectInstructionSurfaces(manifest)` (the instruction-surface collector — `skills` stems only, independently testable, same total/`safeCollect` contract as the four executable collectors; ADR-2363 D3 classifies `agents` as an instruction surface too, but a third-party capability's declared `agents[]` are never staged into the agent's instruction context — `stageAgentsForRuntimeWithConverter` (`src/install-profiles.cts`) has no registry-aware third-party path the way `readInstalledCapabilitySkill` gives skills — so disclosing them would name a surface that does not exist; agents stay unimplemented pending a maintainer decision, and are NOT thereby safe or inert, only undisclosed); `summarizeInstructionSurfaces(disclosure)` (renders the instruction-surface section of the consent summary; called from BOTH branches of `summarizeDisclosure` because a skill-only capability has `hasExecutable === false` and takes the early return, so a section appended only at the end would never render for exactly the capabilities that need it). The MCP disclosure also captures each server's `env` (string→string, filtered) and `cwd` (#1459) — `disclosureSignature` folds them in as STABLE SORTED JSON so any env/cwd add/change forces re-consent while a key reorder does not; `signatureForManifest(manifest, stagedDir?)` is the single source of truth for that signature (consumed by the loader's consent check and the lifecycle's consent binding). #1459 finding 5: each MCP surface also carries `rawConfig` — the FULL declared server config the writer persists (`{...config}`), prototype-pollution-cleaned — folded into the signature as STABLE SORTED JSON so a change to ANY persisted field (not just the explicit whitelist — a future `envFile`/`workingDir`/launch option) forces re-consent, while a pure key reorder does not; the human summary stays readable via the key fields only. Instruction surfaces are deliberately EXCLUDED from `disclosureSignature` (ADR-2363 D4, #3248) — a manifest gaining, losing, or changing `skills` produces a byte-identical signature and disturbs no stored consent record; any future binding arrives as a versioned v2, never an in-place re-encoding of v1. The barrier is consent + integrity + reversibility, NOT a sandbox — see `docs/explanation/capability-trust-model.md`.
@@ -300,7 +300,7 @@ ADR-1244 Phase 4 (D5+D6) orchestration seam (`gsd-core/bin/lib/capability-lifecy
ADR-1244 Phase 5 (D7) registry-driven dispatch of capability command families. First-party families (`graphify`/`intel`/`audit`, shipped in `bin/lib/`) dispatch via `dispatchCapabilityCommand` (`gsd-core/bin/gsd-tools.cjs`) against the FROZEN `capability-registry.cjs` `commandFamilies` (confined to `bin/lib/`) — unchanged. Third-party (installed overlay) families dispatch via `dispatchOverlayCapabilityCommand`: after the first-party path returns false, it calls `loadRegistry({ includeInstalled, cwd })` and dispatches a family iff its `capId` is in `_overlay.commandRoots` — which `capability-loader.cjs` populates ONLY for accepted overlay capabilities that declare `commands` AND pass the loader's activation gate (a **committed** ledger entry, present and non-`_pending`, PLUS — for PROJECT scope — a matching user consent record in the Capability Consent Store; GLOBAL scope needs no consent record). A bundle dropped on disk with no install (no ledger entry) or no on-this-machine consent is NOT command-dispatchable. The router module is `require()`'d FROM the capability's install root via `defaultRequireFromInstallRoot` (bare-`.cjs` basename + `realpath` containment, rejecting `..` traversal and symlink escape); same own-property/function/sync-only guards as the first-party path. Wired into the `runCommand` default arm before "Unknown command". A repo-planted project ledger no longer activates anything on its own (#1459) — see `docs/explanation/capability-trust-model.md` "project-scope trust boundary".
### Claude Orchestration Capability
-Default-off, BETA, claude-only Capability (`capabilities/claude-orchestration/`, `role: feature`, `runtimeCompat.supported: ["claude"]`, `tier: full`, `activationKey: claude_orchestration.enabled`) adopting Claude Code's Workflow tool (the engine behind `/effort ultracode`, Agent SDK ≥ v0.3.149) as an optional parallel-execution backend for the GSD loop, and folding the `gsd-ultraplan-phase` plan-offload under the same runtime gate (#1143; ADR-1143). Pure, fail-closed core in `gsd-core/bin/lib/claude-orchestration.cjs` (generated from `src/claude-orchestration.cts`): `detectWorkflowBackend({ runtimeId, hostIntegration, config, agentSdkVersion }) → { available, backend:'workflow'|'inline', reason }` (gate ladder: enabled → Claude → execution_backend ≠ inline → host dispatch nested+background → valid Agent SDK → SDK ≥ floor; every miss degrades to `inline`, never throws); `emitWorkflowScript({ phaseDir, waves, runId, budgetTokens?, executorModel? }) → { ok, script, summary }` mapping waves → `parallel()` stage barriers, plans → `agent({ agentType:'gsd-executor', isolation:'worktree', model? })`, `files_modified` overlap → separate sequential stages (greedy first-fit), `resumeFromRunId` wired to the run id, shared `budget(tokens)`; all interpolated identifiers validated script-safe (no `"`,`\`,control chars) and briefs JSON-quoted (review anti-injection). #2686: `executorModel` — resolved for `gsd-executor` from the same config the inline path reads, defaulted by the router so no caller change is needed, `--executor-model` to pin — is emitted per plan and OMITTED when it resolves to `inherit`/empty/whitespace/non-string (#2517: an empty model 404s on runtimes without native tier aliases); a value carrying an unscriptable character is rejected outright (`ok:false`) rather than quoted, because the ADR-1411 provenance header interpolates it into a `//` comment where U+2028/U+2029 would terminate the comment and execute the remainder. `resolveWaveDispatch` forwards it. Registers two loop contributions at WIRED points only (execute:wave:pre/execute:pre are declared but not rendered, same constraint external-job documents): `execute:wave:post into:executor` (Workflow-backend guidance) and `plan:post into:planner` (ultraplan ownership declaration), both `when: claude_orchestration.enabled`, `onError: skip`. Federated config keys (`claude_orchestration.enabled` default false, `execution_backend` enum auto|workflow|inline default auto, `min_agent_sdk_version` string default "0.3.149") live only in the registry — uninstall removes them cleanly. Pre-release versions of the floor compare below GA (SemVer precedence). Restores the wave parallelism + plan-checker + verifier that #853 forces inline on Claude Code; on any runtime lacking the Workflow tool, behaviour is byte-identical to today. BETA v1 ships detection + emission + declarative ultraplan ownership + a `claude-orchestration` command family (`gsd-tools claude-orchestration detect-backend|emit-workflow`, router `gsd-core/bin/lib/claude-orchestration-command-router.cjs` from `src/claude-orchestration-command-router.cts`); full install-profile migration of the ultraplan skill into `skills[]` is a follow-up (CLUSTERS/profile gate). Test anchors: `tests/claude-orchestration.test.cjs`, `tests/claude-orchestration-command-router.test.cjs`.
+Default-off, BETA, claude-only Capability (`capabilities/claude-orchestration/`, `role: feature`, `runtimeCompat.supported: ["claude"]`, `tier: full`, `activationKey: claude_orchestration.enabled`) adopting Claude Code's Workflow tool (the engine behind `/effort ultracode`, Agent SDK ≥ v0.3.149) as an optional parallel-execution backend for the GSD loop, and folding the `gsd-ultraplan-phase` plan-offload under the same runtime gate (#1143; ADR-1143). Pure, fail-closed core in `gsd-core/bin/lib/claude-orchestration.cjs`: `detectWorkflowBackend({ runtimeId, hostIntegration, config, agentSdkVersion }) → { available, backend:'workflow'|'inline', reason }` (gate ladder: enabled → Claude → execution_backend ≠ inline → host dispatch nested+background → valid Agent SDK → SDK ≥ floor; every miss degrades to `inline`, never throws); `emitWorkflowScript({ phaseDir, waves, runId, budgetTokens?, executorModel? }) → { ok, script, summary }` mapping waves → `parallel()` stage barriers, plans → `agent({ agentType:'gsd-executor', isolation:'worktree', model? })`, `files_modified` overlap → separate sequential stages (greedy first-fit), `resumeFromRunId` wired to the run id, shared `budget(tokens)`; all interpolated identifiers validated script-safe (no `"`,`\`,control chars) and briefs JSON-quoted (review anti-injection). #2686: `executorModel` — resolved for `gsd-executor` from the same config the inline path reads, defaulted by the router so no caller change is needed, `--executor-model` to pin — is emitted per plan and OMITTED when it resolves to `inherit`/empty/whitespace/non-string (#2517: an empty model 404s on runtimes without native tier aliases); a value carrying an unscriptable character is rejected outright (`ok:false`) rather than quoted, because the ADR-1411 provenance header interpolates it into a `//` comment where U+2028/U+2029 would terminate the comment and execute the remainder. `resolveWaveDispatch` forwards it. Registers two loop contributions at WIRED points only (execute:wave:pre/execute:pre are declared but not rendered, same constraint external-job documents): `execute:wave:post into:executor` (Workflow-backend guidance) and `plan:post into:planner` (ultraplan ownership declaration), both `when: claude_orchestration.enabled`, `onError: skip`. Federated config keys (`claude_orchestration.enabled` default false, `execution_backend` enum auto|workflow|inline default auto, `min_agent_sdk_version` string default "0.3.149") live only in the registry — uninstall removes them cleanly. Pre-release versions of the floor compare below GA (SemVer precedence). Restores the wave parallelism + plan-checker + verifier that #853 forces inline on Claude Code; on any runtime lacking the Workflow tool, behaviour is byte-identical to today. BETA v1 ships detection + emission + declarative ultraplan ownership + a `claude-orchestration` command family (`gsd-tools claude-orchestration detect-backend|emit-workflow`, router `gsd-core/bin/lib/claude-orchestration-command-router.cjs` from `src/claude-orchestration-command-router.cts`); full install-profile migration of the ultraplan skill into `skills[]` is a follow-up (CLUSTERS/profile gate). Test anchors: `tests/claude-orchestration.test.cjs`, `tests/claude-orchestration-command-router.test.cjs`.
### Loop Extension Point
A named, stable site on a host loop step (per-step `pre`/`post` plus per-wave in Execute; 12 total) where Capabilities register hooks. Three hook kinds: `step` (runs as its own sequenced unit), `contribution` (injects into the core step's prompt/context), and `gate` (checks and optionally blocks via a declared `blocking` flag). Each hook declares the artifacts it produces and consumes; hook order is derived by topological sort of that produces/consumes graph (capability-id tiebreak), which also defines data flow — file-artifact based, surviving `/clear` and fresh executor contexts. Hooks are surfaced by runtime resolution with concrete projection: the workflow calls a query that resolves the active hooks and returns fully-rendered, ordered markdown for the executor. Failure is default-resilient — a non-gate hook that errors is skipped with a warning; a hook may opt into `onError: halt`. Part of the Capability system. ADR-857 phase 3c ships the registry-consuming query layer: `gsd-core/bin/lib/loop-resolver.cjs` exposes `resolveLoopHooks({ point, registry, config })` (pure, no I/O), `renderLoopHooks(resolved)` (pure markdown renderer), and `cmdLoopRenderHooks(cwd, point, raw, opts)` (I/O entry point); activated via `gsd-tools loop render-hooks ` which emits `{ point, activeHooks[], rendered }`. Activation is driven by `when` (dotted config key resolved against `loadConfig`), with inline literal `__proto__`/`constructor`/`prototype` prototype-pollution guard. The first phase-6 cutovers wiring workflows to this query have landed — ui-phase at `plan:pre` and ui-review at `verify:post` (in `plan-phase.md`/`autonomous.md`); further per-feature cutovers are ongoing.
@@ -361,7 +361,7 @@ Module owning the projection of gsd-core's artifact surfaces (`commands`, `agent
Module owning the graphify integration: tri-state capability gate (`isCapabilityActive('graphify', cwd)` from capability-state.cjs — requires installed AND surfaced AND config-enabled; replaces the former config-only `isGraphifyEnabled` gate, cutover in #1306), disabled response (`disabledResponse`), subprocess helper (`execGraphify`, typed `GRAPHIFY_REASON` enum), presence detection (`checkGraphifyInstalled`), version checking (`checkGraphifyVersion`), query surface (`graphifyQuery` — BFS seed-expand + budget trim), status surface (`graphifyStatus` — node/edge counts, mtime staleness, commit-staleness tri-state via `built_at_commit`/`commits_behind`/`commit_stale`), diff surface (`graphifyDiff` — added/removed/changed nodes+edges), build pre-flight (`graphifyBuild`), snapshot management (`writeSnapshot`). Config leg reads `.planning/config.json:graphify.enabled`; all three legs (install, surface, config) must be active; writes to `.planning/graphs/`. Graph location override (#1825): `graphify.graph_path` in `.planning/config.json` (a path relative to the project root, or absolute) redirects where `graphifyQuery`/`graphifyStatus`/`graphifyDiff` read `graph.json` — so one umbrella-level cross-repo graph serves multiple sibling projects without N drifting mirror copies; the diff snapshot (`.last-build-snapshot.json`) travels with the configured graph (same dir); the auto-update status sidecar stays project-local; `writeSnapshot` honors the key (reads the configured graph, writes the snapshot alongside it); build stays project-scoped (`.planning/graphs/`) since the build skill hardcodes that destination — the umbrella graph is built in the umbrella project and sub-projects only READ it. Unset/blank/non-string → byte-identical `.planning/graphs/graph.json` default; a configured-but-missing file yields an actionable error naming the path. The key is registered in `config-schema.manifest.json` `validKeys`. Auto-update hook (`hooks/gsd-graphify-update.sh`) triggers a detached background rebuild after HEAD-advancing git operations on the default branch when `graphify.auto_update=true`. Status file `.planning/graphs/.last-build-status.json` carries `{ ts, status, exit_code, duration_ms, head_at_build, graphify_version }`. Graph IR uses `nodes[]`, `edges[]` (or `links[]` for graphify ≥0.7 compat), `hyperedges[]`, `built_at_commit`. `commit_stale` is tri-state: `false` (known fresh), `true` (stale), `null` (unknown — no git or pre-v0.7 graph). Source: `gsd-core/bin/lib/graphify.cjs`. Skill: `commands/gsd/graphify.md`.
### Intel Module
-Module owning the code-intelligence store: tri-state capability gate (`isCapabilityActive('intel', cwd)` from capability-state.cjs — honours installed+surfaced+config-enabled; replaces the former config-only `isIntelEnabled` gate, cutover in #1307; intel has `skills:[]` so installed/surfaced are vacuously true and the effective gate is `intel.enabled` in config), disabled response, query surface (`intelQuery` — full-text search across all intel JSON files), status surface (`intelStatus` — per-file freshness, 24-hour staleness threshold), diff surface (`intelDiff` — added/changed/removed files vs last-refresh snapshot), snapshot management (`saveRefreshSnapshot`/`intelSnapshot`), validation (`intelValidate` — existence, JSON validity, _meta.updated_at recency), api-surface render (`intelApiSurface` — generates `.planning/intel/API-SURFACE.md` from `api-map.json`), plus ungated utilities (`intelPatchMeta` — patches `_meta.updated_at` in any JSON file; `intelExtractExports` — extracts CJS/ESM exports from any JS file). Loop hook rendering gates on `state.active` (not `state.enabled`) so the `activationKey` config gate is honoured even without a per-hook `when` guard (Phase 4 tri-state alignment, #1307). Source: `gsd-core/bin/lib/intel.cjs` (generated from `src/intel.cts`). Router: `gsd-core/bin/lib/intel-command-router.cjs`. See Capability Command Family Module (ADR-959 4d-impl-4) and Loop Extension Point.
+Module owning the code-intelligence store: tri-state capability gate (`isCapabilityActive('intel', cwd)` from capability-state.cjs — honours installed+surfaced+config-enabled; replaces the former config-only `isIntelEnabled` gate, cutover in #1307; intel has `skills:[]` so installed/surfaced are vacuously true and the effective gate is `intel.enabled` in config), disabled response, query surface (`intelQuery` — full-text search across all intel JSON files), status surface (`intelStatus` — per-file freshness, 24-hour staleness threshold), diff surface (`intelDiff` — added/changed/removed files vs last-refresh snapshot), snapshot management (`saveRefreshSnapshot`/`intelSnapshot`), validation (`intelValidate` — existence, JSON validity, _meta.updated_at recency), api-surface render (`intelApiSurface` — generates `.planning/intel/API-SURFACE.md` from `api-map.json`), plus ungated utilities (`intelPatchMeta` — patches `_meta.updated_at` in any JSON file; `intelExtractExports` — extracts CJS/ESM exports from any JS file). Loop hook rendering gates on `state.active` (not `state.enabled`) so the `activationKey` config gate is honoured even without a per-hook `when` guard (Phase 4 tri-state alignment, #1307). Source: `gsd-core/bin/lib/intel.cjs`. Router: `gsd-core/bin/lib/intel-command-router.cjs`. See Capability Command Family Module (ADR-959 4d-impl-4) and Loop Extension Point.
### Research Module
The GSD-RESEARCH capability behind an L2-hybrid seam: code owns cache + provider policy + package legitimacy; MCP owns the actual fetch. Reachable via `gsd-tools query research-plan|research-store|package-legitimacy`. Source: `src/research-{store,provider}.cts` + `src/package-legitimacy.cts` (generated to `gsd-core/bin/lib/*.cjs` per ADR-457). Replaces the prose provider-waterfall duplicated across the researcher agents and the pip-install `slopcheck` bolt-on.
@@ -372,13 +372,12 @@ The GSD-RESEARCH capability behind an L2-hybrid seam: code owns cache + provider
- `GSD-RESEARCH.INTEGRATION.L2-hybrid=code owns cache+legitimacy+confidence+provider-pick (gsd-tools query research-plan/research-store/package-legitimacy); MCP owns the fetch; agent returns RESEARCH.md path, never raw fetches`
- `GSD-RESEARCH.PROVIDER.availability=config flags brave_search/exa_search/firecrawl/tavily_search/ref_search/perplexity/jina (env _API_KEY or ~/.gsd/_api_key); context7/jina/websearch always available; planResearch falls through waterfall to websearch terminal`
- `GSD-RESEARCH.CONTEXT-DISCIPLINE=less-context levers: subagent isolation + compact provider output + fetches-to-disk + cache-returns-digest; API clear_tool_uses/memory tool are the conceptual model, not a Claude Code harness knob`
-- `DEFECT.RESEARCH-PROVIDER-PROSE-DRIFT=provider waterfall duplicated across N researcher agent .md files drifts independently (META.RULE.brief-no-paraphrase); fix-forward=research-provider.cjs single source of truth + generated agents (#657)`
### UAT-Passed Predicate
-Runtime-neutral predicate evaluating `*-UAT.md` / `*-VERIFICATION.md` result fields with markdown-aware parsing that ignores false-positive contexts (frontmatter body, fenced code, HTML comments, blockquotes). Returns `passed: true` only when all required checks pass; supports `--require-verification` to demand at least one VERIFICATION.md file alongside UAT results. Output envelope: `{ passed, uat_files[], verification_files[], checks[], blockers[], policy }`. Source: `gsd-core/bin/lib/uat-predicate.cjs` (generated from `src/uat-predicate.cts`). Wired via `phase uat-passed` alias → `phase-command-router` → `cmdPhaseUatPassed`.
+Runtime-neutral predicate evaluating `*-UAT.md` / `*-VERIFICATION.md` result fields with markdown-aware parsing that ignores false-positive contexts (frontmatter body, fenced code, HTML comments, blockquotes). Returns `passed: true` only when all required checks pass; supports `--require-verification` to demand at least one VERIFICATION.md file alongside UAT results. Output envelope: `{ passed, uat_files[], verification_files[], checks[], blockers[], policy }`. Source: `gsd-core/bin/lib/uat-predicate.cjs`. Wired via `phase uat-passed` alias → `phase-command-router` → `cmdPhaseUatPassed`.
### Coverage Metadata Module
-Deterministic classifier for the per-deliverable coverage RTM on SUMMARY.md (#1602). Parses the optional `coverage:` frontmatter block (a list-of-maps-with-nested-list-of-maps that `extractFrontmatter` cannot represent — so a dedicated indentation parser, sibling of `parseMustHavesBlock`), validates each entry's schema, and classifies each into `auto_passed` (deterministically covered) vs `present` (human UAT required). Output envelope: `{ mode, summary_file, total, all_auto_covered, auto_passed[], present[], errors[] }` with frozen `MODE`/`PRESENT_REASON`/`ERROR_CODE` enums. Auto-pass requires the narrow proven case (strict-boolean `human_judgment:false` AND non-empty all-`pass` verification AND zero errors); everything else, including a malformed entry, routes to `present` (fail-safe — never drops a deliverable, never false-auto-passes). `mode:legacy` (absent block) ⇒ caller falls back to prose `## Accomplishments` extraction, byte-identical for un-migrated phases. Source: `gsd-core/bin/lib/coverage.cjs` (generated from `src/coverage.cts`). Wired via `uat classify-coverage --summary ` → `cmdClassify`; authored by `execute-plan` create_summary, consumed by `verify-work` extract_tests. See `RULESET.WORKFLOW.COVERAGE-METADATA`.
+Deterministic classifier for the per-deliverable coverage RTM on SUMMARY.md (#1602). Parses the optional `coverage:` frontmatter block (a list-of-maps-with-nested-list-of-maps that `extractFrontmatter` cannot represent — so a dedicated indentation parser, sibling of `parseMustHavesBlock`), validates each entry's schema, and classifies each into `auto_passed` (deterministically covered) vs `present` (human UAT required). Output envelope: `{ mode, summary_file, total, all_auto_covered, auto_passed[], present[], errors[] }` with frozen `MODE`/`PRESENT_REASON`/`ERROR_CODE` enums. Auto-pass requires the narrow proven case (strict-boolean `human_judgment:false` AND non-empty all-`pass` verification AND zero errors); everything else, including a malformed entry, routes to `present` (fail-safe — never drops a deliverable, never false-auto-passes). `mode:legacy` (absent block) ⇒ caller falls back to prose `## Accomplishments` extraction, byte-identical for un-migrated phases. Source: `gsd-core/bin/lib/coverage.cjs`. Wired via `uat classify-coverage --summary ` → `cmdClassify`; authored by `execute-plan` create_summary, consumed by `verify-work` extract_tests. See `RULESET.WORKFLOW.COVERAGE-METADATA`.
### Eval Scoring Module
Deterministic eval-scoring projection (#10 / #1579) that moves the `gsd-eval-auditor`'s weighted arithmetic out of the prompt into code. `computeEvalScore(covered, total, infra[])` returns `{ coverage_score, infra_score, overall_score, verdict }` — coverage = `covered/total*100`, infra = mean of per-item weights (`ok`=1, `partial`=0.5, `missing`=0) over exactly 5 items, `overall = coverage*0.6 + infra*0.4` (2-dp rounding), verdict banded at 80/60/40 (`PRODUCTION READY` / `NEEDS WORK` / `SIGNIFICANT GAPS` / `NOT IMPLEMENTED`). `cmdEvalScore` is the CLI guard: rejects empty/NaN flags, `infra.length !== 5`, and out-of-domain counts (requires `0 <= covered <= total`). Pure arithmetic — no `.planning/` access (it is in `SKIP_ROOT_RESOLUTION`), no `Date.now`/`Math.random`. Wired via the `eval.score` verb (and the `eval score` spaced alias) → `eval-command-router` → `cmdEvalScore`; consumed by `gsd-eval-auditor`. Source of truth: `gsd-core/bin/lib/eval.cjs` (generated from `src/eval.cts`, gitignored per ADR-457). Tests: `tests/eval.test.cjs`, `tests/eval.property.test.cjs`.
@@ -402,7 +401,7 @@ The orthogonal `verification` dimension a resolved probe item carries alongside
The ownership seam between the prohibition probe and security/compliance tooling (ADR-550 D6). The probe owns **bespoke** product/values prohibitions — the unwritten must-NOTs specific to this feature's intent (e.g. "the streak reminder must not manipulate the user into returning"). When precision classifies an item as a **canon** security/compliance concern (OWASP / GDPR / fairness / prototype-pollution / path-traversal — the codified, cross-project rule sets), the probe does **not** mint a SPEC prohibition: it emits a one-line breadcrumb (*"possible canon-security concern X — owned by `/gsd:secure-phase` / eslint"*) and stops. Canon checks are **referred, not duplicated** — keeping the surfaced list short (#644's ~2–3-item precision goal) and the secure-phase boundary explicit. See Prohibition Probe Module.
### Prohibition Probe Module
-Second adapter of the Probe Core Module (ADR-550 Decision 7): the spec-phase prohibition-completeness probe wired into spec-phase Step 5.6, surfacing the unwritten *must-NOT* constraints (values/safety/ethics) the spec never forbids. Unlike the Edge Probe, recall is **prose-orchestrated, not a compiled engine** (ADR-550 D7b) — a two-stage pass per requirement: Stage 1 an adversarial recall question, Stage 2 a one-pass precision classifier (drop routine engineering, keep genuine prohibitions). The code surface is schema/projection only: `projectProhibitions()` (deterministic SPEC↔`must_haves.prohibitions` projection backing the `DEFECT.GENERATIVE-FIX` parity assertion), the `{test, judgment}` `PROHIBITION_VALIDATORS`, `validateProhibitionResolution`, and `dispositionForProhibition()` (the fail-closed default — an unwired `test`-tier item resolves to `unverified`/flagged, never green). **Deterministic test-tier locate (#1278, ADR-550 D3 addendum):** a resolved `test`-tier prohibition MAY carry an optional flat-scalar `check` descriptor — `check_kind` (`node-test` | `lint-rule`), `check_target`, and `check_rule` (lint-rule only) — that `projectProhibitions` emits into `must_haves.prohibitions` when well-formed, and `descriptorFromProjection()` (the read-back seam in the #1259 enforcement producer, `src/prohibition-enforcement.cts`) reconstructs into a `{kind, target, rule?}` `CheckDescriptor`, so verify-phase locates the wired check with zero LLM/author authoring. **Flat scalars, never a nested `check:{}` object** — so the round-trip rides the *unchanged* shared `parseMustHavesBlock` (the #644 no-parser-rewrite precedent); an absent/partial descriptor falls through to the producer's existing fail-closed locate, and `failFirst` stays caller-attested (machine-proof is #1279). No `proposeProhibitions()` — recall is LLM prose. `plan-phase` lifts every resolved prohibition from the SPEC `## Prohibitions (must-NOT)` section into the `must_haves.prohibitions` sibling block (never `truths`). Exports (locked surface): `projectProhibitions`, `PROHIBITION_VALIDATORS` (the `{test, judgment}` validators bundle injected into `probe-core`'s generic engine), `validateProhibitionResolution`, and `dispositionForProhibition` (the fail-closed disposition) — the prohibition adapter surface, shipped from `probe-core` alongside the generic engine. Source of truth: `gsd-core/bin/lib/probe-core.cjs` (the prohibition exports live in `src/probe-core.cts`, gitignored per ADR-457) + `gsd-core/references/prohibition-probe.md`. Tests: `tests/prohibition-probe.*.test.cjs`. See ADR-550, Probe Core Module, Edge Probe Module, Verification Tier, Bespoke vs Canon Prohibition.
+Second adapter of the Probe Core Module (ADR-550 Decision 7): the spec-phase prohibition-completeness probe wired into spec-phase Step 5.6, surfacing the unwritten *must-NOT* constraints (values/safety/ethics) the spec never forbids. Unlike the Edge Probe, recall is **prose-orchestrated, not a compiled engine** (ADR-550 D7b) — a two-stage pass per requirement: Stage 1 an adversarial recall question, Stage 2 a one-pass precision classifier (drop routine engineering, keep genuine prohibitions). The code surface is schema/projection only: `projectProhibitions()` (deterministic SPEC↔`must_haves.prohibitions` projection backing the `RULESET.GENERATIVE-FIX` parity assertion), the `{test, judgment}` `PROHIBITION_VALIDATORS`, `validateProhibitionResolution`, and `dispositionForProhibition()` (the fail-closed default — an unwired `test`-tier item resolves to `unverified`/flagged, never green). **Deterministic test-tier locate (#1278, ADR-550 D3 addendum):** a resolved `test`-tier prohibition MAY carry an optional flat-scalar `check` descriptor — `check_kind` (`node-test` | `lint-rule`), `check_target`, and `check_rule` (lint-rule only) — that `projectProhibitions` emits into `must_haves.prohibitions` when well-formed, and `descriptorFromProjection()` (the read-back seam in the #1259 enforcement producer, `src/prohibition-enforcement.cts`) reconstructs into a `{kind, target, rule?}` `CheckDescriptor`, so verify-phase locates the wired check with zero LLM/author authoring. **Flat scalars, never a nested `check:{}` object** — so the round-trip rides the *unchanged* shared `parseMustHavesBlock` (the #644 no-parser-rewrite precedent); an absent/partial descriptor falls through to the producer's existing fail-closed locate, and `failFirst` stays caller-attested (machine-proof is #1279). No `proposeProhibitions()` — recall is LLM prose. `plan-phase` lifts every resolved prohibition from the SPEC `## Prohibitions (must-NOT)` section into the `must_haves.prohibitions` sibling block (never `truths`). Exports (locked surface): `projectProhibitions`, `PROHIBITION_VALIDATORS` (the `{test, judgment}` validators bundle injected into `probe-core`'s generic engine), `validateProhibitionResolution`, and `dispositionForProhibition` (the fail-closed disposition) — the prohibition adapter surface, shipped from `probe-core` alongside the generic engine. Source of truth: `gsd-core/bin/lib/probe-core.cjs` (the prohibition exports live in `src/probe-core.cts`, gitignored per ADR-457) + `gsd-core/references/prohibition-probe.md`. Tests: `tests/prohibition-probe.*.test.cjs`. See ADR-550, Probe Core Module, Edge Probe Module, Verification Tier, Bespoke vs Canon Prohibition.
### Spec-Section Helper Module
The SINGLE source of truth for "did the phase SPEC supply section X (with at least one resolved row)?" — the SPEC-section detection seam consumed by `plan-phase` Step 7.95 (the spec-less probe fallback) to decide, per section, whether to run the fallback. Replaces the ad-hoc `awk` that previously lived in the workflow body, which hard-coded the section header strings at the call site and hand-rolled markdown-table row counting — a brittleness that produced two bugs: an exact `^## Prohibitions$` anchor that missed the canonical `## Prohibitions (must-NOT)` heading, and a single-table row-counting assumption. **Suffix-tolerant header invariant:** `SECTION_HEADERS` regexes match a heading AND any parenthetical/whitespace suffix — `prohibitions` matches both `## Prohibitions` and `## Prohibitions (must-NOT)`; `edges` matches `## Edge Coverage` (and any future suffix); if spec-phase renames a heading, update HERE and the `templates/spec.md` heading together (the contract is pinned by `tests/spec-section.test.cjs`). **Supply rule:** `supplied = present AND dataRows > 0` — a present-but-empty section is NOT supplied (it triggers the fallback). **Multi-table robustness:** a blank or prose line resets the per-table state, so a section with multiple tables (or prose between them) counts every table's data rows without miscounting a second table's header row; the `|…|` line before a `|---|` separator is the table header row and is never counted. **Fail-safe:** a missing/unreadable SPEC file resolves to `present:false` / `supplied:false` (so the fallback fires) rather than throwing. Exports (locked surface): the `SpecSectionKey` type (`edges | prohibitions`), `SECTION_HEADERS` (the canonical header matchers), the `SectionStatus` shape (`{ key, present, dataRows, supplied }`), `countSectionDataRows` (pure `specText → { present, dataRows }`), and `specSectionStatus` (disk-reading wrapper). CLI: `node spec-section.cjs ` prints `SectionStatus` JSON — exit 0 on success (an absent file is a valid "not supplied" answer), exit 2 only on a usage error (missing args / bad key). Pure and dependency-free. Source of truth: `gsd-core/bin/lib/spec-section.cjs` (generated from `src/spec-section.cts`, gitignored per ADR-457). Tests: `tests/spec-section.test.cjs`. See Edge Probe Module, Prohibition Probe Module, and `references/specless-probe-fallback.md`.
@@ -778,153 +777,10 @@ The prompt-level data/instruction isolation seam for untrusted web/document ingr
## Defect anti-patterns and fix-forwards
-`DEFECT.SCOPE.window=PRs #3306..#3325 + sibling fixes #3240/#3242/#3245/#3257/#3261/#3267/#3286/#3287`
-`DEFECT.FORMAT=class.sub-key=value | classes are greppable; each class carries detect / fix / anchor sub-keys when applicable`
-
-`DEFECT.REMOVED-BUT-NEEDED.symptom=file/key removed because "no longer used" without verifying every consumer (workflows, docs, manifests, npm scripts)`
-`DEFECT.REMOVED-BUT-NEEDED.examples=#3316 root package-lock.json (root package.json declares deps; workflows use cache:'npm' + npm ci), e3b52c70 docs referenced removed /gsd-new-workspace`
-`DEFECT.REMOVED-BUT-NEEDED.detect=before deletion, grep filename across .github/workflows, gsd-core/, docs/, package.json scripts; if any reference exists removal is incomplete`
-`DEFECT.REMOVED-BUT-NEEDED.fix-forward=restore the file or update every consumer in the same commit; do not paper over with --no-package-lock or workflow workarounds that lose reproducibility`
-
-`DEFECT.STATE-TRAMPLE.symptom=state-mutation paths overwrite curated values when body-derived computation is narrower than what's stored in frontmatter`
-`DEFECT.STATE-TRAMPLE.examples=#3242 (Last Activity overwrote progress.completed_plans), #3257 (nested plans/ files uncounted), #3261 (buildStateFrontmatter), #3265 (canonical fields), #3286 (record-metric/add-decision sections)`
-`DEFECT.STATE-TRAMPLE.detect=any state writer that calls buildStateFrontmatter without preserving existing progress.* keys; any mutation surface that does not honor shouldPreserveExistingProgress`
-`DEFECT.STATE-TRAMPLE.fix-forward=route through state-document.cjs/.ts shouldPreserveExistingProgress + normalizeProgressNumbers (extracted in #3316; the sdk/ tree that PR originally targeted has since been fully retired per ADR-0174 — these functions now live solely in src/state-document.cts)`
-
-`DEFECT.PHASE-DIR-PREFIX-DRIFT.symptom=multiple workflow files independently construct .planning/phases/{NN}-{slug} paths; project_code prefix or slug normalization missing in some surfaces`
-`DEFECT.PHASE-DIR-PREFIX-DRIFT.examples=#3287 (init.phase-op + init.plan-phase first-touch), #3306/PRED.k015 (plan-milestone-gaps + import + add-backlog), #3297/#3298 (sibling reports)`
-`DEFECT.PHASE-DIR-PREFIX-DRIFT.detect=grep mkdir/touch/path.join with {NN}-{slug} or padded_phase + phase_slug; if not consuming expected_phase_dir from init.* JSON it is drifting`
-`DEFECT.PHASE-DIR-PREFIX-DRIFT.fix-forward=consume expected_phase_dir from init.phase-op / init.plan-phase output; never re-construct from padded_phase + slug in workflow steps`
-`DEFECT.PHASE-DIR-PREFIX-DRIFT.anchor=tests/phase.test.cjs (expected_phase_dir assertions; consolidated from tests/bug-3298-phase-dir-prefix-drift-in-workflows.test.cjs into the Phase Lifecycle Module test suite in #3741)`
-
-`DEFECT.STACKED-PR-AUTO-RETARGET.symptom=PR #N is stacked on branch B; branch B merges to main and is deleted; GitHub does not reliably auto-retarget #N to main; PR shows DIRTY/CONFLICTING with phantom conflicts`
-`DEFECT.STACKED-PR-AUTO-RETARGET.examples=#3311 base fix/3255-add-json-errors-mode-gsd-tools deleted after #3304 merged`
-`DEFECT.STACKED-PR-AUTO-RETARGET.detect=ls-remote shows base ref absent; PR base still points at the deleted ref; mergeable=CONFLICTING with no real diff conflicts`
-`DEFECT.STACKED-PR-AUTO-RETARGET.fix-forward=PATCH /repos/{owner}/{repo}/pulls/{N} -f base=main; rebase head onto current main; resolve carry-over commits (parent commits will auto-drop as patch contents already upstream)`
-
-`DEFECT.BOT-BRANCH-STALE-BASE.symptom=auto-branch.yml creates fix/{N}-{slug} when issue is filed; branch is anchored to issue-creation main; by the time work begins, main has moved`
-`DEFECT.BOT-BRANCH-STALE-BASE.examples=#3309 fix/3309-checkpoint-type-human-verify-burns-token (was at e14ef535; main at 2e87c60a)`
-`DEFECT.BOT-BRANCH-STALE-BASE.detect=git merge-base origin/ origin/main returns the bot branch tip — confirms the bot branch is an ancestor of main, just stale`
-`DEFECT.BOT-BRANCH-STALE-BASE.fix-forward=git checkout --detach origin/main; do work; git checkout -b ; force-push with --force-with-lease`
-
-`DEFECT.SUPERSEDED-CONCURRENT-PRS.symptom=multiple in-flight PRs attack overlapping subsets of the same issue; the broadest one merges first; narrower siblings remain open with phantom conflicts`
-`DEFECT.SUPERSEDED-CONCURRENT-PRS.examples=#3303 + #3307 superseded by #3306 (all addressing #3297/#3298 project_code prefix family)`
-`DEFECT.SUPERSEDED-CONCURRENT-PRS.detect=after a fix lands on main, grep recently-merged PR title for shared keyword/issue; check open PRs touching same files; if open PRs are subsets of merged work they are superseded`
-`DEFECT.SUPERSEDED-CONCURRENT-PRS.fix-forward=close superseded PRs via gh api PATCH state=closed; do not comment on self-authored PRs (k101); the link to the merged PR makes supersession discoverable in PR history`
-
-`DEFECT.PROMPT-INJECTION-SCAN-COLLISION.symptom=custom XML element name in agent .md file matches scripts/scan-prompt-injection regex; legitimate agent vocabulary trips the security gate`
-`DEFECT.PROMPT-INJECTION-SCAN-COLLISION.examples=#3309 added a bare 'human' element (angle-bracket-wrapped) for verify-block harvesting; tests/prompt-injection-scan.security.test.cjs flags angle-bracket-wrapped names matching system|assistant|human (open or close form)`
-`DEFECT.PROMPT-INJECTION-SCAN-COLLISION.detect=any new bare tag in agents/*.md`
-`DEFECT.PROMPT-INJECTION-SCAN-COLLISION.fix-forward=hyphenate the tag (, ) — scanner regex matches bare names only`
-
-`DEFECT.INVENTORY-DRIFT.symptom=new file added under gsd-core/references/ or gsd-core/workflows/ without updating docs/INVENTORY.md row AND docs/INVENTORY-MANIFEST.json`
-`DEFECT.INVENTORY-DRIFT.examples=#3309 planner-human-verify-mode.md (caught by tests/inventory-manifest-sync.test.cjs)`
-`DEFECT.INVENTORY-DRIFT.detect=tests/inventory-manifest-sync.test.cjs fails with "New surfaces not in manifest"; tests/inventory-headings-countfree.test.cjs fails if a (N shipped) count is re-added to a heading`
-`DEFECT.INVENTORY-DRIFT.fix-forward=update INVENTORY.md row entry; run node scripts/gen-inventory-manifest.cjs --write to regen INVENTORY-MANIFEST.json (all eight families.* arrays are canonical — see RULESET.MANIFEST-CANONICAL-KEY); a workflow SUB-file (gsd-core/workflows//steps/*.md or modes/*.md) lands in workflow_steps/workflow_modes, not in workflows, which is keyed by bare basename and cannot hold a nested path`
-
-`DEFECT.AGENT-FILE-SIZE-CAP-BREACH.symptom=adding to agents/gsd-planner.md (or other large agent files) exceeds the 45K char extraction-evidence threshold`
-`DEFECT.AGENT-FILE-SIZE-CAP-BREACH.state=gsd-planner.md is 49,125 chars on main, just under the test's actual PLANNER_EXTRACTED_LIMIT of 48K (49,152 chars — the test's own title still says "45K" but the enforced constant was raised in #2341); the test currently passes, but any further net-new content risks pushing it over`
-`DEFECT.AGENT-FILE-SIZE-CAP-BREACH.detect=tests/planner-decomposition.test.cjs ("planner is under 45K chars (proves mode sections were extracted)") and tests/reachability-check.test.cjs ("file stays under 50000 char limit")`
-`DEFECT.AGENT-FILE-SIZE-CAP-BREACH.fix-forward=mirror MVP mode pattern — extract full rules to gsd-core/references/planner-.md, leave a slim Detection section in the agent file with @-reference to the new file`
-
-`DEFECT.CHANGESET-PR-FIELD-DRIFT.symptom=.changeset/*.md frontmatter pr: value is the issue number, a guess made before PR opened, or a stale stacked-PR number`
-`DEFECT.CHANGESET-PR-FIELD-DRIFT.examples=#3316 (pr:3312 was the issue), #3325 (pr:3319 was a guess); recurs every cycle`
-`DEFECT.CHANGESET-PR-FIELD-DRIFT.detect=changeset pr: value mismatches the actual PR number returned by gh api POST /pulls`
-`DEFECT.CHANGESET-PR-FIELD-DRIFT.fix-forward=author changeset with placeholder pr:0; immediately after gh api POST /pulls returns the number, edit changeset and amend or follow-up commit; never guess`
-
-`DEFECT.WORKTREE-FETCH-SHA-DIVERGENCE.symptom=in a worktree, git fetch origin pull/N/head:pr-N produces commits with SHAs different from the actual remote PR head SHA; force-push rejected as non-fast-forward despite recent fetch`
-`DEFECT.WORKTREE-FETCH-SHA-DIVERGENCE.examples=this session, branch fix/3309-... and pr-3316`
-`DEFECT.WORKTREE-FETCH-SHA-DIVERGENCE.detect=git rev-parse HEAD~1 vs git rev-parse origin/ — if they differ despite fetch the local copy was rewritten by some checkout-time hook`
-`DEFECT.WORKTREE-FETCH-SHA-DIVERGENCE.fix-forward=git checkout --detach origin/ directly; do work from detached HEAD; push HEAD:`
-
-`DEFECT.WINDOWS-FS-OPS.symptom=fs.renameSync / fs.copyFileSync hits EPERM/EBUSY on Windows when antivirus or another process holds a transient handle on the target`
-`DEFECT.WINDOWS-FS-OPS.examples=c47c2c5d build-hooks rename → copy fallback, d2412271 install Windows persistent SDK shim`
-`DEFECT.WINDOWS-FS-OPS.detect=ADR-1703 Phase 6: enforced by local/require-fs-op-fallback (AST ESLint rule, error) over src/**/*.cts + bin/install.js + scripts/build-hooks.js — flags an unguarded fs.rename/fs.renameSync (the atomic-publish primitive named in .symptom) that lacks a transient-errno retry or a Windows platform guard; a catch that silently swallows or cleans-up-and-rethrows without an errno check does NOT satisfy the .fix-forward clause. copyFile/unlink are the fallback primitives (out of scope); delegated retry helpers (retryRenameSync from shell-command-projection) are the recognized compliant shape`
-`DEFECT.WINDOWS-FS-OPS.fix-forward=catch EPERM/EBUSY/EACCES, fall back to copy + unlink with retry, surface degraded-mode message; never silently swallow; the canonical production cure is retryRenameSync (shell-command-projection.cjs) or a bounded RENAME_RETRY_ERRNOS = new Set(['EPERM','EBUSY','EACCES']) loop`
-
-`DEFECT.UNBOUNDED-SUBPROCESS.symptom=git/npm subprocess shelled out without timeout; CLI hangs indefinitely on stuck remote, large repo, or missing network`
-`DEFECT.UNBOUNDED-SUBPROCESS.examples=a33cbe72 worktree fix bound git subprocesses with timeout`
-`DEFECT.UNBOUNDED-SUBPROCESS.detect=execSync/execFileSync/spawnSync without timeout option in non-test code; especially git list-worktrees, git fetch, npm view`
-`DEFECT.UNBOUNDED-SUBPROCESS.fix-forward=add timeout (5-30s for git, 60s for npm); on timeout return degraded result + structured warning rather than throw`
-
-`DEFECT.PARSER-BRITTLE-MARKER-WHITELIST.symptom=human-output parser whitelists known markers (severity, status); silently drops unfamiliar markers as malformed`
-`DEFECT.PARSER-BRITTLE-MARKER-WHITELIST.examples=ac518646/#3263 code-review SUMMARY parser rejected BL-/blocker variants`
-`DEFECT.PARSER-BRITTLE-MARKER-WHITELIST.detect=any parser with hard-coded marker list; any parser that returns empty for non-matching input without warning`
-`DEFECT.PARSER-BRITTLE-MARKER-WHITELIST.fix-forward=accept variants explicitly (case-insensitive, hyphen/space alternatives); on unknown marker emit a structured WARN with the original line so the human can fix the source`
-
-`DEFECT.HALT-COST-PATTERN.symptom=architecturally-sound checkpoint pattern produces hidden token cost because subagent context is discarded across the pause and respawn`
-`DEFECT.HALT-COST-PATTERN.examples=#3309 checkpoint:human-verify (mid-flight halt = full executor cold-start per round-trip; reporter measured "tens of thousands of tokens" per halt)`
-`DEFECT.HALT-COST-PATTERN.detect=any subagent-spawning workflow with mid-flight pause-and-resume that does not preserve subagent context`
-`DEFECT.HALT-COST-PATTERN.fix-forward=offer config flag for end-of-phase aggregation; if cost dominates make end-of-phase the default; route deferred items through existing verifier surface, do not invent new writer`
-
-`DEFECT.HOOK-OVER-ENFORCEMENT.symptom=PreToolUse hook keeps blocking gh pr edit / gh issue edit even after all required files are read in the session`
-`DEFECT.HOOK-OVER-ENFORCEMENT.examples=this session repeatedly hit "Refusing to run gh issue create|edit / gh pr create|edit" despite reading every listed file`
-`DEFECT.HOOK-OVER-ENFORCEMENT.detect=hook re-fires on each invocation regardless of session-state read receipts`
-`DEFECT.HOOK-OVER-ENFORCEMENT.fix-forward=use gh api -X PATCH repos/{owner}/{repo}/pulls/{N} or repos/{owner}/{repo}/issues/{N} directly — same effect, hook regex does not match`
-
-`DEFECT.DEFAULT-FLIP-DOCUMENTATION.symptom=PR flips a config default but does not call out the migration semantics (when does the new default take effect; existing configs vs new configs; what the opt-back-in looks like)`
-`DEFECT.DEFAULT-FLIP-DOCUMENTATION.examples=#3309 v2 default flip from mid-flight to end-of-phase`
-`DEFECT.DEFAULT-FLIP-DOCUMENTATION.detect=any PR that changes a default value in CONFIG_DEFAULTS or buildNewProjectConfig; check that PR body Breaking Changes section explicitly covers (a) when the new default takes effect, (b) opt-back-in command, (c) effect on in-flight artifacts`
-`DEFECT.DEFAULT-FLIP-DOCUMENTATION.fix-forward=template — "new default takes effect when .planning/config.json is rewritten (config-set, fresh project, regenerated config); existing artifacts continue to work; opt-back-in: gsd config-set "`
-
-`DEFECT.SOURCE-GREP-IN-NEW-TESTS.symptom=new test file uses readFileSync + .includes() / .match() against source code (RULESET.TESTS.no-source-grep); contradicts the test rule lint script`
-`DEFECT.SOURCE-GREP-IN-NEW-TESTS.detect=npm run lint (AST ESLint rule local/no-source-grep, eslint-rules/no-source-grep.cjs) fails with a line-number-precise violation`
-`DEFECT.SOURCE-GREP-IN-NEW-TESTS.fix-forward=replace with runGsdTools(...) behavioral test capturing JSON; if asserting agent .md content (which IS the runtime contract) add // allow-test-rule: source-text-is-the-product with one-line justification`
-
-`DEFECT.GENERATIVE-PRIORITY=these defect classes share a common root: parallel implementations diverge silently because no parity test enforces equality at the test layer`
-`DEFECT.GENERATIVE-FIX=for any new constant/array/parser shared between two parallel surfaces (two workflow surfaces, or a generated artifact and its hand-authored source), the same commit MUST add a parity assertion that fails when the two diverge`
-`DEFECT.GENERATIVE-EXEMPLAR=tests/runtime-launcher-parity.test.cjs (asserts every workflow bash block uses the canonical gsd_run launcher — the in-repo pattern for enforcing equality across parallel surfaces)`
-
-`DEFECT.FRONTMATTER-SCALAR-BROAD-GREP.symptom=a YAML-frontmatter scalar (e.g. VERIFICATION.md status) read with grep "^key:" over the WHOLE markdown report instead of the frontmatter block; a key: line in the body (code block, copied artifact, example) returns extra matches that concatenate after cut|tr into a value matching no expected token, so a valid state is misrouted`
-`DEFECT.FRONTMATTER-SCALAR-BROAD-GREP.examples=#586/PR #650 ship.md verification gate — grep "^status:" also matched body status: lines, yielding passed+gaps_found+human_needed instead of passed and blocking a passed phase; execute-phase.md has since been fixed to the frontmatter-scoped form (#651)`
-`DEFECT.FRONTMATTER-SCALAR-BROAD-GREP.detect=grep "^:" on a *.md whose result is compared to exact tokens, with no frontmatter scoping and no -m1; one body line beginning : is enough to break it`
-`DEFECT.FRONTMATTER-SCALAR-BROAD-GREP.fix-forward=scope to the leading frontmatter block and take the first match: sed -n '/^---$/,/^---$/p' "$f" | grep -m1 "^:" | cut -d: -f2 | tr -d ' '; fix every parallel copy in the same change or consolidate behind one queryable seam (#651)`
-`DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.symptom=a test that parses a workflow bash block out of a *.md and runs it via execFileSync('bash',...) breaks on Windows two ways: the fence regex uses a literal \n after the bash fence that will not match CRLF and is flagged by local/no-crlf-fragile-split (the windows-test-parity-guard ratchet it formerly tripped was deleted in ADR-1703 Phase 4 #1726); and git-bash exists so a bash-presence probe is true, but an os.tmpdir() Windows path (C:\...) is un-globbable in bash so the pipeline returns empty and assertions fail`
-`DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.examples=#586/PR #650 tests/ship-586-verification-routing.test.cjs — the fence \n offender failed ubuntu-24/macos/coverage, then the Windows tmpdir-path glob failed full test (windows-latest,22) at fail 3; both were invisible to file-scoped gsd-test-both runs because the parity guard is only scanned by the full suite`
-`DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.detect=test does readFileSync(md).match for a bash fence with literal \n, OR execFileSync('bash',...) gated only on a bash-presence probe; also verifying a new test with a file-scoped run instead of the full suite hides repo-wide static guards; now enforced at write-time + CI by local/no-crlf-fragile-split (CRLF fence/frontmatter regex + readFileSync split-on-\n) and local/no-unguarded-nonportable-exec (bash+chmod), eslint, ADR-1703`
-`DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.fix-forward=match the fence with \r?\n and normalize the captured block to LF; gate pipeline execution on process.platform !== 'win32' && hasBash since the extraction LOGIC is platform-independent and POSIX coverage suffices; run the full suite (or the parity/lint guards) before push when adding a test file`
-
-`DEFECT.WINDOWS-TEST-PORTABILITY.symptom=local gsd-test runs Mac+Linux only (no Windows host); Windows-only test failures (chmod exec-bit not honored for PATH-executing extension-less scripts in Git Bash msys2; / vs \ path-separator in assertions; Git Bash msys2 shell semantics) surface ONLY in CI test (windows-latest,*) / full test (windows-latest,*) lanes, never locally`
-`DEFECT.WINDOWS-TEST-PORTABILITY.examples=PR #1084 (chmod 0o755 + bare-command execution failed on windows lane); PR #1692 tests/stale-bake-guard.test.cjs resolveAgentDir assertions hardcoded '/H/.config/opencode/agent' forward-slash literals against a path.join return — passed macOS/linux/ubuntu CI (incl. gsd-test docker mirror), failed windows-latest,24 + full test windows-latest,22 shard 2/3; test files that assert path.join result without normalizing to forward slashes`
-`DEFECT.WINDOWS-TEST-PORTABILITY.detect=npm run lint (eslint) runs the local/* AST portability rules (ADR-1703): local/no-unguarded-nonportable-exec flags a test that chmods an exec bit AND runs it via sh/bash -c without a process.platform !== 'win32' guard (the retired scripts/lint-windows-test-portability.cjs tripwire, migrated to AST in #1720); local/no-path-literal-in-assert + local/no-posix-mode-bit-assert cover the assertion shapes; local/no-crlf-fragile-split (CRLF file-content split/regex), local/no-hardcoded-tmp (/tmp literal → os.tmpdir()), local/no-bare-npm-exec (npm needs shell:true on Windows) and local/require-userprofile-with-home (set USERPROFILE alongside HOME) replace the deleted windows-test-parity-guard ratchet (#1726); all are platform-guard-aware with zero opt-out (tests/portability-rule-disable-ban.test.cjs); watch CI windows matrix green before declaring a PR done`
-`DEFECT.WINDOWS-TEST-PORTABILITY.fix-forward=gate platform-specific execution with if (process.platform !== 'win32'); normalize path expectations to forward slashes with .replace(/\\/g, '/'); invoke scripts via explicit interpreter (sh ) rather than relying on exec-bit; there is NO opt-out for the local/* portability rules — structure platform-specific code behind a recognized process.platform !== 'win32' guard (ADR-1703 zero escape hatch)`
-`DEFECT.WINDOWS-TEST-PORTABILITY.prevention=run npm run lint (the local/* AST portability rules, ADR-1703) before opening a PR; treat the CI windows lane as the only true Windows signal — gsd-test (Mac/Linux only) cannot substitute for it`
-
-`DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.symptom=a test writes a file with a POSIX mode (fs.writeFileSync(p, data, {mode: 0o644}) or fs.chmodSync) then asserts fs.statSync(p).mode & 0o777 === ; passes on macOS/Linux/ubuntu CI, FAILS on the windows-latest CI lane — Windows fs does NOT honor POSIX write modes, Node reports the mode derived from the DOS readonly attribute (0o666 for writable / 0o444 for readonly), never the requested 0o644/0o755`
-`DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.examples=#1634/PR #1638 tests/capability-lifecycle.test.cjs "a .cjs hook command is node-prefixed so it runs without the executable bit" failed windows-latest,24 on "precondition: file staged without +x" (expected 420/0o644, got 438/0o666); the node-prefix behavioral assertion was correct — only the mode-bit precondition was the POSIX-only fact`
-`DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.detect=grep tests for \`.mode & 0o777\` / \`.mode) === 0o\` / \`writeFileSync(...{ mode: 0o\` / \`chmodSync\` paired with a strict-equality assertion on the resulting mode; any such assertion is a POSIX-only fact that will diverge on Windows (write reads back as 0o666); NOW mechanically enforced by the AST ESLint rule local/no-posix-mode-bit-assert (eslint-rules/no-posix-mode-bit-assert.cjs, ADR-1703 Phase 2 #1711) — flags a .mode-vs-octal-literal equality assertion unless control-dependent on a process.platform !== 'win32' guard (eslint-rules/lib/platform-guard.cjs); zero opt-outs (tests/portability-rule-disable-ban.test.cjs)`
-`DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.fix-forward=gate the mode-bit precondition on if (process.platform !== 'win32') — the executable-bit/mode is a POSIX concept meaningless on Windows; KEEP the platform-independent behavioral assertion (the actual behavior under test) running on every OS; do NOT delete the precondition, scope it to POSIX`
-`DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.prevention=ref DEFECT.WINDOWS-TEST-PORTABILITY — gsd-test is Mac/Linux only (no Windows host), only the CI windows-latest lane catches this; enforced at write-time + CI by the AST ESLint rule local/no-posix-mode-bit-assert (eslint, error; ADR-1703 Phase 2 #1711); run npm run lint before push; prefer asserting the BEHAVIOR (command shape, runnability) over the filesystem mode bit`
-
-`DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.symptom=path.join() result on Windows (backslashes) substituted verbatim into markdown body (@-references, workflow files, generated docs); content gains mixed separators; cross-platform substring assertions fail on windows-latest CI lane only; macOS/Linux CI green so defect ships undetected`
-`DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.examples=PR #1622 computePathPrefix returned ${resolvedTarget}/ verbatim — rewrites of @~/.claude/gsd-core/commands/gsd/X.md wrote @C:\...\gsd-ial-windsurf-XXX\gsd-core/commands/gsd/help.md (trailing forward slashes from the original literal survived, prefix backslashes did not); tests/install-runtime-artifacts.test.cjs:318 + tests/install.test.cjs:1323 failed on windows-latest only`
-`DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.detect=any function returning a filesystem path that flows into markdown/text body substitution; grep for path.join/raw resolvedTarget/${configDir}/ in code paths writing workflow .md, agent .md, or generated docs; smoke pattern is ${resolvedTarget}/ or ${configDir}/... templates that bypass normalization; NOW enforced at write-time + CI by local/normalize-path-in-content (eslint, error, src/**/*.cts; ADR-1703 Phase 5 #1733) — flags a path-returning fn result (path.basename excluded — returns a separator-less filename) interpolated DIRECTLY into @-reference content (shape a: @~/, @$, @/) or into a template immediately followed by a /…\.md or /…\.json quasi (shape b); INDIRECT data-flow (path stored in a variable/object field then interpolated, e.g. ${entry.ref}) is NOT detected by the rule — normalize at the assignment source or at the emit site; one known indirect leak (src/init.cts cmdAgentSkills entry.ref) fixed in PR #1733 by normalizing at emit; zero opt-out (the out-of-band disable-ban scans src/**/*.cts too)`
-`DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.fix-forward=normalize at the SOURCE not the test: posixTarget=String(resolvedTarget).replace(/\\/g,'/'), posixHome=homeDir?String(homeDir).replace(/\\/g,'/'):homeDir; markdown body is POSIX-only; .replace(/\\/g,'/') is idempotent on POSIX (no backslashes present) so safe to apply unconditionally; isWindowsHost arg is a no-op tripwire (enh-1511) — do NOT branch on it, normalize always`
-`DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.prevention=enforced by local/normalize-path-in-content (eslint, error; ADR-1703 Phase 5 #1733) per RULESET.CONTENT-PATH-NORMALIZATION; tests are downstream signal, never the fix; ref DEFECT.WINDOWS-TEST-PORTABILITY for test-side parity (normalize expected substrings too: ${configDir}/foo.replace(/\\/g,'/'))`
+`RULESET.GENERATIVE-FIX=parallel implementations diverge silently when no parity test enforces equality at the test layer; for any new constant/array/parser shared between two parallel surfaces (two workflow surfaces, or a generated artifact and its hand-authored source), the same commit MUST add a parity assertion that fails when the two diverge; exemplar: tests/runtime-launcher-parity.test.cjs (asserts every workflow bash block uses the canonical gsd_run launcher)`
`RULESET.CONTENT-PATH-NORMALIZATION=filesystem paths substituted into markdown body text (@-references, workflow .md, agent .md, generated docs, command bodies) MUST be normalized to POSIX forward slashes via .replace(/\\/g,'/') at the production source BEFORE substitution; never push normalization to tests; cross-platform content is POSIX-only; applies to: computePathPrefix output, install-path rewrites, generated shim paths emitted into .md bodies; idempotent on POSIX so unconditional; mechanically enforced by local/normalize-path-in-content (eslint, src/**/*.cts; #1733)`
-`DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.symptom=an assertion compares the return value of a path-returning function (resolveAgentDir, path.join, path.resolve, getPathX, computePathPrefix, etc.) to a HARDCODED forward-slash string literal like '/H/.config/opencode/agent' or 'C:/Users/...' — passes on POSIX (macOS/linux/ubuntu CI incl. gsd-test docker mirror, where path.join emits forward slashes so literal == actual), FAILS on windows-latest CI lane where path.join emits backslashes so literal != actual`
-`DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.examples=PR #1692 tests/stale-bake-guard.test.cjs resolveAgentDir suite: assert.equal(resolveAgentDir('opencode',{homedir:()=>'/H'}), '/H/.config/opencode/agent') — green on macOS+ubuntu (docker gate PASS 21101/21101), red on test (windows-latest,24) + full test (windows-latest,22, shard 2/3); same root cause as DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT but on the TEST side against a function return, not the production-markdown side`
-`DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.detect=any assert*/expect call whose ACTUAL operand is a call to a path-returning fn (path.join, path.resolve, resolveAgentDir, getPathX, computePathPrefix, os.homedir(), path.dirname/basename) AND whose EXPECTED operand is a string literal containing '/' that does NOT first flow through .replace(/\\/g,'/'); the literal-vs-fnCall shape is the tripwire — assert.equal(pathFn(...), '/hardcoded/posix/path') is the violation; assert.equal(String(pathFn(...)).replace(/\\/g,'/'), '/hardcoded/posix/path') is the compliant form; NOW mechanically enforced by the AST ESLint rule local/no-path-literal-in-assert (eslint-rules/no-path-literal-in-assert.cjs, ADR-1703 Phase 1 #1707) — platform-guard-aware (won't flag an assertion control-dependent on a process.platform !== 'win32' guard; eslint-rules/lib/platform-guard.cjs), fn list single-sourced as eslint-rules/lib/portability-vocab.cjs PATH_RETURNING_FNS (drift-guarded vs src/runtime-homes.cts)`
-`DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.fix-forward=normalize the ACTUAL value to POSIX before comparing: assert.equal(String(pathFn(...)).replace(/\\/g,'/'), '/posix/literal'). Do NOT instead path.join the expected value to match the platform separator — that passes on every platform but masks a malformed backslash-on-POSIX return (both sides wrong together). The .replace is idempotent on POSIX so it is safe unconditionally. For values that are conceptually never paths (null/undefined/numbers), no normalization needed.`
-`DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.prevention=enforced at write-time (editor) and in CI by the AST ESLint rule local/no-path-literal-in-assert (error, scoped to tests/**/*.test.cjs in eslint.config.mjs; ADR-1703 Phase 1 #1707); inline suppression is banned out-of-band by tests/portability-rule-disable-ban.test.cjs (zero escape hatches — structure platform-specific code behind a recognized process.platform guard, never opt out); run npm run lint before push; treat the CI windows-latest lane as the only true Windows signal — gsd-test (Mac/Linux only) cannot substitute; ref umbrella DEFECT.WINDOWS-TEST-PORTABILITY and production-side analogue DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT`
-
-`DEFECT.PROMPT-INJECTION-SCAN-COLLISION-WITH-TESTS.symptom=scripts/prompt-injection-scan.sh flags a NEW test file as a finding because the test contains real injection payloads as fixtures (strings that match one of the scanner's PATTERNS — see scripts/prompt-injection-scan.sh lines 18-64) to prove the validator under test rejects them; scanner cannot distinguish fixture from real injection; CI security lane fails on the test that ADDS the security validation`
-`DEFECT.PROMPT-INJECTION-SCAN-COLLISION-WITH-TESTS.examples=PR #1622 commit 4ed208e74 added convertClaudeCommandToWindsurfWorkflow commandName validation with 22 malicious-name fixtures; scanner matched an instruction-override phrase at tests/windsurf-conversion.test.cjs:122; CI security lane failed even though the test is the security control`
-`DEFECT.PROMPT-INJECTION-SCAN-COLLISION-WITH-TESTS.detect=CI security lane (Prompt injection scan step) reports FAIL: tests/.test.cjs with a line number pointing at a string literal; the literal is inside an assert.throws() or array of malicious inputs; the test file name is not in scripts/prompt-injection-scan.sh ALLOWLIST`
-`DEFECT.PROMPT-INJECTION-SCAN-COLLISION-WITH-TESTS.fix-forward=ADD the test file to scripts/prompt-injection-scan.sh ALLOWLIST array with a comment citing this defect class; for large fixture sets, move them to tests/fixtures/adversarial/security/ (auto-allowlisted dir) and load via readFileSync; never weaken or fragment the payload to evade the scanner — that defeats the test's purpose; ALSO when documenting this defect in CONTEXT.md, do NOT quote the literal pattern — describe it generically (the scanner scans CONTEXT.md too)`
-`DEFECT.PROMPT-INJECTION-SCAN-COLLISION-WITH-TESTS.prevention=when writing a security regression test that uses real injection payloads as fixtures, immediately add the test file path to scripts/prompt-injection-scan.sh ALLOWLIST in the same commit; when documenting this defect class anywhere under scanner scope (CONTEXT.md, docs/, agent .md), use descriptive references like 'scanner-matching payload' rather than quoting the literal pattern; ref DEFECT.PROMPT-INJECTION-SCAN-COLLISION (the older XML-tag-collision variant)`
-
-`DEFECT.WORKFLOW-DELEGATION-TARGET-NOT-INSTALLED.symptom=workflow wrapper file (e.g. Windsurf convertClaudeCommandToWindsurfWorkflow) delegates to a command body at /gsd-core/commands/gsd/X.md via a hardcoded @~/.claude/gsd-core/commands/gsd/ path that _applyRuntimeRewrites rewrites to the install target; the source gsd-core/ dir ships without commands/ (it lives at package-root commands/gsd/); install completes successfully, workflow files appear in the / menu, but invocation tells the LLM to read a file that does not exist; the slash commands silently fail`
-`DEFECT.WORKFLOW-DELEGATION-TARGET-NOT-INSTALLED.examples=PR #1622 (issue #1615) shipped Windsurf /gsd-* workflow wrappers that all reference /.windsurf/gsd-core/commands/gsd/X.md; that directory was never populated; none of the reviews (security, Codex adversarial, Memtrace) caught it; a #1629 regression test verifying 'every workflow @- reference target exists on disk' surfaced it post-merge`
-`DEFECT.WORKFLOW-DELEGATION-TARGET-NOT-INSTALLED.detect=after install, for every workflow .md file under //workflows/, extract the @ reference from the body and assert fs.existsSync(path); if any reference target is absent, this defect is present`
-`DEFECT.WORKFLOW-DELEGATION-TARGET-NOT-INSTALLED.fix-forward=copy the canonical command source (commands/gsd/*.md) into /gsd-core/commands/gsd/ during install, gated on the runtime that uses workflow delegation (currently Windsurf local only); use copyWithPathReplacement to apply the same path+brand rewrites as the rest of the install; verify with a regression test that every workflow's @-reference resolves`
-`DEFECT.WORKFLOW-DELEGATION-TARGET-NOT-INSTALLED.prevention=any new converter that emits a wrapper file delegating to another file MUST verify the delegation target is actually written by the same install; add a post-install invariant test: for every @ reference in every generated wrapper, assert the target exists; the workflow converter's hardcoded path was copy-pasted from Claude's skill pattern without verifying the target exists for the new runtime`
-
-`DEFECT.HOST-RESERVED-DIR-NAME=a host runtime reserves a directory NAME that GSD also writes verbatim, so the mere presence of GSD's directory trips the host's own reserved-name detection regardless of contents; example: pi (#3023) treats GSD's shared-hooks bundle dir hooks/ as its own deprecated extension location and printed a startup warning purely because checkDeprecatedExtensionDirs() in packages/coding-agent/src/migrations.ts gates on a bare existsSync(hooksDir) with no readdir/emptiness check (unlike its tools/ sibling); fix-forward=make the shared-hooks directory name descriptor-driven (hostBehaviors.sharedHooksDirName, default hooks) and override it per-runtime when a name collision is detected (pi sets gsd-hooks), with adapters probing the new name then falling back to the legacy name for dev/half-upgraded trees`
-
-
---
## Shell Command Projection Module (expanded glossary entry, 2026-05-13)
@@ -960,41 +816,6 @@ Migration: Phases 1-4 (#3465-#3468) shipped 2026-05-13 — seam additions, subpr
`SESSION.2026-05-15.parallel-fix-dispatch=[#3542/PR #3546 prohibit git stash family in executor agents (shared refs/stash across worktrees); #3541/PR #3547 non-TTY resolution for installer prompt-user actions (default remove for SDK build artifacts, keep for skills/gsd-*/SKILL.md); #3545 filed for gsd-test-summary concurrent /tmp output collision; new predicates DEFECT.HOOK-OVER-ENFORCEMENT.read-tool-tracking, DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION, DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL, DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT, PROC.PARALLEL-FIX-DISPATCH; agent-trust-but-verify caught /gsd-update retired-syntax comment slip in #3541 implementation before PR open]`
`SESSION.2026-05-16=[multi-PR triage wave (#3577/3581/3640/3641/3642/3648/3649/3637/3639). Established global PreToolUse hook ~/.claude/hooks/test-memory-guard.sh denying new node/test spawns when sum(RSS of node|vitest|jest|...) >= 4 GiB on the 24 GB Mac OR when a same-runner process is already in argv[0] — hard deny via hookSpecificOutput.permissionDecision=deny. PR #3577 fix: revert config-ensure-section dispatch to CJS cmdConfigEnsureSection (SDK author wrote single-section semantics under a name whose legacy callers expect full-default config init); plus 3 SDK parity carve-outs (configNewProject defaults align with sdk/shared/config-defaults.manifest.json, return relative .planning/config.json path, drop quotes from Unknown config key, lead malformed-JSON error with "Failed to read config.json:"). PR #3649 fix: chunk node --test spawn at 28K argv ceiling (Windows CreateProcess lpCommandLine cap 32,767 was instantly aborting unchunked spawn of 546 paths). Chunking fix surfaced 14 pre-existing Windows-only test bugs (4010 pass / 14 fail; vs 0/0 before — entire suite was un-runnable on Windows). PRs #3639 + #3637 confirmed unable to stand alone (legitimately depend on Phase 6 scaffolding only present on feat/3575-enforcement-hardening) — user decision: cherry-pick into #3577 and close. Five other PRs each had ≤1 unresolved CR thread of the changeset-pr-number / null-vs-throw / implicit-Claude-runtime / docs-stale-guidance / hardcoded-tests-path family — all quick wins. New predicates: DEFECT.SDK-PORT-NAME-COLLISION, DEFECT.WINDOWS-ARGV-OVERFLOW, DEFECT.STACKED-PR-CANNOT-STAND-ALONE, DEFECT.CANARY-VERSION-LEAK, DEFECT.GSD-TEST-HOST-MID-RUN-DEATH, RULESET.HARNESS.test-memory-guard, RULESET.PR-FLOW.docker-before-push, RULESET.PR-FLOW.templates-mandatory]`
-`DEFECT.NAME-COLLISION.symptom=a router migration rebinds CLI dispatch for a canonical command name to a handler with a different positional-arg shape; every legacy no-arg / wrong-arg caller then errors out at the new handler's own validation throw`
-`DEFECT.NAME-COLLISION.examples=#3577 config-ensure-section (legacy = no-arg full-default init via ensureConfigFile→buildNewProjectConfig; the rebound configEnsureSection = single-section ensure requiring args[0]; all CLI callers pass no args; handler throws "Usage: config-ensure-section ")`
-`DEFECT.NAME-COLLISION.detect=trace every CLI/test caller of the canonical name → if any caller's argv shape differs from the rebound handler's args[0] expectation, the migration broke the legacy contract`
-`DEFECT.NAME-COLLISION.fix-forward=either (a) bind the dispatch to a handler whose body mirrors legacy semantics (e.g. configNewProject when no args), or (b) keep the dispatch case calling the original handler directly (precedent: 7d5dfa9d codex runtime carve-out). Whichever path, add a behavioral test that round-trips the legacy invocation shape to lock the contract`
-`DEFECT.SDK-PORT-NAME-COLLISION.generative-tie=instance of DEFECT.GENERATIVE-PRIORITY — parity assertion at the test layer between CJS handler shape and SDK handler shape would have failed at PR open`
-
-`DEFECT.WINDOWS-ARGV-OVERFLOW.symptom=execFileSync(node, ['--test', ...N paths]) succeeds on Linux/macOS, instantly exits with code 1 and no test output on Windows when N×avg(path_len) exceeds 32,767 chars (CreateProcess lpCommandLine cap)`
-`DEFECT.WINDOWS-ARGV-OVERFLOW.examples=#3649 scripts/run-tests.cjs spawning 546 paths (~85 chars each ≈ 46 KB); Linux ARG_MAX 2 MB allows it, Windows aborts in ~70 ms with zero test output making the failure look like the runner itself crashed`
-`DEFECT.WINDOWS-ARGV-OVERFLOW.detect=Windows CI job at "Run unit tests" exits with code 1 within seconds of starting, no node:test output between "run-tests: suite=… files=N: …" line and "Process completed with exit code 1"; same job on Linux/macOS runs full duration`
-`DEFECT.WINDOWS-ARGV-OVERFLOW.fix-forward=chunk argv into batches whose total length stays under 28,000 chars (headroom under the 32,767 ceiling); run each chunk sequentially; aggregate exit codes (first non-zero wins). Expose RUN_TESTS_MAX_CMDLINE_CHARS env override so cross-platform regression tests can force chunking with short tmp paths`
-`DEFECT.WINDOWS-ARGV-OVERFLOW.test-anchor=tests/run-tests-harness.test.cjs "Windows argv-overflow chunking (issue #3597)" — 30 long-named fixture files + RUN_TESTS_MAX_CMDLINE_CHARS=2000 → asserts run-tests: chunk N/M marker in stderr; pattern works on every platform`
-`DEFECT.WINDOWS-ARGV-OVERFLOW.prevention=a RUNTIME argv-length property (args-array size not statically knowable) — NOT AST-lint-enforceable; addressed at the source by the production run-tests.cjs chunking under RUN_TESTS_MAX_CMDLINE_CHARS plus its test-anchor (tests/run-tests-harness.test.cjs). ADR-1703 Phase 3 (#1720) evaluated and dropped a no-oversized-test-argv lint rule as unsound (it could not detect the canonical execFileSync(node,[...paths]) array overflow)`
-
-`DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.symptom=a test deletes/rewrites a SHARED REAL build artifact or fixture (e.g. gsd-core/bin/lib/*.cjs, the build tsbuildinfo) that other test files require; node --test runs files concurrently, so innocent concurrent tests intermittently fail with "Cannot find module" / ENOENT while the racy test itself passes (victim-not-culprit, leg-asymmetric red); placing mutable build state inside a copied/shipped tree (gsd-core/bin/) additionally races install-test fs.cpSync copies → copyfile ENOENT`
-`DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.examples=#996/88e30d53 — bug-969 hardening tests fs.unlinkSync'd + restored the real gsd-core/bin/lib/core.cjs and set tsBuildInfoFile inside gsd-core/bin/ → next red across the full-test matrix (macOS/Windows) + ubuntu-24 coverage leg, ~40-50 MODULE_NOT_FOUND/ENOENT per leg; reproduced locally on iteration 1; fixed #1001/#1002`
-`DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.detect=grep tests for fs.unlinkSync|rmSync|writeFileSync|renameSync|cpSync targeting paths resolved from the repo root (join(__dirname,'..',...)) under gsd-core/bin/lib or a shared committed fixture, instead of a mkdtempSync temp dir; any build helper (e.g. ensureBuiltArtifacts) invoked with real-tree paths during the concurrent test phase; any tsBuildInfoFile / build-cache path that lands inside a copied/shipped dir (gsd-core/bin/)`
-`DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.fix-forward=tests mutate ONLY isolated mkdtempSync copies — never delete/rewrite shared real build outputs while node --test runs files concurrently; parameterize build helpers to accept {root,srcDir,outDir,tsBuildInfoPath,tsconfigPath} overrides and point the test at a throwaway temp project (precedent: #1002 ensureBuiltArtifacts(overrides)); keep mutable build state (tsbuildinfo) OUTSIDE copied/shipped trees (repo root, gitignored) + best-effort self-heal of stale bin-local copies; this is the concrete instance of the RULESET.TESTS.delete-bad-tests real-race class`
-`DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.test-anchor=tests/run-tests-harness.test.cjs (hermetic temp-project rewrite); regression gate = 10x concurrent run of that suite + tests/state.test.cjs + tests/install.test.cjs must be clean (reproduces on iter 1 when racy)`
-
-`DEFECT.STACKED-PR-CANNOT-STAND-ALONE.symptom=patch PR was authored against scaffolding (handler files, lint scripts, generated modules) that exists only on an unmerged upstream feature branch; the PR's "base" on GitHub is the feature branch, not main; merging requires the upstream PR to land first`
-`DEFECT.STACKED-PR-CANNOT-STAND-ALONE.examples=#3639 + #3637 both targeted base=feat/3575-enforcement-hardening (the Phase 6 PR #3577); #3639 modifies SDK-bridge calls in 6 family-router files that on main do NOT have any SDK-bridge call yet; #3637 patches scripts/lint-shared-module-handsync.cjs which does not exist on main at all`
-`DEFECT.STACKED-PR-CANNOT-STAND-ALONE.detect=gh pr view --json baseRefName shows non-main base; OR git rebase --onto origin/main produces real (not whitespace) conflicts at files the patch claims to modify; OR git cat-file -e origin/main: errors with "does not exist in origin/main"`
-`DEFECT.STACKED-PR-CANNOT-STAND-ALONE.fix-forward=user policy (this session, 2026-05-16): every PR must stand alone. Resolution = cherry-pick the patch's unique commits onto the upstream PR head, push to upstream PR branch, close patch PR with "subsumed by #". Alternatives explicitly rejected: leaving stacked open ("no, fold them in") and closing-without-folding ("we want the fix")`
-`DEFECT.STACKED-PR-CANNOT-STAND-ALONE.anti-pattern=blindly running git rebase --onto origin/main on the patch branch — produces "conflicts" that are really "the scaffolding doesn't exist yet"; resolving them means reinventing the upstream PR's contribution, which duplicates work and creates merge hazards. Recognize the shape early via cat-file probe before rebasing`
-
-`DEFECT.CANARY-VERSION-LEAK.symptom=package.json version on main carries a -canary. suffix that per release policy belongs to the dev branch only; nothing publishable depends on the version string at runtime, but every consumer of the version metadata (release flow, install banners, statusline) sees the dev-channel label`
-`DEFECT.CANARY-VERSION-LEAK.examples=2026-05-16 audit found origin/main + origin/feat/3575-enforcement-hardening both at "version": "1.50.0-canary.0" in sdk/package.json AND root package.json; npm view @opengsd/gsd-sdk versions returned ["0.1.0"] only, dist-tag latest=0.1.0, @1.50.0-canary.0 404 — confirms the string is metadata-only, never published. git log -S '"version": "1.50.0-canary.0"' origin/main blamed commit 2d32ad82 fix(plan-phase)... (#3206), a fix PR that accidentally carried the version bump from a dev-branch base`
-`DEFECT.CANARY-VERSION-LEAK.detect=jq -r .version package.json on origin/main shows a -canary suffix; OR npm view dist-tags shows latest != main's version`
-`DEFECT.CANARY-VERSION-LEAK.fix-forward=open a chore/* PR against main that resets the version strings to the canonical pre-canary stable; rebase open PRs to pick it up; gate at PR open with a CI check that rejects -canary versions on PRs targeting main`
-`DEFECT.GSD-TEST-HOST-MID-RUN-DEATH.symptom=pick_host succeeds at probe time (ssh -o ConnectTimeout=3 -o BatchMode=yes "$h" true); subsequent ssh "$h" 'docker run ...' hangs indefinitely because the chosen host went unreachable between probe and exec; gsd-test-summary buffers stderr until the wrapper exits, so the operator sees no progress at all`
-`DEFECT.GSD-TEST-HOST-MID-RUN-DEATH.examples=2026-05-16 redshirt probed up at 12:48 UTC, gsd-test-summary picked it, docker container spawned, then redshirt's ssh daemon stopped responding — banner-exchange timeout. Test stalled 20+ minutes with the wrapper's output file at 0 bytes`
-`DEFECT.GSD-TEST-HOST-MID-RUN-DEATH.detect=gsd-test-summary's task output file at /private/tmp/claude-*/tasks/.output stays 0 bytes for >5 min after launch; ps shows the test still alive; ssh -o ConnectTimeout=5 true now times out`
-`DEFECT.GSD-TEST-HOST-MID-RUN-DEATH.fix-forward=TaskStop the wrapper; pkill -f gsd-test-summary + pkill -f "ssh "; re-run gsd-test-summary so pick_host re-randomizes from the live set (probe each ~/.config/gsd-test/hosts entry first to confirm). Upstream fix candidate: gsd-test should add a heartbeat read on the ssh-stdin channel and abort + retry on a different host after N silent seconds`
-`DEFECT.GSD-TEST-HOST-MID-RUN-DEATH.related=DEFECT.GSD-TEST-MIRROR-POISONED (legacy bind-mount ownership); GSD-TEST-CONCURRENT-OUTPUT-COLLISION (file collision) — host-mid-run-death is the third independent gsd-test infra failure mode this month`
-
`RULESET.HARNESS.test-memory-guard=~/.claude/hooks/test-memory-guard.sh fires on every Bash PreToolUse; if argv[0]∈{node|vitest|jest|mocha|tsx|ts-node|tap|ava|playwright|cypress} OR matches (npm|pnpm|yarn|bun) (run )?(t|test|tests|vitest|jest); blocks via hookSpecificOutput.permissionDecision=deny when sum(RSS of running matching procs, excluding tsserver|*-mcp|claude|Electron|...) ≥ 4 GiB OR when argv[0] basename matches a running process's argv[0]. Exception: node --version|-v|--help|-h|-p|-e are trivial probes and skip the check. Designed for a 24 GB Mac where prior accidental fan-out exhausted RAM`
`RULESET.PR-FLOW.docker-before-push=before ANY git push of any fix to any PR, run gsd-test (docker on the remote, mirrors ubuntu CI) and confirm exit 0. macOS-local node --test is NOT a substitute — many failures are platform-specific (path separators, case sensitivity, locale, fs semantics). Watchdog with Monitor on the output log; never set a sleep/timer and walk away. Source: user feedback 2026-05-16 — "we don't set a timer we actively watch and record results in real time as possible". SUPERSEDED 2026-07-17: 'confirm exit 0' is a false-green trap — piping/backgrounding can report exit 0 on a failed suite; gate on the verdict-line outcome:"passed" for the exact HEAD sha instead. See CLAUDE.md's gsd-test rule and the gsd-test-is-ref-based-commit-first predicate for the current, correct gating contract.`
@@ -1014,33 +835,10 @@ Migration: Phases 1-4 (#3465-#3468) shipped 2026-05-13 — seam additions, subpr
`EXEC.CLASSIFY.retry-after-parser=\bretry[-_ ]after[:\s]+(\d+)\b avoids embedded-word false matches like noretry-after`
`EXEC.CLASSIFY.proactive-signal-not-usable=Anthropic exposes anthropic-ratelimit-* headers + Agent SDK RateLimitEvent; Claude Code subprocess does NOT forward to hooks/statusline today (upstream #33820, #22407, #32796)`
-`DEFECT.GSD-TEST-MIRROR-POISONED.symptom=gsd-test-summary --both exits docker=23 (rsync partial transfer) with mkstemp Permission denied on remote mirror files; mirror has root-owned artifacts from prior cold runs`
-`DEFECT.GSD-TEST-MIRROR-POISONED.detect=docker stderr shows rsync: [generator] delete_file: unlink(...) failed: Permission denied (13) OR [receiver] mkstemp ".gsd-*." failed`
-`DEFECT.GSD-TEST-MIRROR-POISONED.root-cause=container ran without --user; build:hooks wrote into bind-mount as root; chown-back-before-exec patch closes forward path but not legacy hosts`
-`DEFECT.GSD-TEST-MIRROR-POISONED.recovery=ssh 'docker run --rm -v ~/gsd-mirror-gsd-core:/work gsd-test:node22 chown -R : /work'; remote-uid is the SSH user's uid on the remote (1000 on holodeck, NOT local Mac 501)`
-`DEFECT.GSD-TEST-MIRROR-POISONED.upstream=trek-e/gsd-test-runner#1 — proposes self-healing init-time chown probe`
-
-`DEFECT.HOOK-OVER-ENFORCEMENT.read-tool-tracking=gh-templates-first PreToolUse hook tracks Read tool invocations specifically; Bash cat/head of the same file does NOT satisfy the hook; future-self must use Read tool from the first contact with template files`
-`DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION.symptom=two simultaneous gsd-test-summary --both invocations (e.g. one per worktree) both crash with UnicodeDecodeError in parse_events_from_file; "local exit=1 docker exit=1" reported even though remote containers ran fine`
-`DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION.root-cause=gsd-test-summary lines 126-127 default LOCAL_OUT/DOCKER_OUT to fixed /tmp/gsd-test-{local,docker}.jsonl; concurrent line-buffered writers interleave bytes mid-multibyte → split UTF-8 sequence → decoder explodes on f.read()`
-`DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION.detect=two gsd-test-summary --both runs in flight; UnicodeDecodeError in parse_events_from_string traceback; /tmp/gsd-test-*.jsonl size mismatch vs total events emitted`
-`DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION.fix-forward=set per-invocation LOCAL_OUT=/tmp/gsd-test--local.jsonl DOCKER_OUT=/tmp/gsd-test--docker.jsonl env vars; or serialize the runs; upstream fix tracked in #3545 (default to tempfile.mkstemp + advisory flock)`
-`DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION.upstream=open-gsd/gsd-test-runner#4 (moved from #3545 in the predecessor repo, filed in the wrong repo; now CLOSED/COMPLETED — fix shipped)`
-`DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL.symptom=spawned sub-agent kicks off gsd-test-summary --both via Bash run_in_background, then stops on the harness "you will be notified" message; never receives the notification because cross-turn task-notifications are only delivered to the top-level orchestrator`
-`DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL.detect=sub-agent returns prematurely with text like "I should wait for the notification per CLAUDE.md" and incomplete work in its worktree (commits absent, push absent, PR absent)`
-`DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL.fix-forward=keep gsd-test-summary --both at the top-level orchestrator; sub-agents either run it foreground with timeout: 1500000 (25min) and block, OR delegate the test step back to the orchestrator (write commits + return); never have a sub-agent fire-and-await a backgrounded long task`
-`DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL.anchor=lesson: cross-turn task notifications are delivered only to the top-level orchestrator, never to a sub-agent — load-bearing for multi-worktree parallel fix dispatch (the CLAUDE.md passage this entry previously quoted verbatim has since been removed/rewritten; no live replacement citation exists)`
-`DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.symptom=sub-agent writes /gsd- (legacy hyphen syntax) in code comments or doc strings while implementing a fix; lands as part of the implementation diff`
-`DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.examples=#3541 implementation included a typical /gsd-update path comment in installer-migration-report.cjs; caught by tests/slash-command-namespace.test.cjs (#3443 invariant)`
-`DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.detect=tests/slash-command-namespace.test.cjs prints "Found N retired /gsd- reference(s) — use /gsd: instead" with line-number-precise violations`
-`DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.fix-forward=replace /gsd- with /gsd: at the cited file:line; healthy emergent property — project-wide invariant test catches drift agents would never self-correct`
-`DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.lesson=agent-trust-but-verify is load-bearing — sub-agent reporting "done" is not a substitute for running the full suite; the invariant test surfaces drift even in doc-only changes`
`PROC.PARALLEL-FIX-DISPATCH.pattern=bot triage brief → worktree per branch → parallel sub-agents do rubber-duck/RCA/TDD implementation only → top-level orchestrator owns commit + gsd-test + push + PR + changeset-pr-backfill`
`PROC.PARALLEL-FIX-DISPATCH.rationale=long-running test runs need cross-turn notifications (orchestrator-only); CONTRIBUTING.md gh-templates-first hook requires session-scoped Read calls sub-agents wouldn't otherwise make; sequencing test runs avoids GSD-TEST-CONCURRENT-OUTPUT-COLLISION`
`PROC.PARALLEL-FIX-DISPATCH.observed=#3541 + #3542 dispatched simultaneously this session; PRs #3546 #3547 opened green; one syntax slip caught by AGENT-RETIRED-SLASH-SYNTAX-DRIFT and fixed before second PR opened`
-`DEFECT.HOOK-OVER-ENFORCEMENT.write-bypass=security_reminder_hook can block Write on substring match (e.g. a literal child-process call-expression token); workaround is heredoc to /tmp then mv into place, or use Edit instead — Edit hooks are more lenient than Write hooks`
-
`PROC.TRIAGE.routing-incoming=stale-bug-already-fixed to close as duplicate of originating issue + cite fix PR + first stable tag; release-publish-or-backport to ready-for-human; reporter-can-self-test to awaiting-retest`
`PROC.TRIAGE.comment-shape=lead with "duplicate of #NNNN, fixed by PR #MMMM, in v1.X.Y"; show current code snippet proving bug-surface gone; give @latest and @next upgrade commands; close`
`PROC.TRIAGE.no-duplicate-label=this repo has no duplicate label; framing lives in comment text + closing the issue`
@@ -1104,3 +902,6 @@ Full detail in `~/.claude/skills/gsd-pr-fix-discipline/SKILL.md`. AI agents MUST
- **Symptom:** `gh pr merge --auto` returns `GraphQL: Auto merge is not allowed for this repository`
- **Affected this session:** All stacked PRs
- **Fix:** Merge manually by hand in dependency order once CI greens; `gh pr merge --squash --repo open-gsd/gsd-core`
+
+### Defect enforcement (ADR-2143 follow-on)
+Prose defect entries retired in favour of gates; the gate IS the record. `DEFECT.UNBOUNDED-SUBPROCESS` → `eslint-rules/require-subprocess-timeout.cjs` (error, `src/**`; options literal must carry `timeout` — git 5-30s, npm 60s; the rule only requires the call be bounded, not what the caller does after — 7 of the 8 sites this surfaced degrade to an empty/false/null result on failure, and the 1 that guards a destructive real-run migration (`roadmap-upgrade.cts`'s pre-mutation clean-tree check) correctly still throws rather than proceed against an unverified working tree). `DEFECT.CANARY-VERSION-LEAK` → `scripts/lint-canary-version-leak.cjs` + the `canary-version-leak` job in `.github/workflows/version-gate.yml` (PRs whose base is `main`). `DEFECT.CHANGESET-PR-FIELD-DRIFT` → `findPrFieldDrift` in `scripts/changeset/lint.cjs`. Entries whose condition no automated check can evaluate were deleted rather than kept as unenforceable prose. `DEFECT.FRONTMATTER-SCALAR-BROAD-GREP` → `scripts/lint-frontmatter-scalar-broad-grep.cjs` (in `lint:ci`; flags an unscoped `grep "^key:"` over a whole planning doc with no frontmatter slice and no `-m1`/`head -1` guard). `DEFECT.REMOVED-BUT-NEEDED` → `scripts/lint-removed-but-needed.cjs` (in `lint:ci`; a deleted file whose basename still appears in `.github/workflows/`, `gsd-core/`, `docs/` or `package.json`). `DEFECT.DEFAULT-FLIP-DOCUMENTATION` → `scripts/lint-default-flip-documentation.cjs` + `.github/workflows/default-flip-documentation.yml`, covering `gsd-core/bin/shared/config-defaults.manifest.json` only: a changed value for an EXISTING key requires a `## Breaking Changes` PR section. The six Windows-portability entries (`WINDOWS-TEST-PORTABILITY`, `WINDOWS-POSIX-MODE-BIT-ASSERT`, `WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT`, `WINDOWS-PATH-LITERAL-IN-ASSERT`, `WINDOWS-FS-OPS`, `TEST-SHELL-PIPELINE-NONPORTABLE`) were already enforced by ADR-1703's own `local/*` AST ESLint rules (`no-posix-mode-bit-assert`, `normalize-path-in-content`, `no-path-literal-in-assert`, `require-fs-op-fallback`, `no-crlf-fragile-split`, `no-unguarded-nonportable-exec` — see `eslint.config.mjs`) before this PR; their prose duplicated the rules' own doc comments, so it was deleted rather than kept as a second copy. **Residual, deliberately unenforced:** `buildNewProjectConfig`'s hardcoded literal in `src/config.cts` has env-derived branches and `CONFIG_DEFAULTS` spreads, so no reliable resolved-value diff exists without executing the compiled module at both refs — a line-diff there false-fires on any refactor that merely moves the object, so it was left unchecked rather than shipped noisy.
diff --git a/docs/CONTEXT-INDEX.json b/docs/CONTEXT-INDEX.json
index 94e9cbecd..af4cb1aee 100644
--- a/docs/CONTEXT-INDEX.json
+++ b/docs/CONTEXT-INDEX.json
@@ -1,11 +1,10 @@
{
"schemaVersion": 1,
- "count": 428,
+ "count": 261,
"classes": {
"ARCH": 1,
"CI": 2,
"CONFIG": 5,
- "DEFECT": 168,
"EXEC": 8,
"GSD-RESEARCH": 6,
"LEARNING": 1,
@@ -18,7 +17,7 @@
"PROC": 14,
"PROHIB": 10,
"RELEASE-NOTES": 31,
- "RULESET": 57,
+ "RULESET": 58,
"SESSION": 9,
"WAVE": 5,
"WORKSTREAM": 5,
@@ -65,846 +64,6 @@
"klass": "CONFIG",
"value": "loadConfig(cwd,{workstream}) replaces env-mutation fallback; no temporary process.env GSD_WORKSTREAM rewrites"
},
- {
- "id": "DEFECT.AGENT-FILE-SIZE-CAP-BREACH.detect",
- "klass": "DEFECT",
- "value": "tests/planner-decomposition.test.cjs (\"planner is under 45K chars (proves mode sections were extracted)\") and tests/reachability-check.test.cjs (\"file stays under 50000 char limit\")"
- },
- {
- "id": "DEFECT.AGENT-FILE-SIZE-CAP-BREACH.fix-forward",
- "klass": "DEFECT",
- "value": "mirror MVP mode pattern — extract full rules to gsd-core/references/planner-.md, leave a slim Detection section in the agent file with @-reference to the new file"
- },
- {
- "id": "DEFECT.AGENT-FILE-SIZE-CAP-BREACH.state",
- "klass": "DEFECT",
- "value": "gsd-planner.md is 49,125 chars on main, just under the test's actual PLANNER_EXTRACTED_LIMIT of 48K (49,152 chars — the test's own title still says \"45K\" but the enforced constant was raised in #2341); the test currently passes, but any further net-new content risks pushing it over"
- },
- {
- "id": "DEFECT.AGENT-FILE-SIZE-CAP-BREACH.symptom",
- "klass": "DEFECT",
- "value": "adding to agents/gsd-planner.md (or other large agent files) exceeds the 45K char extraction-evidence threshold"
- },
- {
- "id": "DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.detect",
- "klass": "DEFECT",
- "value": "tests/slash-command-namespace.test.cjs prints \"Found N retired /gsd- reference(s) — use /gsd: instead\" with line-number-precise violations"
- },
- {
- "id": "DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.examples",
- "klass": "DEFECT",
- "value": "#3541 implementation included a typical /gsd-update path comment in installer-migration-report.cjs; caught by tests/slash-command-namespace.test.cjs (#3443 invariant)"
- },
- {
- "id": "DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.fix-forward",
- "klass": "DEFECT",
- "value": "replace /gsd- with /gsd: at the cited file:line; healthy emergent property — project-wide invariant test catches drift agents would never self-correct"
- },
- {
- "id": "DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.lesson",
- "klass": "DEFECT",
- "value": "agent-trust-but-verify is load-bearing — sub-agent reporting \"done\" is not a substitute for running the full suite; the invariant test surfaces drift even in doc-only changes"
- },
- {
- "id": "DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.symptom",
- "klass": "DEFECT",
- "value": "sub-agent writes /gsd- (legacy hyphen syntax) in code comments or doc strings while implementing a fix; lands as part of the implementation diff"
- },
- {
- "id": "DEFECT.BOT-BRANCH-STALE-BASE.detect",
- "klass": "DEFECT",
- "value": "git merge-base origin/ origin/main returns the bot branch tip — confirms the bot branch is an ancestor of main, just stale"
- },
- {
- "id": "DEFECT.BOT-BRANCH-STALE-BASE.examples",
- "klass": "DEFECT",
- "value": "#3309 fix/3309-checkpoint-type-human-verify-burns-token (was at e14ef535; main at 2e87c60a)"
- },
- {
- "id": "DEFECT.BOT-BRANCH-STALE-BASE.fix-forward",
- "klass": "DEFECT",
- "value": "git checkout --detach origin/main; do work; git checkout -b ; force-push with --force-with-lease"
- },
- {
- "id": "DEFECT.BOT-BRANCH-STALE-BASE.symptom",
- "klass": "DEFECT",
- "value": "auto-branch.yml creates fix/{N}-{slug} when issue is filed; branch is anchored to issue-creation main; by the time work begins, main has moved"
- },
- {
- "id": "DEFECT.CANARY-VERSION-LEAK.detect",
- "klass": "DEFECT",
- "value": "jq -r .version package.json on origin/main shows a -canary suffix; OR npm view dist-tags shows latest != main's version"
- },
- {
- "id": "DEFECT.CANARY-VERSION-LEAK.examples",
- "klass": "DEFECT",
- "value": "2026-05-16 audit found origin/main + origin/feat/3575-enforcement-hardening both at \"version\": \"1.50.0-canary.0\" in sdk/package.json AND root package.json; npm view @opengsd/gsd-sdk versions returned [\"0.1.0\"] only, dist-tag latest=0.1.0, @1.50.0-canary.0 404 — confirms the string is metadata-only, never published. git log -S '\"version\": \"1.50.0-canary.0\"' origin/main blamed commit 2d32ad82 fix(plan-phase)... (#3206), a fix PR that accidentally carried the version bump from a dev-branch base"
- },
- {
- "id": "DEFECT.CANARY-VERSION-LEAK.fix-forward",
- "klass": "DEFECT",
- "value": "open a chore/* PR against main that resets the version strings to the canonical pre-canary stable; rebase open PRs to pick it up; gate at PR open with a CI check that rejects -canary versions on PRs targeting main"
- },
- {
- "id": "DEFECT.CANARY-VERSION-LEAK.symptom",
- "klass": "DEFECT",
- "value": "package.json version on main carries a -canary. suffix that per release policy belongs to the dev branch only; nothing publishable depends on the version string at runtime, but every consumer of the version metadata (release flow, install banners, statusline) sees the dev-channel label"
- },
- {
- "id": "DEFECT.CHANGESET-PR-FIELD-DRIFT.detect",
- "klass": "DEFECT",
- "value": "changeset pr: value mismatches the actual PR number returned by gh api POST /pulls"
- },
- {
- "id": "DEFECT.CHANGESET-PR-FIELD-DRIFT.examples",
- "klass": "DEFECT",
- "value": "#3316 (pr:3312 was the issue), #3325 (pr:3319 was a guess); recurs every cycle"
- },
- {
- "id": "DEFECT.CHANGESET-PR-FIELD-DRIFT.fix-forward",
- "klass": "DEFECT",
- "value": "author changeset with placeholder pr:0; immediately after gh api POST /pulls returns the number, edit changeset and amend or follow-up commit; never guess"
- },
- {
- "id": "DEFECT.CHANGESET-PR-FIELD-DRIFT.symptom",
- "klass": "DEFECT",
- "value": ".changeset/*.md frontmatter pr: value is the issue number, a guess made before PR opened, or a stale stacked-PR number"
- },
- {
- "id": "DEFECT.DEFAULT-FLIP-DOCUMENTATION.detect",
- "klass": "DEFECT",
- "value": "any PR that changes a default value in CONFIG_DEFAULTS or buildNewProjectConfig; check that PR body Breaking Changes section explicitly covers (a) when the new default takes effect, (b) opt-back-in command, (c) effect on in-flight artifacts"
- },
- {
- "id": "DEFECT.DEFAULT-FLIP-DOCUMENTATION.examples",
- "klass": "DEFECT",
- "value": "#3309 v2 default flip from mid-flight to end-of-phase"
- },
- {
- "id": "DEFECT.DEFAULT-FLIP-DOCUMENTATION.fix-forward",
- "klass": "DEFECT",
- "value": "template — \"new default takes effect when .planning/config.json is rewritten (config-set, fresh project, regenerated config); existing artifacts continue to work; opt-back-in: gsd config-set \""
- },
- {
- "id": "DEFECT.DEFAULT-FLIP-DOCUMENTATION.symptom",
- "klass": "DEFECT",
- "value": "PR flips a config default but does not call out the migration semantics (when does the new default take effect; existing configs vs new configs; what the opt-back-in looks like)"
- },
- {
- "id": "DEFECT.FORMAT",
- "klass": "DEFECT",
- "value": "class.sub-key=value | classes are greppable; each class carries detect / fix / anchor sub-keys when applicable"
- },
- {
- "id": "DEFECT.FRONTMATTER-SCALAR-BROAD-GREP.detect",
- "klass": "DEFECT",
- "value": "grep \"^:\" on a *.md whose result is compared to exact tokens, with no frontmatter scoping and no -m1; one body line beginning : is enough to break it"
- },
- {
- "id": "DEFECT.FRONTMATTER-SCALAR-BROAD-GREP.examples",
- "klass": "DEFECT",
- "value": "#586/PR #650 ship.md verification gate — grep \"^status:\" also matched body status: lines, yielding passed+gaps_found+human_needed instead of passed and blocking a passed phase; execute-phase.md has since been fixed to the frontmatter-scoped form (#651)"
- },
- {
- "id": "DEFECT.FRONTMATTER-SCALAR-BROAD-GREP.fix-forward",
- "klass": "DEFECT",
- "value": "scope to the leading frontmatter block and take the first match: sed -n '/^---$/,/^---$/p' \"$f\" | grep -m1 \"^:\" | cut -d: -f2 | tr -d ' '; fix every parallel copy in the same change or consolidate behind one queryable seam (#651)"
- },
- {
- "id": "DEFECT.FRONTMATTER-SCALAR-BROAD-GREP.symptom",
- "klass": "DEFECT",
- "value": "a YAML-frontmatter scalar (e.g. VERIFICATION.md status) read with grep \"^key:\" over the WHOLE markdown report instead of the frontmatter block; a key: line in the body (code block, copied artifact, example) returns extra matches that concatenate after cut|tr into a value matching no expected token, so a valid state is misrouted"
- },
- {
- "id": "DEFECT.GENERATIVE-EXEMPLAR",
- "klass": "DEFECT",
- "value": "tests/runtime-launcher-parity.test.cjs (asserts every workflow bash block uses the canonical gsd_run launcher — the in-repo pattern for enforcing equality across parallel surfaces)"
- },
- {
- "id": "DEFECT.GENERATIVE-FIX",
- "klass": "DEFECT",
- "value": "for any new constant/array/parser shared between two parallel surfaces (two workflow surfaces, or a generated artifact and its hand-authored source), the same commit MUST add a parity assertion that fails when the two diverge"
- },
- {
- "id": "DEFECT.GENERATIVE-PRIORITY",
- "klass": "DEFECT",
- "value": "these defect classes share a common root: parallel implementations diverge silently because no parity test enforces equality at the test layer"
- },
- {
- "id": "DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION.detect",
- "klass": "DEFECT",
- "value": "two gsd-test-summary --both runs in flight; UnicodeDecodeError in parse_events_from_string traceback; /tmp/gsd-test-*.jsonl size mismatch vs total events emitted"
- },
- {
- "id": "DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION.fix-forward",
- "klass": "DEFECT",
- "value": "set per-invocation LOCAL_OUT=/tmp/gsd-test--local.jsonl DOCKER_OUT=/tmp/gsd-test--docker.jsonl env vars; or serialize the runs; upstream fix tracked in #3545 (default to tempfile.mkstemp + advisory flock)"
- },
- {
- "id": "DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION.root-cause",
- "klass": "DEFECT",
- "value": "gsd-test-summary lines 126-127 default LOCAL_OUT/DOCKER_OUT to fixed /tmp/gsd-test-{local,docker}.jsonl; concurrent line-buffered writers interleave bytes mid-multibyte → split UTF-8 sequence → decoder explodes on f.read()"
- },
- {
- "id": "DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION.symptom",
- "klass": "DEFECT",
- "value": "two simultaneous gsd-test-summary --both invocations (e.g. one per worktree) both crash with UnicodeDecodeError in parse_events_from_file; \"local exit=1 docker exit=1\" reported even though remote containers ran fine"
- },
- {
- "id": "DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION.upstream",
- "klass": "DEFECT",
- "value": "open-gsd/gsd-test-runner#4 (moved from #3545 in the predecessor repo, filed in the wrong repo; now CLOSED/COMPLETED — fix shipped)"
- },
- {
- "id": "DEFECT.GSD-TEST-HOST-MID-RUN-DEATH.detect",
- "klass": "DEFECT",
- "value": "gsd-test-summary's task output file at /private/tmp/claude-*/tasks/.output stays 0 bytes for >5 min after launch; ps shows the test still alive; ssh -o ConnectTimeout=5 true now times out"
- },
- {
- "id": "DEFECT.GSD-TEST-HOST-MID-RUN-DEATH.examples",
- "klass": "DEFECT",
- "value": "2026-05-16 redshirt probed up at 12:48 UTC, gsd-test-summary picked it, docker container spawned, then redshirt's ssh daemon stopped responding — banner-exchange timeout. Test stalled 20+ minutes with the wrapper's output file at 0 bytes"
- },
- {
- "id": "DEFECT.GSD-TEST-HOST-MID-RUN-DEATH.fix-forward",
- "klass": "DEFECT",
- "value": "TaskStop the wrapper; pkill -f gsd-test-summary + pkill -f \"ssh \"; re-run gsd-test-summary so pick_host re-randomizes from the live set (probe each ~/.config/gsd-test/hosts entry first to confirm). Upstream fix candidate: gsd-test should add a heartbeat read on the ssh-stdin channel and abort + retry on a different host after N silent seconds"
- },
- {
- "id": "DEFECT.GSD-TEST-HOST-MID-RUN-DEATH.related",
- "klass": "DEFECT",
- "value": "DEFECT.GSD-TEST-MIRROR-POISONED (legacy bind-mount ownership); GSD-TEST-CONCURRENT-OUTPUT-COLLISION (file collision) — host-mid-run-death is the third independent gsd-test infra failure mode this month"
- },
- {
- "id": "DEFECT.GSD-TEST-HOST-MID-RUN-DEATH.symptom",
- "klass": "DEFECT",
- "value": "pick_host succeeds at probe time (ssh -o ConnectTimeout=3 -o BatchMode=yes \"$h\" true); subsequent ssh \"$h\" 'docker run ...' hangs indefinitely because the chosen host went unreachable between probe and exec; gsd-test-summary buffers stderr until the wrapper exits, so the operator sees no progress at all"
- },
- {
- "id": "DEFECT.GSD-TEST-MIRROR-POISONED.detect",
- "klass": "DEFECT",
- "value": "docker stderr shows rsync: [generator] delete_file: unlink(...) failed: Permission denied (13) OR [receiver] mkstemp \".gsd-*.\" failed"
- },
- {
- "id": "DEFECT.GSD-TEST-MIRROR-POISONED.recovery",
- "klass": "DEFECT",
- "value": "ssh 'docker run --rm -v ~/gsd-mirror-gsd-core:/work gsd-test:node22 chown -R : /work'; remote-uid is the SSH user's uid on the remote (1000 on holodeck, NOT local Mac 501)"
- },
- {
- "id": "DEFECT.GSD-TEST-MIRROR-POISONED.root-cause",
- "klass": "DEFECT",
- "value": "container ran without --user; build:hooks wrote into bind-mount as root; chown-back-before-exec patch closes forward path but not legacy hosts"
- },
- {
- "id": "DEFECT.GSD-TEST-MIRROR-POISONED.symptom",
- "klass": "DEFECT",
- "value": "gsd-test-summary --both exits docker=23 (rsync partial transfer) with mkstemp Permission denied on remote mirror files; mirror has root-owned artifacts from prior cold runs"
- },
- {
- "id": "DEFECT.GSD-TEST-MIRROR-POISONED.upstream",
- "klass": "DEFECT",
- "value": "trek-e/gsd-test-runner#1 — proposes self-healing init-time chown probe"
- },
- {
- "id": "DEFECT.HALT-COST-PATTERN.detect",
- "klass": "DEFECT",
- "value": "any subagent-spawning workflow with mid-flight pause-and-resume that does not preserve subagent context"
- },
- {
- "id": "DEFECT.HALT-COST-PATTERN.examples",
- "klass": "DEFECT",
- "value": "#3309 checkpoint:human-verify (mid-flight halt = full executor cold-start per round-trip; reporter measured \"tens of thousands of tokens\" per halt)"
- },
- {
- "id": "DEFECT.HALT-COST-PATTERN.fix-forward",
- "klass": "DEFECT",
- "value": "offer config flag for end-of-phase aggregation; if cost dominates make end-of-phase the default; route deferred items through existing verifier surface, do not invent new writer"
- },
- {
- "id": "DEFECT.HALT-COST-PATTERN.symptom",
- "klass": "DEFECT",
- "value": "architecturally-sound checkpoint pattern produces hidden token cost because subagent context is discarded across the pause and respawn"
- },
- {
- "id": "DEFECT.HOOK-OVER-ENFORCEMENT.detect",
- "klass": "DEFECT",
- "value": "hook re-fires on each invocation regardless of session-state read receipts"
- },
- {
- "id": "DEFECT.HOOK-OVER-ENFORCEMENT.examples",
- "klass": "DEFECT",
- "value": "this session repeatedly hit \"Refusing to run gh issue create|edit / gh pr create|edit\" despite reading every listed file"
- },
- {
- "id": "DEFECT.HOOK-OVER-ENFORCEMENT.fix-forward",
- "klass": "DEFECT",
- "value": "use gh api -X PATCH repos/{owner}/{repo}/pulls/{N} or repos/{owner}/{repo}/issues/{N} directly — same effect, hook regex does not match"
- },
- {
- "id": "DEFECT.HOOK-OVER-ENFORCEMENT.read-tool-tracking",
- "klass": "DEFECT",
- "value": "gh-templates-first PreToolUse hook tracks Read tool invocations specifically; Bash cat/head of the same file does NOT satisfy the hook; future-self must use Read tool from the first contact with template files"
- },
- {
- "id": "DEFECT.HOOK-OVER-ENFORCEMENT.symptom",
- "klass": "DEFECT",
- "value": "PreToolUse hook keeps blocking gh pr edit / gh issue edit even after all required files are read in the session"
- },
- {
- "id": "DEFECT.HOOK-OVER-ENFORCEMENT.write-bypass",
- "klass": "DEFECT",
- "value": "security_reminder_hook can block Write on substring match (e.g. a literal child-process call-expression token); workaround is heredoc to /tmp then mv into place, or use Edit instead — Edit hooks are more lenient than Write hooks"
- },
- {
- "id": "DEFECT.HOST-RESERVED-DIR-NAME",
- "klass": "DEFECT",
- "value": "a host runtime reserves a directory NAME that GSD also writes verbatim, so the mere presence of GSD's directory trips the host's own reserved-name detection regardless of contents; example: pi (#3023) treats GSD's shared-hooks bundle dir hooks/ as its own deprecated extension location and printed a startup warning purely because checkDeprecatedExtensionDirs() in packages/coding-agent/src/migrations.ts gates on a bare existsSync(hooksDir) with no readdir/emptiness check (unlike its tools/ sibling); fix-forward=make the shared-hooks directory name descriptor-driven (hostBehaviors.sharedHooksDirName, default hooks) and override it per-runtime when a name collision is detected (pi sets gsd-hooks), with adapters probing the new name then falling back to the legacy name for dev/half-upgraded trees"
- },
- {
- "id": "DEFECT.INVENTORY-DRIFT.detect",
- "klass": "DEFECT",
- "value": "tests/inventory-manifest-sync.test.cjs fails with \"New surfaces not in manifest\"; tests/inventory-headings-countfree.test.cjs fails if a (N shipped) count is re-added to a heading"
- },
- {
- "id": "DEFECT.INVENTORY-DRIFT.examples",
- "klass": "DEFECT",
- "value": "#3309 planner-human-verify-mode.md (caught by tests/inventory-manifest-sync.test.cjs)"
- },
- {
- "id": "DEFECT.INVENTORY-DRIFT.fix-forward",
- "klass": "DEFECT",
- "value": "update INVENTORY.md row entry; run node scripts/gen-inventory-manifest.cjs --write to regen INVENTORY-MANIFEST.json (all eight families.* arrays are canonical — see RULESET.MANIFEST-CANONICAL-KEY); a workflow SUB-file (gsd-core/workflows//steps/*.md or modes/*.md) lands in workflow_steps/workflow_modes, not in workflows, which is keyed by bare basename and cannot hold a nested path"
- },
- {
- "id": "DEFECT.INVENTORY-DRIFT.symptom",
- "klass": "DEFECT",
- "value": "new file added under gsd-core/references/ or gsd-core/workflows/ without updating docs/INVENTORY.md row AND docs/INVENTORY-MANIFEST.json"
- },
- {
- "id": "DEFECT.NAME-COLLISION.detect",
- "klass": "DEFECT",
- "value": "trace every CLI/test caller of the canonical name → if any caller's argv shape differs from the rebound handler's args[0] expectation, the migration broke the legacy contract"
- },
- {
- "id": "DEFECT.NAME-COLLISION.examples",
- "klass": "DEFECT",
- "value": "#3577 config-ensure-section (legacy = no-arg full-default init via ensureConfigFile→buildNewProjectConfig; the rebound configEnsureSection = single-section ensure requiring args[0]; all CLI callers pass no args; handler throws \"Usage: config-ensure-section \")"
- },
- {
- "id": "DEFECT.NAME-COLLISION.fix-forward",
- "klass": "DEFECT",
- "value": "either (a) bind the dispatch to a handler whose body mirrors legacy semantics (e.g. configNewProject when no args), or (b) keep the dispatch case calling the original handler directly (precedent: 7d5dfa9d codex runtime carve-out). Whichever path, add a behavioral test that round-trips the legacy invocation shape to lock the contract"
- },
- {
- "id": "DEFECT.NAME-COLLISION.symptom",
- "klass": "DEFECT",
- "value": "a router migration rebinds CLI dispatch for a canonical command name to a handler with a different positional-arg shape; every legacy no-arg / wrong-arg caller then errors out at the new handler's own validation throw"
- },
- {
- "id": "DEFECT.PARSER-BRITTLE-MARKER-WHITELIST.detect",
- "klass": "DEFECT",
- "value": "any parser with hard-coded marker list; any parser that returns empty for non-matching input without warning"
- },
- {
- "id": "DEFECT.PARSER-BRITTLE-MARKER-WHITELIST.examples",
- "klass": "DEFECT",
- "value": "ac518646/#3263 code-review SUMMARY parser rejected BL-/blocker variants"
- },
- {
- "id": "DEFECT.PARSER-BRITTLE-MARKER-WHITELIST.fix-forward",
- "klass": "DEFECT",
- "value": "accept variants explicitly (case-insensitive, hyphen/space alternatives); on unknown marker emit a structured WARN with the original line so the human can fix the source"
- },
- {
- "id": "DEFECT.PARSER-BRITTLE-MARKER-WHITELIST.symptom",
- "klass": "DEFECT",
- "value": "human-output parser whitelists known markers (severity, status); silently drops unfamiliar markers as malformed"
- },
- {
- "id": "DEFECT.PHASE-DIR-PREFIX-DRIFT.anchor",
- "klass": "DEFECT",
- "value": "tests/phase.test.cjs (expected_phase_dir assertions; consolidated from tests/bug-3298-phase-dir-prefix-drift-in-workflows.test.cjs into the Phase Lifecycle Module test suite in #3741)"
- },
- {
- "id": "DEFECT.PHASE-DIR-PREFIX-DRIFT.detect",
- "klass": "DEFECT",
- "value": "grep mkdir/touch/path.join with {NN}-{slug} or padded_phase + phase_slug; if not consuming expected_phase_dir from init.* JSON it is drifting"
- },
- {
- "id": "DEFECT.PHASE-DIR-PREFIX-DRIFT.examples",
- "klass": "DEFECT",
- "value": "#3287 (init.phase-op + init.plan-phase first-touch), #3306/PRED.k015 (plan-milestone-gaps + import + add-backlog), #3297/#3298 (sibling reports)"
- },
- {
- "id": "DEFECT.PHASE-DIR-PREFIX-DRIFT.fix-forward",
- "klass": "DEFECT",
- "value": "consume expected_phase_dir from init.phase-op / init.plan-phase output; never re-construct from padded_phase + slug in workflow steps"
- },
- {
- "id": "DEFECT.PHASE-DIR-PREFIX-DRIFT.symptom",
- "klass": "DEFECT",
- "value": "multiple workflow files independently construct .planning/phases/{NN}-{slug} paths; project_code prefix or slug normalization missing in some surfaces"
- },
- {
- "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION-WITH-TESTS.detect",
- "klass": "DEFECT",
- "value": "CI security lane (Prompt injection scan step) reports FAIL: tests/.test.cjs with a line number pointing at a string literal; the literal is inside an assert.throws() or array of malicious inputs; the test file name is not in scripts/prompt-injection-scan.sh ALLOWLIST"
- },
- {
- "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION-WITH-TESTS.examples",
- "klass": "DEFECT",
- "value": "PR #1622 commit 4ed208e74 added convertClaudeCommandToWindsurfWorkflow commandName validation with 22 malicious-name fixtures; scanner matched an instruction-override phrase at tests/windsurf-conversion.test.cjs:122; CI security lane failed even though the test is the security control"
- },
- {
- "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION-WITH-TESTS.fix-forward",
- "klass": "DEFECT",
- "value": "ADD the test file to scripts/prompt-injection-scan.sh ALLOWLIST array with a comment citing this defect class; for large fixture sets, move them to tests/fixtures/adversarial/security/ (auto-allowlisted dir) and load via readFileSync; never weaken or fragment the payload to evade the scanner — that defeats the test's purpose; ALSO when documenting this defect in CONTEXT.md, do NOT quote the literal pattern — describe it generically (the scanner scans CONTEXT.md too)"
- },
- {
- "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION-WITH-TESTS.prevention",
- "klass": "DEFECT",
- "value": "when writing a security regression test that uses real injection payloads as fixtures, immediately add the test file path to scripts/prompt-injection-scan.sh ALLOWLIST in the same commit; when documenting this defect class anywhere under scanner scope (CONTEXT.md, docs/, agent .md), use descriptive references like 'scanner-matching payload' rather than quoting the literal pattern; ref DEFECT.PROMPT-INJECTION-SCAN-COLLISION (the older XML-tag-collision variant)"
- },
- {
- "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION-WITH-TESTS.symptom",
- "klass": "DEFECT",
- "value": "scripts/prompt-injection-scan.sh flags a NEW test file as a finding because the test contains real injection payloads as fixtures (strings that match one of the scanner's PATTERNS — see scripts/prompt-injection-scan.sh lines 18-64) to prove the validator under test rejects them; scanner cannot distinguish fixture from real injection; CI security lane fails on the test that ADDS the security validation"
- },
- {
- "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION.detect",
- "klass": "DEFECT",
- "value": "any new bare tag in agents/*.md"
- },
- {
- "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION.examples",
- "klass": "DEFECT",
- "value": "#3309 added a bare 'human' element (angle-bracket-wrapped) for verify-block harvesting; tests/prompt-injection-scan.security.test.cjs flags angle-bracket-wrapped names matching system|assistant|human (open or close form)"
- },
- {
- "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION.fix-forward",
- "klass": "DEFECT",
- "value": "hyphenate the tag (, ) — scanner regex matches bare names only"
- },
- {
- "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION.symptom",
- "klass": "DEFECT",
- "value": "custom XML element name in agent .md file matches scripts/scan-prompt-injection regex; legitimate agent vocabulary trips the security gate"
- },
- {
- "id": "DEFECT.REMOVED-BUT-NEEDED.detect",
- "klass": "DEFECT",
- "value": "before deletion, grep filename across .github/workflows, gsd-core/, docs/, package.json scripts; if any reference exists removal is incomplete"
- },
- {
- "id": "DEFECT.REMOVED-BUT-NEEDED.examples",
- "klass": "DEFECT",
- "value": "#3316 root package-lock.json (root package.json declares deps; workflows use cache:'npm' + npm ci), e3b52c70 docs referenced removed /gsd-new-workspace"
- },
- {
- "id": "DEFECT.REMOVED-BUT-NEEDED.fix-forward",
- "klass": "DEFECT",
- "value": "restore the file or update every consumer in the same commit; do not paper over with --no-package-lock or workflow workarounds that lose reproducibility"
- },
- {
- "id": "DEFECT.REMOVED-BUT-NEEDED.symptom",
- "klass": "DEFECT",
- "value": "file/key removed because \"no longer used\" without verifying every consumer (workflows, docs, manifests, npm scripts)"
- },
- {
- "id": "DEFECT.RESEARCH-PROVIDER-PROSE-DRIFT",
- "klass": "DEFECT",
- "value": "provider waterfall duplicated across N researcher agent .md files drifts independently (META.RULE.brief-no-paraphrase); fix-forward=research-provider.cjs single source of truth + generated agents (#657)"
- },
- {
- "id": "DEFECT.SCOPE.window",
- "klass": "DEFECT",
- "value": "PRs #3306..#3325 + sibling fixes #3240/#3242/#3245/#3257/#3261/#3267/#3286/#3287"
- },
- {
- "id": "DEFECT.SDK-PORT-NAME-COLLISION.generative-tie",
- "klass": "DEFECT",
- "value": "instance of DEFECT.GENERATIVE-PRIORITY — parity assertion at the test layer between CJS handler shape and SDK handler shape would have failed at PR open"
- },
- {
- "id": "DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.detect",
- "klass": "DEFECT",
- "value": "grep tests for fs.unlinkSync|rmSync|writeFileSync|renameSync|cpSync targeting paths resolved from the repo root (join(__dirname,'..',...)) under gsd-core/bin/lib or a shared committed fixture, instead of a mkdtempSync temp dir; any build helper (e.g. ensureBuiltArtifacts) invoked with real-tree paths during the concurrent test phase; any tsBuildInfoFile / build-cache path that lands inside a copied/shipped dir (gsd-core/bin/)"
- },
- {
- "id": "DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.examples",
- "klass": "DEFECT",
- "value": "#996/88e30d53 — bug-969 hardening tests fs.unlinkSync'd + restored the real gsd-core/bin/lib/core.cjs and set tsBuildInfoFile inside gsd-core/bin/ → next red across the full-test matrix (macOS/Windows) + ubuntu-24 coverage leg, ~40-50 MODULE_NOT_FOUND/ENOENT per leg; reproduced locally on iteration 1; fixed #1001/#1002"
- },
- {
- "id": "DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.fix-forward",
- "klass": "DEFECT",
- "value": "tests mutate ONLY isolated mkdtempSync copies — never delete/rewrite shared real build outputs while node --test runs files concurrently; parameterize build helpers to accept {root,srcDir,outDir,tsBuildInfoPath,tsconfigPath} overrides and point the test at a throwaway temp project (precedent: #1002 ensureBuiltArtifacts(overrides)); keep mutable build state (tsbuildinfo) OUTSIDE copied/shipped trees (repo root, gitignored) + best-effort self-heal of stale bin-local copies; this is the concrete instance of the RULESET.TESTS.delete-bad-tests real-race class"
- },
- {
- "id": "DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.symptom",
- "klass": "DEFECT",
- "value": "a test deletes/rewrites a SHARED REAL build artifact or fixture (e.g. gsd-core/bin/lib/*.cjs, the build tsbuildinfo) that other test files require; node --test runs files concurrently, so innocent concurrent tests intermittently fail with \"Cannot find module\" / ENOENT while the racy test itself passes (victim-not-culprit, leg-asymmetric red); placing mutable build state inside a copied/shipped tree (gsd-core/bin/) additionally races install-test fs.cpSync copies → copyfile ENOENT"
- },
- {
- "id": "DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.test-anchor",
- "klass": "DEFECT",
- "value": "tests/run-tests-harness.test.cjs (hermetic temp-project rewrite); regression gate = 10x concurrent run of that suite + tests/state.test.cjs + tests/install.test.cjs must be clean (reproduces on iter 1 when racy)"
- },
- {
- "id": "DEFECT.SOURCE-GREP-IN-NEW-TESTS.detect",
- "klass": "DEFECT",
- "value": "npm run lint (AST ESLint rule local/no-source-grep, eslint-rules/no-source-grep.cjs) fails with a line-number-precise violation"
- },
- {
- "id": "DEFECT.SOURCE-GREP-IN-NEW-TESTS.fix-forward",
- "klass": "DEFECT",
- "value": "replace with runGsdTools(...) behavioral test capturing JSON; if asserting agent .md content (which IS the runtime contract) add // allow-test-rule: source-text-is-the-product with one-line justification"
- },
- {
- "id": "DEFECT.SOURCE-GREP-IN-NEW-TESTS.symptom",
- "klass": "DEFECT",
- "value": "new test file uses readFileSync + .includes() / .match() against source code (RULESET.TESTS.no-source-grep); contradicts the test rule lint script"
- },
- {
- "id": "DEFECT.STACKED-PR-AUTO-RETARGET.detect",
- "klass": "DEFECT",
- "value": "ls-remote shows base ref absent; PR base still points at the deleted ref; mergeable=CONFLICTING with no real diff conflicts"
- },
- {
- "id": "DEFECT.STACKED-PR-AUTO-RETARGET.examples",
- "klass": "DEFECT",
- "value": "#3311 base fix/3255-add-json-errors-mode-gsd-tools deleted after #3304 merged"
- },
- {
- "id": "DEFECT.STACKED-PR-AUTO-RETARGET.fix-forward",
- "klass": "DEFECT",
- "value": "PATCH /repos/{owner}/{repo}/pulls/{N} -f base=main; rebase head onto current main; resolve carry-over commits (parent commits will auto-drop as patch contents already upstream)"
- },
- {
- "id": "DEFECT.STACKED-PR-AUTO-RETARGET.symptom",
- "klass": "DEFECT",
- "value": "PR #N is stacked on branch B; branch B merges to main and is deleted; GitHub does not reliably auto-retarget #N to main; PR shows DIRTY/CONFLICTING with phantom conflicts"
- },
- {
- "id": "DEFECT.STACKED-PR-CANNOT-STAND-ALONE.anti-pattern",
- "klass": "DEFECT",
- "value": "blindly running git rebase --onto origin/main on the patch branch — produces \"conflicts\" that are really \"the scaffolding doesn't exist yet\"; resolving them means reinventing the upstream PR's contribution, which duplicates work and creates merge hazards. Recognize the shape early via cat-file probe before rebasing"
- },
- {
- "id": "DEFECT.STACKED-PR-CANNOT-STAND-ALONE.detect",
- "klass": "DEFECT",
- "value": "gh pr view --json baseRefName shows non-main base; OR git rebase --onto origin/main produces real (not whitespace) conflicts at files the patch claims to modify; OR git cat-file -e origin/main: errors with \"does not exist in origin/main\""
- },
- {
- "id": "DEFECT.STACKED-PR-CANNOT-STAND-ALONE.examples",
- "klass": "DEFECT",
- "value": "#3639 + #3637 both targeted base=feat/3575-enforcement-hardening (the Phase 6 PR #3577); #3639 modifies SDK-bridge calls in 6 family-router files that on main do NOT have any SDK-bridge call yet; #3637 patches scripts/lint-shared-module-handsync.cjs which does not exist on main at all"
- },
- {
- "id": "DEFECT.STACKED-PR-CANNOT-STAND-ALONE.fix-forward",
- "klass": "DEFECT",
- "value": "user policy (this session, 2026-05-16): every PR must stand alone. Resolution = cherry-pick the patch's unique commits onto the upstream PR head, push to upstream PR branch, close patch PR with \"subsumed by #\". Alternatives explicitly rejected: leaving stacked open (\"no, fold them in\") and closing-without-folding (\"we want the fix\")"
- },
- {
- "id": "DEFECT.STACKED-PR-CANNOT-STAND-ALONE.symptom",
- "klass": "DEFECT",
- "value": "patch PR was authored against scaffolding (handler files, lint scripts, generated modules) that exists only on an unmerged upstream feature branch; the PR's \"base\" on GitHub is the feature branch, not main; merging requires the upstream PR to land first"
- },
- {
- "id": "DEFECT.STATE-TRAMPLE.detect",
- "klass": "DEFECT",
- "value": "any state writer that calls buildStateFrontmatter without preserving existing progress.* keys; any mutation surface that does not honor shouldPreserveExistingProgress"
- },
- {
- "id": "DEFECT.STATE-TRAMPLE.examples",
- "klass": "DEFECT",
- "value": "#3242 (Last Activity overwrote progress.completed_plans), #3257 (nested plans/ files uncounted), #3261 (buildStateFrontmatter), #3265 (canonical fields), #3286 (record-metric/add-decision sections)"
- },
- {
- "id": "DEFECT.STATE-TRAMPLE.fix-forward",
- "klass": "DEFECT",
- "value": "route through state-document.cjs/.ts shouldPreserveExistingProgress + normalizeProgressNumbers (extracted in #3316; the sdk/ tree that PR originally targeted has since been fully retired per ADR-0174 — these functions now live solely in src/state-document.cts)"
- },
- {
- "id": "DEFECT.STATE-TRAMPLE.symptom",
- "klass": "DEFECT",
- "value": "state-mutation paths overwrite curated values when body-derived computation is narrower than what's stored in frontmatter"
- },
- {
- "id": "DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL.anchor",
- "klass": "DEFECT",
- "value": "lesson: cross-turn task notifications are delivered only to the top-level orchestrator, never to a sub-agent — load-bearing for multi-worktree parallel fix dispatch (the CLAUDE.md passage this entry previously quoted verbatim has since been removed/rewritten; no live replacement citation exists)"
- },
- {
- "id": "DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL.detect",
- "klass": "DEFECT",
- "value": "sub-agent returns prematurely with text like \"I should wait for the notification per CLAUDE.md\" and incomplete work in its worktree (commits absent, push absent, PR absent)"
- },
- {
- "id": "DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL.fix-forward",
- "klass": "DEFECT",
- "value": "keep gsd-test-summary --both at the top-level orchestrator; sub-agents either run it foreground with timeout: 1500000 (25min) and block, OR delegate the test step back to the orchestrator (write commits + return); never have a sub-agent fire-and-await a backgrounded long task"
- },
- {
- "id": "DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL.symptom",
- "klass": "DEFECT",
- "value": "spawned sub-agent kicks off gsd-test-summary --both via Bash run_in_background, then stops on the harness \"you will be notified\" message; never receives the notification because cross-turn task-notifications are only delivered to the top-level orchestrator"
- },
- {
- "id": "DEFECT.SUPERSEDED-CONCURRENT-PRS.detect",
- "klass": "DEFECT",
- "value": "after a fix lands on main, grep recently-merged PR title for shared keyword/issue; check open PRs touching same files; if open PRs are subsets of merged work they are superseded"
- },
- {
- "id": "DEFECT.SUPERSEDED-CONCURRENT-PRS.examples",
- "klass": "DEFECT",
- "value": "#3303 + #3307 superseded by #3306 (all addressing #3297/#3298 project_code prefix family)"
- },
- {
- "id": "DEFECT.SUPERSEDED-CONCURRENT-PRS.fix-forward",
- "klass": "DEFECT",
- "value": "close superseded PRs via gh api PATCH state=closed; do not comment on self-authored PRs (k101); the link to the merged PR makes supersession discoverable in PR history"
- },
- {
- "id": "DEFECT.SUPERSEDED-CONCURRENT-PRS.symptom",
- "klass": "DEFECT",
- "value": "multiple in-flight PRs attack overlapping subsets of the same issue; the broadest one merges first; narrower siblings remain open with phantom conflicts"
- },
- {
- "id": "DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.detect",
- "klass": "DEFECT",
- "value": "test does readFileSync(md).match for a bash fence with literal \\n, OR execFileSync('bash',...) gated only on a bash-presence probe; also verifying a new test with a file-scoped run instead of the full suite hides repo-wide static guards; now enforced at write-time + CI by local/no-crlf-fragile-split (CRLF fence/frontmatter regex + readFileSync split-on-\\n) and local/no-unguarded-nonportable-exec (bash+chmod), eslint, ADR-1703"
- },
- {
- "id": "DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.examples",
- "klass": "DEFECT",
- "value": "#586/PR #650 tests/ship-586-verification-routing.test.cjs — the fence \\n offender failed ubuntu-24/macos/coverage, then the Windows tmpdir-path glob failed full test (windows-latest,22) at fail 3; both were invisible to file-scoped gsd-test-both runs because the parity guard is only scanned by the full suite"
- },
- {
- "id": "DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.fix-forward",
- "klass": "DEFECT",
- "value": "match the fence with \\r?\\n and normalize the captured block to LF; gate pipeline execution on process.platform !== 'win32' && hasBash since the extraction LOGIC is platform-independent and POSIX coverage suffices; run the full suite (or the parity/lint guards) before push when adding a test file"
- },
- {
- "id": "DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.symptom",
- "klass": "DEFECT",
- "value": "a test that parses a workflow bash block out of a *.md and runs it via execFileSync('bash',...) breaks on Windows two ways: the fence regex uses a literal \\n after the bash fence that will not match CRLF and is flagged by local/no-crlf-fragile-split (the windows-test-parity-guard ratchet it formerly tripped was deleted in ADR-1703 Phase 4 #1726); and git-bash exists so a bash-presence probe is true, but an os.tmpdir() Windows path (C:\\...) is un-globbable in bash so the pipeline returns empty and assertions fail"
- },
- {
- "id": "DEFECT.UNBOUNDED-SUBPROCESS.detect",
- "klass": "DEFECT",
- "value": "execSync/execFileSync/spawnSync without timeout option in non-test code; especially git list-worktrees, git fetch, npm view"
- },
- {
- "id": "DEFECT.UNBOUNDED-SUBPROCESS.examples",
- "klass": "DEFECT",
- "value": "a33cbe72 worktree fix bound git subprocesses with timeout"
- },
- {
- "id": "DEFECT.UNBOUNDED-SUBPROCESS.fix-forward",
- "klass": "DEFECT",
- "value": "add timeout (5-30s for git, 60s for npm); on timeout return degraded result + structured warning rather than throw"
- },
- {
- "id": "DEFECT.UNBOUNDED-SUBPROCESS.symptom",
- "klass": "DEFECT",
- "value": "git/npm subprocess shelled out without timeout; CLI hangs indefinitely on stuck remote, large repo, or missing network"
- },
- {
- "id": "DEFECT.WINDOWS-ARGV-OVERFLOW.detect",
- "klass": "DEFECT",
- "value": "Windows CI job at \"Run unit tests\" exits with code 1 within seconds of starting, no node:test output between \"run-tests: suite=… files=N: …\" line and \"Process completed with exit code 1\"; same job on Linux/macOS runs full duration"
- },
- {
- "id": "DEFECT.WINDOWS-ARGV-OVERFLOW.examples",
- "klass": "DEFECT",
- "value": "#3649 scripts/run-tests.cjs spawning 546 paths (~85 chars each ≈ 46 KB); Linux ARG_MAX 2 MB allows it, Windows aborts in ~70 ms with zero test output making the failure look like the runner itself crashed"
- },
- {
- "id": "DEFECT.WINDOWS-ARGV-OVERFLOW.fix-forward",
- "klass": "DEFECT",
- "value": "chunk argv into batches whose total length stays under 28,000 chars (headroom under the 32,767 ceiling); run each chunk sequentially; aggregate exit codes (first non-zero wins). Expose RUN_TESTS_MAX_CMDLINE_CHARS env override so cross-platform regression tests can force chunking with short tmp paths"
- },
- {
- "id": "DEFECT.WINDOWS-ARGV-OVERFLOW.prevention",
- "klass": "DEFECT",
- "value": "a RUNTIME argv-length property (args-array size not statically knowable) — NOT AST-lint-enforceable; addressed at the source by the production run-tests.cjs chunking under RUN_TESTS_MAX_CMDLINE_CHARS plus its test-anchor (tests/run-tests-harness.test.cjs). ADR-1703 Phase 3 (#1720) evaluated and dropped a no-oversized-test-argv lint rule as unsound (it could not detect the canonical execFileSync(node,[...paths]) array overflow)"
- },
- {
- "id": "DEFECT.WINDOWS-ARGV-OVERFLOW.symptom",
- "klass": "DEFECT",
- "value": "execFileSync(node, ['--test', ...N paths]) succeeds on Linux/macOS, instantly exits with code 1 and no test output on Windows when N×avg(path_len) exceeds 32,767 chars (CreateProcess lpCommandLine cap)"
- },
- {
- "id": "DEFECT.WINDOWS-ARGV-OVERFLOW.test-anchor",
- "klass": "DEFECT",
- "value": "tests/run-tests-harness.test.cjs \"Windows argv-overflow chunking (issue #3597)\" — 30 long-named fixture files + RUN_TESTS_MAX_CMDLINE_CHARS=2000 → asserts run-tests: chunk N/M marker in stderr; pattern works on every platform"
- },
- {
- "id": "DEFECT.WINDOWS-FS-OPS.detect",
- "klass": "DEFECT",
- "value": "ADR-1703 Phase 6: enforced by local/require-fs-op-fallback (AST ESLint rule, error) over src/**/*.cts + bin/install.js + scripts/build-hooks.js — flags an unguarded fs.rename/fs.renameSync (the atomic-publish primitive named in .symptom) that lacks a transient-errno retry or a Windows platform guard; a catch that silently swallows or cleans-up-and-rethrows without an errno check does NOT satisfy the .fix-forward clause. copyFile/unlink are the fallback primitives (out of scope); delegated retry helpers (retryRenameSync from shell-command-projection) are the recognized compliant shape"
- },
- {
- "id": "DEFECT.WINDOWS-FS-OPS.examples",
- "klass": "DEFECT",
- "value": "c47c2c5d build-hooks rename → copy fallback, d2412271 install Windows persistent SDK shim"
- },
- {
- "id": "DEFECT.WINDOWS-FS-OPS.fix-forward",
- "klass": "DEFECT",
- "value": "catch EPERM/EBUSY/EACCES, fall back to copy + unlink with retry, surface degraded-mode message; never silently swallow; the canonical production cure is retryRenameSync (shell-command-projection.cjs) or a bounded RENAME_RETRY_ERRNOS = new Set(['EPERM','EBUSY','EACCES']) loop"
- },
- {
- "id": "DEFECT.WINDOWS-FS-OPS.symptom",
- "klass": "DEFECT",
- "value": "fs.renameSync / fs.copyFileSync hits EPERM/EBUSY on Windows when antivirus or another process holds a transient handle on the target"
- },
- {
- "id": "DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.detect",
- "klass": "DEFECT",
- "value": "any function returning a filesystem path that flows into markdown/text body substitution; grep for path.join/raw resolvedTarget/${configDir}/ in code paths writing workflow .md, agent .md, or generated docs; smoke pattern is ${resolvedTarget}/ or ${configDir}/... templates that bypass normalization; NOW enforced at write-time + CI by local/normalize-path-in-content (eslint, error, src/**/*.cts; ADR-1703 Phase 5 #1733) — flags a path-returning fn result (path.basename excluded — returns a separator-less filename) interpolated DIRECTLY into @-reference content (shape a: @~/, @$, @/) or into a template immediately followed by a /…\\.md or /…\\.json quasi (shape b); INDIRECT data-flow (path stored in a variable/object field then interpolated, e.g. ${entry.ref}) is NOT detected by the rule — normalize at the assignment source or at the emit site; one known indirect leak (src/init.cts cmdAgentSkills entry.ref) fixed in PR #1733 by normalizing at emit; zero opt-out (the out-of-band disable-ban scans src/**/*.cts too)"
- },
- {
- "id": "DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.examples",
- "klass": "DEFECT",
- "value": "PR #1622 computePathPrefix returned ${resolvedTarget}/ verbatim — rewrites of @~/.claude/gsd-core/commands/gsd/X.md wrote @C:\\...\\gsd-ial-windsurf-XXX\\gsd-core/commands/gsd/help.md (trailing forward slashes from the original literal survived, prefix backslashes did not); tests/install-runtime-artifacts.test.cjs:318 + tests/install.test.cjs:1323 failed on windows-latest only"
- },
- {
- "id": "DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.fix-forward",
- "klass": "DEFECT",
- "value": "normalize at the SOURCE not the test: posixTarget=String(resolvedTarget).replace(/\\\\/g,'/'), posixHome=homeDir?String(homeDir).replace(/\\\\/g,'/'):homeDir; markdown body is POSIX-only; .replace(/\\\\/g,'/') is idempotent on POSIX (no backslashes present) so safe to apply unconditionally; isWindowsHost arg is a no-op tripwire (enh-1511) — do NOT branch on it, normalize always"
- },
- {
- "id": "DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.prevention",
- "klass": "DEFECT",
- "value": "enforced by local/normalize-path-in-content (eslint, error; ADR-1703 Phase 5 #1733) per RULESET.CONTENT-PATH-NORMALIZATION; tests are downstream signal, never the fix; ref DEFECT.WINDOWS-TEST-PORTABILITY for test-side parity (normalize expected substrings too: ${configDir}/foo.replace(/\\\\/g,'/'))"
- },
- {
- "id": "DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.symptom",
- "klass": "DEFECT",
- "value": "path.join() result on Windows (backslashes) substituted verbatim into markdown body (@-references, workflow files, generated docs); content gains mixed separators; cross-platform substring assertions fail on windows-latest CI lane only; macOS/Linux CI green so defect ships undetected"
- },
- {
- "id": "DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.detect",
- "klass": "DEFECT",
- "value": "any assert*/expect call whose ACTUAL operand is a call to a path-returning fn (path.join, path.resolve, resolveAgentDir, getPathX, computePathPrefix, os.homedir(), path.dirname/basename) AND whose EXPECTED operand is a string literal containing '/' that does NOT first flow through .replace(/\\\\/g,'/'); the literal-vs-fnCall shape is the tripwire — assert.equal(pathFn(...), '/hardcoded/posix/path') is the violation; assert.equal(String(pathFn(...)).replace(/\\\\/g,'/'), '/hardcoded/posix/path') is the compliant form; NOW mechanically enforced by the AST ESLint rule local/no-path-literal-in-assert (eslint-rules/no-path-literal-in-assert.cjs, ADR-1703 Phase 1 #1707) — platform-guard-aware (won't flag an assertion control-dependent on a process.platform !== 'win32' guard; eslint-rules/lib/platform-guard.cjs), fn list single-sourced as eslint-rules/lib/portability-vocab.cjs PATH_RETURNING_FNS (drift-guarded vs src/runtime-homes.cts)"
- },
- {
- "id": "DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.examples",
- "klass": "DEFECT",
- "value": "PR #1692 tests/stale-bake-guard.test.cjs resolveAgentDir suite: assert.equal(resolveAgentDir('opencode',{homedir:()=>'/H'}), '/H/.config/opencode/agent') — green on macOS+ubuntu (docker gate PASS 21101/21101), red on test (windows-latest,24) + full test (windows-latest,22, shard 2/3); same root cause as DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT but on the TEST side against a function return, not the production-markdown side"
- },
- {
- "id": "DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.fix-forward",
- "klass": "DEFECT",
- "value": "normalize the ACTUAL value to POSIX before comparing: assert.equal(String(pathFn(...)).replace(/\\\\/g,'/'), '/posix/literal'). Do NOT instead path.join the expected value to match the platform separator — that passes on every platform but masks a malformed backslash-on-POSIX return (both sides wrong together). The .replace is idempotent on POSIX so it is safe unconditionally. For values that are conceptually never paths (null/undefined/numbers), no normalization needed."
- },
- {
- "id": "DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.prevention",
- "klass": "DEFECT",
- "value": "enforced at write-time (editor) and in CI by the AST ESLint rule local/no-path-literal-in-assert (error, scoped to tests/**/*.test.cjs in eslint.config.mjs; ADR-1703 Phase 1 #1707); inline suppression is banned out-of-band by tests/portability-rule-disable-ban.test.cjs (zero escape hatches — structure platform-specific code behind a recognized process.platform guard, never opt out); run npm run lint before push; treat the CI windows-latest lane as the only true Windows signal — gsd-test (Mac/Linux only) cannot substitute; ref umbrella DEFECT.WINDOWS-TEST-PORTABILITY and production-side analogue DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT"
- },
- {
- "id": "DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.symptom",
- "klass": "DEFECT",
- "value": "an assertion compares the return value of a path-returning function (resolveAgentDir, path.join, path.resolve, getPathX, computePathPrefix, etc.) to a HARDCODED forward-slash string literal like '/H/.config/opencode/agent' or 'C:/Users/...' — passes on POSIX (macOS/linux/ubuntu CI incl. gsd-test docker mirror, where path.join emits forward slashes so literal == actual), FAILS on windows-latest CI lane where path.join emits backslashes so literal != actual"
- },
- {
- "id": "DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.detect",
- "klass": "DEFECT",
- "value": "grep tests for \\`.mode & 0o777\\` / \\`.mode) === 0o\\` / \\`writeFileSync(...{ mode: 0o\\` / \\`chmodSync\\` paired with a strict-equality assertion on the resulting mode; any such assertion is a POSIX-only fact that will diverge on Windows (write reads back as 0o666); NOW mechanically enforced by the AST ESLint rule local/no-posix-mode-bit-assert (eslint-rules/no-posix-mode-bit-assert.cjs, ADR-1703 Phase 2 #1711) — flags a .mode-vs-octal-literal equality assertion unless control-dependent on a process.platform !== 'win32' guard (eslint-rules/lib/platform-guard.cjs); zero opt-outs (tests/portability-rule-disable-ban.test.cjs)"
- },
- {
- "id": "DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.examples",
- "klass": "DEFECT",
- "value": "#1634/PR #1638 tests/capability-lifecycle.test.cjs \"a .cjs hook command is node-prefixed so it runs without the executable bit\" failed windows-latest,24 on \"precondition: file staged without +x\" (expected 420/0o644, got 438/0o666); the node-prefix behavioral assertion was correct — only the mode-bit precondition was the POSIX-only fact"
- },
- {
- "id": "DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.fix-forward",
- "klass": "DEFECT",
- "value": "gate the mode-bit precondition on if (process.platform !== 'win32') — the executable-bit/mode is a POSIX concept meaningless on Windows; KEEP the platform-independent behavioral assertion (the actual behavior under test) running on every OS; do NOT delete the precondition, scope it to POSIX"
- },
- {
- "id": "DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.prevention",
- "klass": "DEFECT",
- "value": "ref DEFECT.WINDOWS-TEST-PORTABILITY — gsd-test is Mac/Linux only (no Windows host), only the CI windows-latest lane catches this; enforced at write-time + CI by the AST ESLint rule local/no-posix-mode-bit-assert (eslint, error; ADR-1703 Phase 2 #1711); run npm run lint before push; prefer asserting the BEHAVIOR (command shape, runnability) over the filesystem mode bit"
- },
- {
- "id": "DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.symptom",
- "klass": "DEFECT",
- "value": "a test writes a file with a POSIX mode (fs.writeFileSync(p, data, {mode: 0o644}) or fs.chmodSync) then asserts fs.statSync(p).mode & 0o777 === ; passes on macOS/Linux/ubuntu CI, FAILS on the windows-latest CI lane — Windows fs does NOT honor POSIX write modes, Node reports the mode derived from the DOS readonly attribute (0o666 for writable / 0o444 for readonly), never the requested 0o644/0o755"
- },
- {
- "id": "DEFECT.WINDOWS-TEST-PORTABILITY.detect",
- "klass": "DEFECT",
- "value": "npm run lint (eslint) runs the local/* AST portability rules (ADR-1703): local/no-unguarded-nonportable-exec flags a test that chmods an exec bit AND runs it via sh/bash -c without a process.platform !== 'win32' guard (the retired scripts/lint-windows-test-portability.cjs tripwire, migrated to AST in #1720); local/no-path-literal-in-assert + local/no-posix-mode-bit-assert cover the assertion shapes; local/no-crlf-fragile-split (CRLF file-content split/regex), local/no-hardcoded-tmp (/tmp literal → os.tmpdir()), local/no-bare-npm-exec (npm needs shell:true on Windows) and local/require-userprofile-with-home (set USERPROFILE alongside HOME) replace the deleted windows-test-parity-guard ratchet (#1726); all are platform-guard-aware with zero opt-out (tests/portability-rule-disable-ban.test.cjs); watch CI windows matrix green before declaring a PR done"
- },
- {
- "id": "DEFECT.WINDOWS-TEST-PORTABILITY.examples",
- "klass": "DEFECT",
- "value": "PR #1084 (chmod 0o755 + bare-command execution failed on windows lane); PR #1692 tests/stale-bake-guard.test.cjs resolveAgentDir assertions hardcoded '/H/.config/opencode/agent' forward-slash literals against a path.join return — passed macOS/linux/ubuntu CI (incl. gsd-test docker mirror), failed windows-latest,24 + full test windows-latest,22 shard 2/3; test files that assert path.join result without normalizing to forward slashes"
- },
- {
- "id": "DEFECT.WINDOWS-TEST-PORTABILITY.fix-forward",
- "klass": "DEFECT",
- "value": "gate platform-specific execution with if (process.platform !== 'win32'); normalize path expectations to forward slashes with .replace(/\\\\/g, '/'); invoke scripts via explicit interpreter (sh ) rather than relying on exec-bit; there is NO opt-out for the local/* portability rules — structure platform-specific code behind a recognized process.platform !== 'win32' guard (ADR-1703 zero escape hatch)"
- },
- {
- "id": "DEFECT.WINDOWS-TEST-PORTABILITY.prevention",
- "klass": "DEFECT",
- "value": "run npm run lint (the local/* AST portability rules, ADR-1703) before opening a PR; treat the CI windows lane as the only true Windows signal — gsd-test (Mac/Linux only) cannot substitute for it"
- },
- {
- "id": "DEFECT.WINDOWS-TEST-PORTABILITY.symptom",
- "klass": "DEFECT",
- "value": "local gsd-test runs Mac+Linux only (no Windows host); Windows-only test failures (chmod exec-bit not honored for PATH-executing extension-less scripts in Git Bash msys2; / vs \\ path-separator in assertions; Git Bash msys2 shell semantics) surface ONLY in CI test (windows-latest,*) / full test (windows-latest,*) lanes, never locally"
- },
- {
- "id": "DEFECT.WORKFLOW-DELEGATION-TARGET-NOT-INSTALLED.detect",
- "klass": "DEFECT",
- "value": "after install, for every workflow .md file under //workflows/, extract the @ reference from the body and assert fs.existsSync(path); if any reference target is absent, this defect is present"
- },
- {
- "id": "DEFECT.WORKFLOW-DELEGATION-TARGET-NOT-INSTALLED.examples",
- "klass": "DEFECT",
- "value": "PR #1622 (issue #1615) shipped Windsurf /gsd-* workflow wrappers that all reference /.windsurf/gsd-core/commands/gsd/X.md; that directory was never populated; none of the reviews (security, Codex adversarial, Memtrace) caught it; a #1629 regression test verifying 'every workflow @- reference target exists on disk' surfaced it post-merge"
- },
- {
- "id": "DEFECT.WORKFLOW-DELEGATION-TARGET-NOT-INSTALLED.fix-forward",
- "klass": "DEFECT",
- "value": "copy the canonical command source (commands/gsd/*.md) into /gsd-core/commands/gsd/ during install, gated on the runtime that uses workflow delegation (currently Windsurf local only); use copyWithPathReplacement to apply the same path+brand rewrites as the rest of the install; verify with a regression test that every workflow's @-reference resolves"
- },
- {
- "id": "DEFECT.WORKFLOW-DELEGATION-TARGET-NOT-INSTALLED.prevention",
- "klass": "DEFECT",
- "value": "any new converter that emits a wrapper file delegating to another file MUST verify the delegation target is actually written by the same install; add a post-install invariant test: for every @ reference in every generated wrapper, assert the target exists; the workflow converter's hardcoded path was copy-pasted from Claude's skill pattern without verifying the target exists for the new runtime"
- },
- {
- "id": "DEFECT.WORKFLOW-DELEGATION-TARGET-NOT-INSTALLED.symptom",
- "klass": "DEFECT",
- "value": "workflow wrapper file (e.g. Windsurf convertClaudeCommandToWindsurfWorkflow) delegates to a command body at /gsd-core/commands/gsd/X.md via a hardcoded @~/.claude/gsd-core/commands/gsd/ path that _applyRuntimeRewrites rewrites to the install target; the source gsd-core/ dir ships without commands/ (it lives at package-root commands/gsd/); install completes successfully, workflow files appear in the / menu, but invocation tells the LLM to read a file that does not exist; the slash commands silently fail"
- },
- {
- "id": "DEFECT.WORKTREE-FETCH-SHA-DIVERGENCE.detect",
- "klass": "DEFECT",
- "value": "git rev-parse HEAD~1 vs git rev-parse origin/ — if they differ despite fetch the local copy was rewritten by some checkout-time hook"
- },
- {
- "id": "DEFECT.WORKTREE-FETCH-SHA-DIVERGENCE.examples",
- "klass": "DEFECT",
- "value": "this session, branch fix/3309-... and pr-3316"
- },
- {
- "id": "DEFECT.WORKTREE-FETCH-SHA-DIVERGENCE.fix-forward",
- "klass": "DEFECT",
- "value": "git checkout --detach origin/ directly; do work from detached HEAD; push HEAD:"
- },
- {
- "id": "DEFECT.WORKTREE-FETCH-SHA-DIVERGENCE.symptom",
- "klass": "DEFECT",
- "value": "in a worktree, git fetch origin pull/N/head:pr-N produces commits with SHAs different from the actual remote PR head SHA; force-push rejected as non-fast-forward despite recent fetch"
- },
{
"id": "EXEC.CLASSIFY.classes",
"klass": "EXEC",
@@ -1835,6 +994,11 @@
"klass": "RULESET",
"value": "the emitted-artifact family (ADR-2719, epic #2719) — POST-CUTOVER (#2724, Phase 4). Historically tests/fixtures/golden-install-parity/*.json (19 path→hash manifests) + tests/workflow-size-baseline.json + tests/agent-size-baseline.json were all committed, PURE FUNCTIONS of the source tree whose correct merge was ALWAYS \"recompute\" — 140 of 143 conflicted-file instances across the open PR queue were these files. #2724 DELETES all three, the golden test (tests/golden-install-parity.test.cjs), the generator (scripts/gen-golden-install-parity-zcode.cjs), `npm run gen:golden`, `UPDATE_GOLDEN`, the merge-driver bridge (scripts/git-merge-regen-driver.cjs, `npm run setup:merge-driver`, the .gitattributes merge=gsd-regen block), and scripts/update-size-baseline.cjs (`npm run size:baseline`). The differential attribution check (tests/emitted-attribution.test.cjs + tests/emitted-provenance.test.cjs) is now the SOLE gate for emitted-artifact propagation AND size growth — no committed artifact, nothing to hand-merge, nothing to regenerate. `npm run regen:derived` still exists for what remains committed and derived: build, registry, ADR index, capability matrix, inventory manifest, manifest versions, and `tests/fixtures/install-tree/*.json` (now `npm run gen:install-tree`, folded into `regen:derived`). tests/fixtures/install-tree/*.json is DELIBERATELY EXCLUDED from the cutover (ADR-2719 §7): it conflicts on 0 of 7, its diffs are readable, and it preserves \"the installer stopped shipping X\" as a hard absolute failure — capturing it would convert that absolute into an attribution-free auto-resolve. The baseline the differential compares against is now published by `scripts/gen-emitted-baseline.cjs` on every push to `next` (cached, keyed on sha) and restored in PR lanes via `GSD_EMITTED_BASELINE`/`resolveBaseline()` (tests/helpers/emitted-baseline.cjs); a cache miss falls back to an in-job build via a throwaway `git worktree` (tests/helpers/emitted-runtime.cjs's `buildBaselineAtRef`). REMEDIATION IS PART OF THE GATE (#2778): the failure output names its own remedy, because a gate that states a requirement and withholds the means of satisfying it is a maintainer round-trip, not a gate — ADR-2719 §3's \"conspicuous declaration\" only works if the contributor can discover how to make it. Both failing branches name a NEW fragment to create under `tests/emitted-drift-acks/` (#2914; pick a name nobody else is using), say it may not exist yet (absence is the healthy steady state), print a minimal valid document, and repeat \"do NOT regenerate anything\" — post-#2724 there is nothing left to regenerate, and hunting for a deleted baseline is the predictable wrong guess. The two branches key on DIFFERENT spaces and each says which: the hash pass keys on the EMITTED PATH (always contains a `/`), the size ratchet keys on the BARE FILENAME (`currentSizes` writes `sizes[entry.name]` from readdirSync over `gsd-core/workflows/` + `agents/`). A stale-ack failure additionally says to delete the FILE when removing its last entry, since an empty-but-present ack parses fine yet signals nothing; post-#2789 it also offers CORRECTING the entry to name the ripple actually made, which is the other honest resolution and the one a contributor usually wants. NOT ack-able and deliberately given no ack text: the `NEW_FILE_CAP` branch, whose remedy is extraction. Text is sourced from one frozen `REMEDIATION` export in tests/helpers/emitted-diff.cjs whose example document is rendered from `ACK_VERSION` via `JSON.stringify`, so the taught schema cannot drift from the accepted one (a round-trip test feeds the printed document back through `parseAck`); the message teaches ONE canonical shape even though `parseAck` also accepts a bare-string reason and a missing `version` — liberal in what it accepts, conservative in what it sends. Note the ADR's Consequences originally called the #2724 migration \"terminal\"; #2778 corrected that — it is terminal only for a PR that grows no shipped file. #2914 replaced the single shared ack file with per-PR fragments under `tests/emitted-drift-acks/` — exactly the shape `.changeset/` already uses for the identical \"every PR rewrites one shared document\" conflict problem — so two PRs needing an ack can no longer collide with each other, and a fragment left on `next` after merge is inert rather than a shared cell; the legacy file is still read and unioned in for branches that predate the split, and a duplicate path key across two sources is a hard, loudly-reported error, never silent last-wins. `tests/emitted-drift-ack.json` (the LEGACY file specifically, NOT the fragment directory) must NEVER persist on `next` (#2914): every entry is scoped to the diff that introduced it, so once merged it is by definition already at the base — spent and inert regardless of shape — and a persistent copy makes that ONE file a shared merge-conflict cell across every open PR that also carries an ack, exactly the \"140 of 143\" cost this whole cutover exists to remove; a persisting FRAGMENT is harmless by construction and is deliberately not what this guard checks. This is enforced on `next` itself only, never as a PR-lane check: the `guard-no-ack-on-next` job in `.github/workflows/test.yml` (push-to-`next` trigger) runs `scripts/lint-emitted-drift-ack.cjs --guard-next` (`assertAbsentOnNext`), which fails on the LEGACY file's PRESENCE alone, valid or not — a PR-lane \"base ack must be absent\" check would red every open PR the instant a spent ack merged, which is the #2768 shape #2789 already ended. cf `RULESET.WORKFLOW_SIZE_BUDGET`, `RULESET.AGENT_SIZE_BUDGET`; see `### Emitted Artifact Provenance`"
},
+ {
+ "id": "RULESET.GENERATIVE-FIX",
+ "klass": "RULESET",
+ "value": "parallel implementations diverge silently when no parity test enforces equality at the test layer; for any new constant/array/parser shared between two parallel surfaces (two workflow surfaces, or a generated artifact and its hand-authored source), the same commit MUST add a parity assertion that fails when the two diverge; exemplar: tests/runtime-launcher-parity.test.cjs (asserts every workflow bash block uses the canonical gsd_run launcher)"
+ },
{
"id": "RULESET.GH.AUTH.DEFAULT",
"klass": "RULESET",
diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md
index e96e946ac..7b09477d8 100644
--- a/docs/INVENTORY.md
+++ b/docs/INVENTORY.md
@@ -453,6 +453,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`.
| `broken-windows.cjs` | Broken-windows ledger library (issue #1950) — typed IR + I/O for `.planning/WINDOWS.md` (cross-phase defect register); pure `parseLedger`/`renderLedger`/`appendWindow`/`markWaived`/`markFixed`/`openCount` + I/O `cmdWindowsStatus`/`cmdWindowsAppend`/`cmdWindowsWaive`/`cmdWindowsMarkFixed`; frozen `REASON` enum for typed-error assertions; CLI surface `gsd-tools windows status\|append\|waive\|fixed`. Generated from `src/broken-windows.cts` |
| `capability-writer.cjs` | Capability State Writer (ADR-1213) — write-side inverse of the resolver; projects desired per-capability enabled/gates onto surface + config substrates, then re-resolves (assert-and-report); exports `setCapabilityState` and I/O handler `cmdCapabilitySet`; command surface: `gsd-tools capability set [--on\|--off] [--gate =]` |
| `check-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools check` |
+| `claude-orchestration.cjs` | Claude Orchestration capability (#1143) — Workflow-tool backend detection + emitter; `detectWorkflowBackend` fail-closed gate (`{available, backend: 'workflow'\|'inline', reason}`, degrades to today's inline behavior unless every gate opens) and `emitWorkflowScript` (maps GSD's wave/plan model onto Workflow primitives: wave → sequential `parallel()` barriers, plan → `agent(...)` with per-plan worktree isolation mirroring the inline path). Pure, zero external dependencies, never throws; never invokes the Workflow tool itself |
| `cli-exit.cjs` | `ExitError` class and `runMain()` helper — CLI entrypoints throw `ExitError` instead of calling `process.exit()`; `runMain()` translates the outcome into `process.exitCode` so output flushes cleanly |
| `cjs-command-router-adapter.cjs` | Shared compatibility adapter for manifest-backed CJS command-family routers |
| `clock.cjs` | Injectable clock seam (now/sleep) for deterministic lock testing |
diff --git a/eslint-rules/require-subprocess-timeout.cjs b/eslint-rules/require-subprocess-timeout.cjs
new file mode 100644
index 000000000..d594ec365
--- /dev/null
+++ b/eslint-rules/require-subprocess-timeout.cjs
@@ -0,0 +1,225 @@
+'use strict';
+
+/**
+ * require-subprocess-timeout
+ *
+ * Flag: an `execSync` / `execFileSync` / `spawnSync` call (the synchronous
+ * `node:child_process` primitives named in `DEFECT.UNBOUNDED-SUBPROCESS`)
+ * whose options object does not carry a `timeout` key.
+ *
+ * The canonical defect: a sync subprocess with no `timeout` cannot be
+ * interrupted and hangs indefinitely on a stuck remote, a large repo, or a
+ * missing network — freezing the calling process (and, on a CI runner, the
+ * whole chunk) with no diagnostic. CLAUDE.md's fix-forward is 5-30s for git,
+ * 60s for npm, with a degraded-result + warning on timeout rather than a
+ * throw.
+ *
+ * References:
+ * DEFECT.UNBOUNDED-SUBPROCESS (CONTEXT.md)
+ *
+ * Message:
+ * Cite DEFECT.UNBOUNDED-SUBPROCESS: a sync subprocess without `timeout`
+ * hangs indefinitely on a stuck remote/large repo/missing network. Add
+ * `timeout` (5-30s for git, 60s for npm).
+ *
+ * ── Known boundaries ─────────────────────────────────────────────────────────
+ *
+ * (a) Name-based matching only, mirroring require-fs-op-fallback.cjs's
+ * fs.rename precedent. Two callee shapes are recognized:
+ * - a bare Identifier call: `execSync(...)` / `execFileSync(...)` /
+ * `spawnSync(...)` (the destructured-import shape used by every
+ * production call site surveyed: `import { execFileSync } from
+ * 'node:child_process'`).
+ * - a dotted MemberExpression call on ANY object identifier:
+ * `childProcess.spawnSync(...)`, `cp.execSync(...)` (the
+ * default-import shape). Unlike require-fs-op-fallback's `fs.rename`
+ * check, the object name is NOT constrained to a fixed spelling
+ * (e.g. `childProcess`) — `execSync`/`execFileSync`/`spawnSync` are
+ * distinctive enough names that constraining the receiver would only
+ * create false negatives for equally-valid aliases (`cp`,
+ * `child_process`), unlike the generic `rename` method name that
+ * motivated locking `fs.rename` to the `fs` spelling.
+ * There is deliberately no static verification that the callee actually
+ * resolves to `node:child_process` (no import-binding trace) — the
+ * production survey showed zero collisions with unrelated methods of
+ * these three names.
+ *
+ * (b) Options-argument POSITION is resolved by fixed Node.js call arity, not
+ * "the last argument" — `execSync(command, options?)` puts options at
+ * index 1; `execFileSync(file, args?, options?)` / `spawnSync(file,
+ * args?, options?)` put options at index 2. A fixed index (rather than
+ * "last argument") is required because a 2-argument execFileSync/
+ * spawnSync call's 2nd argument is the command's `args` ARRAY, not
+ * options — treating it as a candidate options value would silently
+ * swallow the "no options passed at all" case (categorically unbounded).
+ * The one Node.js shape this does NOT detect: `execFileSync(file,
+ * options)` with the middle `args` array omitted entirely — the
+ * production survey found zero call sites using it, so it is out of
+ * scope for v1.
+ *
+ * (c) Only an OBJECT LITERAL at that fixed index is inspected for a
+ * `timeout` property (a plain key, e.g. `timeout: 5000` or `timeout:
+ * opts.timeout ?? 10_000` — the key's PRESENCE is what matters, not its
+ * value). An Identifier or spread-only options argument
+ * (`execFileSync('git', args, opts)`) is NOT flagged — the options may
+ * have been pre-built with a timeout elsewhere and this rule chooses
+ * precision over recall rather than trace the identifier back to its
+ * declaration.
+ *
+ * (d) A call with NO options argument at that index at all
+ * (`execSync('git status')`, `execFileSync('git', ['status'])`) IS
+ * flagged. The production survey of `src/**\/*.cts` found every real
+ * call site already passes an options object literal — there is no
+ * existing "bare, no options" shape to accommodate — and a call with no
+ * options object has categorically no `timeout`, so it is the same
+ * defect as an options object missing the key.
+ *
+ * Suppression: `// allow-unbounded-subprocess: ` as a trailing
+ * comment on the call's line (mirrors the `// allow-adhoc-markdown: `
+ * / `// allow-test-rule: ` per-finding-exemption convention).
+ */
+
+const SYNC_SUBPROCESS_METHODS = new Set(['execSync', 'execFileSync', 'spawnSync']);
+
+// Fixed options-argument index per method (see boundary (b) above):
+// execSync(command, options?) -> options at index 1
+// execFileSync(file, args?, options?) -> options at index 2
+// spawnSync(file, args?, options?) -> options at index 2
+const OPTIONS_ARG_INDEX = {
+ execSync: 1,
+ execFileSync: 2,
+ spawnSync: 2,
+};
+
+/**
+ * Returns the matched method name ('execSync'/'execFileSync'/'spawnSync') for
+ * a bare Identifier call or a dotted MemberExpression call on any object
+ * identifier (see boundary (a)), or null if `node` is not such a call.
+ */
+function matchSyncSubprocessMethodName(node) {
+ if (!node || node.type !== 'CallExpression') return null;
+ const callee = node.callee;
+ if (callee.type === 'Identifier' && SYNC_SUBPROCESS_METHODS.has(callee.name)) {
+ return callee.name;
+ }
+ if (
+ callee.type === 'MemberExpression' &&
+ !callee.computed &&
+ callee.property.type === 'Identifier' &&
+ SYNC_SUBPROCESS_METHODS.has(callee.property.name)
+ ) {
+ return callee.property.name;
+ }
+ return null;
+}
+
+/**
+ * Returns the AST node at the method's fixed options-argument index (see
+ * OPTIONS_ARG_INDEX / boundary (b)), or undefined if the call was not given
+ * that many arguments (no options passed at all).
+ */
+function getOptionsArgument(node, methodName) {
+ const idx = OPTIONS_ARG_INDEX[methodName];
+ return node.arguments[idx];
+}
+
+/**
+ * True if `optionsArg` is an ObjectExpression that carries a `timeout` key
+ * (any property kind: plain, computed-with-literal name). False for
+ * `undefined` (no options argument at all — boundary (d)), a non-object
+ * argument (Identifier/spread/etc — boundary (c)), or an object literal with
+ * no `timeout` key.
+ */
+function hasTimeoutOptionsObject(optionsArg) {
+ if (!optionsArg || optionsArg.type !== 'ObjectExpression') return false;
+ return optionsArg.properties.some((prop) => {
+ if (prop.type !== 'Property') return false; // skip SpreadElement
+ if (prop.computed) {
+ return prop.key.type === 'Literal' && prop.key.value === 'timeout';
+ }
+ if (prop.key.type === 'Identifier') return prop.key.name === 'timeout';
+ if (prop.key.type === 'Literal') return prop.key.value === 'timeout';
+ return false;
+ });
+}
+
+/**
+ * True if `optionsArg` IS present but is NOT an object literal (Identifier,
+ * spread-built, CallExpression, etc) — i.e. a pre-built options value the
+ * rule deliberately declines to trace (boundary (c): precision over recall).
+ */
+function isNonLiteralOptionsArg(optionsArg) {
+ return optionsArg !== undefined && optionsArg.type !== 'ObjectExpression';
+}
+
+/**
+ * True when a `// allow-unbounded-subprocess: ` comment sits on the
+ * node's start line or end line (covers both a trailing comment on a
+ * single-line call and one on the closing-paren line of a multi-line call).
+ */
+function hasSuppressionComment(node, sourceCode) {
+ const startLine = node.loc.start.line;
+ const endLine = node.loc.end.line;
+ const allComments = sourceCode.getAllComments();
+ return allComments.some((c) => {
+ if (!/allow-unbounded-subprocess:\s*\S/.test(c.value)) return false;
+ return c.loc.start.line === startLine || c.loc.start.line === endLine;
+ });
+}
+
+/** @type {import('eslint').Rule.RuleModule} */
+const rule = {
+ meta: {
+ type: 'problem',
+ docs: {
+ description:
+ 'Require execSync/execFileSync/spawnSync to carry a `timeout` option (DEFECT.UNBOUNDED-SUBPROCESS)',
+ category: 'Portability',
+ },
+ schema: [],
+ messages: {
+ requireSubprocessTimeout:
+ 'Unbounded sync subprocess: execSync/execFileSync/spawnSync without `timeout` hangs indefinitely ' +
+ 'on a stuck remote, a large repo, or missing network (DEFECT.UNBOUNDED-SUBPROCESS). Add `timeout` ' +
+ '(5-30s for git, 60s for npm) and handle the timeout with a degraded result, not a throw. ' +
+ 'Suppress with: // allow-unbounded-subprocess: ',
+ },
+ },
+
+ create(context) {
+ // Scope: src/**/*.cts only (never tests/**). eslint.config.mjs also scopes
+ // the plugin registration to `files: ['src/**/*.cts']`, but RuleTester
+ // runs the rule directly with no config-level file filtering, so the
+ // filename check must live in the rule itself for the VALID
+ // tests/**-filename test case to hold.
+ const filename = context.getFilename ? context.getFilename() : context.filename;
+ if (!/(?:^|\/)src\/.*\.cts$/.test(filename.replace(/\\/g, '/'))) {
+ return {};
+ }
+
+ const sourceCode = context.sourceCode ?? context.getSourceCode();
+
+ return {
+ CallExpression(node) {
+ const methodName = matchSyncSubprocessMethodName(node);
+ if (!methodName) return;
+
+ const optionsArg = getOptionsArgument(node, methodName);
+
+ // Precision over recall: an Identifier/spread-built options arg is
+ // not traced back to its declaration — not flagged.
+ if (isNonLiteralOptionsArg(optionsArg)) return;
+
+ // Object literal carrying a `timeout` key -> OK. (`optionsArg`
+ // undefined — no options passed at all — falls through to report.)
+ if (hasTimeoutOptionsObject(optionsArg)) return;
+
+ if (hasSuppressionComment(node, sourceCode)) return;
+
+ context.report({ node, messageId: 'requireSubprocessTimeout' });
+ },
+ };
+ },
+};
+
+module.exports = rule;
diff --git a/eslint.config.mjs b/eslint.config.mjs
index c7b959e0f..42045f1c9 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -26,6 +26,7 @@ import normalizePathInContent from './eslint-rules/normalize-path-in-content.cjs
import requireFsOpFallback from './eslint-rules/require-fs-op-fallback.cjs';
import noUnboundedSpawn from './eslint-rules/no-unbounded-spawn.cjs';
import noDuplicateFoldMarker from './eslint-rules/no-duplicate-fold-marker.cjs';
+import requireSubprocessTimeout from './eslint-rules/require-subprocess-timeout.cjs';
const localPlugin = {
rules: {
@@ -46,6 +47,7 @@ const localPlugin = {
'require-fs-op-fallback': requireFsOpFallback,
'no-unbounded-spawn': noUnboundedSpawn,
'no-duplicate-fold-marker': noDuplicateFoldMarker,
+ 'require-subprocess-timeout': requireSubprocessTimeout,
},
};
@@ -302,6 +304,11 @@ export default tseslint.config(
// (EPERM/EBUSY/EACCES retry or a Windows platform guard). See
// DEFECT.WINDOWS-FS-OPS in CONTEXT.md.
'local/require-fs-op-fallback': 'error',
+ // Flag execSync/execFileSync/spawnSync without a `timeout` option — an
+ // unbounded sync subprocess hangs indefinitely on a stuck remote/large
+ // repo/missing network (DEFECT.UNBOUNDED-SUBPROCESS in CONTEXT.md).
+ // The 8 pre-existing call sites this surfaced were migrated in #2896.
+ 'local/require-subprocess-timeout': 'error',
},
},
diff --git a/examples/dynamic-context-management/CONTEXT-INDEX.json b/examples/dynamic-context-management/CONTEXT-INDEX.json
index 20ba3b4cc..fe2d6f2a1 100644
--- a/examples/dynamic-context-management/CONTEXT-INDEX.json
+++ b/examples/dynamic-context-management/CONTEXT-INDEX.json
@@ -1,11 +1,10 @@
{
"schemaVersion": 1,
- "count": 428,
+ "count": 261,
"classes": {
"ARCH": 1,
"CI": 2,
"CONFIG": 5,
- "DEFECT": 168,
"EXEC": 8,
"GSD-RESEARCH": 6,
"LEARNING": 1,
@@ -18,7 +17,7 @@
"PROC": 14,
"PROHIB": 10,
"RELEASE-NOTES": 31,
- "RULESET": 57,
+ "RULESET": 58,
"SESSION": 9,
"WAVE": 5,
"WORKSTREAM": 5,
@@ -29,2569 +28,1567 @@
"id": "ARCH.SKILL.improve-codebase.next-candidates",
"klass": "ARCH",
"value": "[Workstream Progress Projection Module]",
- "line": 578
+ "line": 600
},
{
"id": "CI.GATE.changeset-lint",
"klass": "CI",
"value": "hard-fail for user-facing code diffs unless .changeset/* or PR has no-changelog label",
- "line": 562
+ "line": 584
},
{
"id": "CI.GATE.issue-link-required",
"klass": "CI",
"value": "hard-fail if PR body lacks closes/fixes/resolves #",
- "line": 561
+ "line": 583
},
{
"id": "CONFIG.LOCATION.SEAM.in-process-scrub",
"klass": "CONFIG",
"value": "TEST_ENV_BASE reaches CHILD env only; a test calling install() IN-PROCESS must additionally use helpers.scrubConfigLocationEnv() in beforeEach + its restorer in afterEach — HOME/USERPROFILE sandboxing is NOT sufficient because getGlobalConfigDir is env-FIRST",
- "line": 596
+ "line": 618
},
{
"id": "CONFIG.LOCATION.SEAM.kimi-two-homes",
"klass": "CONFIG",
"value": "kimi declares TWO config-location vars: KIMI_CONFIG_DIR (registry, generic Agent-Skills root via resolveKimiGlobalDir) and KIMI_SHARE_DIR (KIMI_HOOKS_TOML_DESCRIPTOR, kimi's OWN native config.toml carrying GSD's [[hooks]] block via resolveKimiHooksTomlDir); a registry-only derivation covers the first and silently misses the second",
- "line": 595
+ "line": 617
},
{
"id": "CONFIG.LOCATION.SEAM.scrub-set",
"klass": "CONFIG",
"value": "tests/helpers.cjs CONFIG_LOCATION_ENV_KEYS is DERIVED from five sources rather than maintained as one hand-written list (source 4 IS a literal residue list, for vars that fit no other rung — what is never hand-listed is the SET): capability-registry runtimes[].runtime.configHome.env AND [].configHome.skillsHome.env + runtime-homes NON_REGISTRY_CONFIG_HOME_DESCRIPTORS[].env AND [].skillsHome.env (a descriptor is a descriptor — BOTH descriptor rungs walk skillsHome, which resolves independently via resolveSkillsBaseFromDescriptor) + runtime-homes GSD_LOCATION_ENV_KEYS + a residue list (GROK_AGENTS_HOME, GSD_RUNTIME, GSD_PROJECT, GSD_WORKSTREAM) + WRITE_ESCAPE_PERMISSION_ENV_KEYS (GSD_ALLOW_SYMLINKED_DEST — a permission, not a location: it names no path but disarms the symlink-escape guard, so blanking it makes the guard STRICTER, never looser); adding a config-location var means making it ENUMERABLE at one of those sources, not appending a literal",
- "line": 593
+ "line": 615
},
{
"id": "CONFIG.LOCATION.SEAM.two-families",
"klass": "CONFIG",
"value": "runtime configHomes (where a third-party runtime keeps config, registry- or descriptor-declared) and GSD's OWN location vars (GSD_HOME -> $GSD_HOME/.gsd store, GSD_AGENTS_DIR -> getAgentsDir priority 1) are DISTINCT families; no registry derivation reaches the second, and treating a miss there as a registry gap is what produced review round 2",
- "line": 594
+ "line": 616
},
{
"id": "CONFIG.SEAM.loadConfig-context",
"klass": "CONFIG",
"value": "loadConfig(cwd,{workstream}) replaces env-mutation fallback; no temporary process.env GSD_WORKSTREAM rewrites",
- "line": 592
- },
- {
- "id": "DEFECT.AGENT-FILE-SIZE-CAP-BREACH.detect",
- "klass": "DEFECT",
- "value": "tests/planner-decomposition.test.cjs (\"planner is under 45K chars (proves mode sections were extracted)\") and tests/reachability-check.test.cjs (\"file stays under 50000 char limit\")",
- "line": 804
- },
- {
- "id": "DEFECT.AGENT-FILE-SIZE-CAP-BREACH.fix-forward",
- "klass": "DEFECT",
- "value": "mirror MVP mode pattern — extract full rules to gsd-core/references/planner-.md, leave a slim Detection section in the agent file with @-reference to the new file",
- "line": 805
- },
- {
- "id": "DEFECT.AGENT-FILE-SIZE-CAP-BREACH.state",
- "klass": "DEFECT",
- "value": "gsd-planner.md is 49,125 chars on main, just under the test's actual PLANNER_EXTRACTED_LIMIT of 48K (49,152 chars — the test's own title still says \"45K\" but the enforced constant was raised in #2341); the test currently passes, but any further net-new content risks pushing it over",
- "line": 803
- },
- {
- "id": "DEFECT.AGENT-FILE-SIZE-CAP-BREACH.symptom",
- "klass": "DEFECT",
- "value": "adding to agents/gsd-planner.md (or other large agent files) exceeds the 45K char extraction-evidence threshold",
- "line": 802
- },
- {
- "id": "DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.detect",
- "klass": "DEFECT",
- "value": "tests/slash-command-namespace.test.cjs prints \"Found N retired /gsd- reference(s) — use /gsd: instead\" with line-number-precise violations",
- "line": 1012
- },
- {
- "id": "DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.examples",
- "klass": "DEFECT",
- "value": "#3541 implementation included a typical /gsd-update path comment in installer-migration-report.cjs; caught by tests/slash-command-namespace.test.cjs (#3443 invariant)",
- "line": 1011
- },
- {
- "id": "DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.fix-forward",
- "klass": "DEFECT",
- "value": "replace /gsd- with /gsd: at the cited file:line; healthy emergent property — project-wide invariant test catches drift agents would never self-correct",
- "line": 1013
- },
- {
- "id": "DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.lesson",
- "klass": "DEFECT",
- "value": "agent-trust-but-verify is load-bearing — sub-agent reporting \"done\" is not a substitute for running the full suite; the invariant test surfaces drift even in doc-only changes",
- "line": 1014
- },
- {
- "id": "DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.symptom",
- "klass": "DEFECT",
- "value": "sub-agent writes /gsd- (legacy hyphen syntax) in code comments or doc strings while implementing a fix; lands as part of the implementation diff",
- "line": 1010
- },
- {
- "id": "DEFECT.BOT-BRANCH-STALE-BASE.detect",
- "klass": "DEFECT",
- "value": "git merge-base origin/ origin/main returns the bot branch tip — confirms the bot branch is an ancestor of main, just stale",
- "line": 784
- },
- {
- "id": "DEFECT.BOT-BRANCH-STALE-BASE.examples",
- "klass": "DEFECT",
- "value": "#3309 fix/3309-checkpoint-type-human-verify-burns-token (was at e14ef535; main at 2e87c60a)",
- "line": 783
- },
- {
- "id": "DEFECT.BOT-BRANCH-STALE-BASE.fix-forward",
- "klass": "DEFECT",
- "value": "git checkout --detach origin/main; do work; git checkout -b ; force-push with --force-with-lease",
- "line": 785
- },
- {
- "id": "DEFECT.BOT-BRANCH-STALE-BASE.symptom",
- "klass": "DEFECT",
- "value": "auto-branch.yml creates fix/{N}-{slug} when issue is filed; branch is anchored to issue-creation main; by the time work begins, main has moved",
- "line": 782
- },
- {
- "id": "DEFECT.CANARY-VERSION-LEAK.detect",
- "klass": "DEFECT",
- "value": "jq -r .version package.json on origin/main shows a -canary suffix; OR npm view dist-tags shows latest != main's version",
- "line": 967
- },
- {
- "id": "DEFECT.CANARY-VERSION-LEAK.examples",
- "klass": "DEFECT",
- "value": "2026-05-16 audit found origin/main + origin/feat/3575-enforcement-hardening both at \"version\": \"1.50.0-canary.0\" in sdk/package.json AND root package.json; npm view @opengsd/gsd-sdk versions returned [\"0.1.0\"] only, dist-tag latest=0.1.0, @1.50.0-canary.0 404 — confirms the string is metadata-only, never published. git log -S '\"version\": \"1.50.0-canary.0\"' origin/main blamed commit 2d32ad82 fix(plan-phase)... (#3206), a fix PR that accidentally carried the version bump from a dev-branch base",
- "line": 966
- },
- {
- "id": "DEFECT.CANARY-VERSION-LEAK.fix-forward",
- "klass": "DEFECT",
- "value": "open a chore/* PR against main that resets the version strings to the canonical pre-canary stable; rebase open PRs to pick it up; gate at PR open with a CI check that rejects -canary versions on PRs targeting main",
- "line": 968
- },
- {
- "id": "DEFECT.CANARY-VERSION-LEAK.symptom",
- "klass": "DEFECT",
- "value": "package.json version on main carries a -canary. suffix that per release policy belongs to the dev branch only; nothing publishable depends on the version string at runtime, but every consumer of the version metadata (release flow, install banners, statusline) sees the dev-channel label",
- "line": 965
- },
- {
- "id": "DEFECT.CHANGESET-PR-FIELD-DRIFT.detect",
- "klass": "DEFECT",
- "value": "changeset pr: value mismatches the actual PR number returned by gh api POST /pulls",
- "line": 809
- },
- {
- "id": "DEFECT.CHANGESET-PR-FIELD-DRIFT.examples",
- "klass": "DEFECT",
- "value": "#3316 (pr:3312 was the issue), #3325 (pr:3319 was a guess); recurs every cycle",
- "line": 808
- },
- {
- "id": "DEFECT.CHANGESET-PR-FIELD-DRIFT.fix-forward",
- "klass": "DEFECT",
- "value": "author changeset with placeholder pr:0; immediately after gh api POST /pulls returns the number, edit changeset and amend or follow-up commit; never guess",
- "line": 810
- },
- {
- "id": "DEFECT.CHANGESET-PR-FIELD-DRIFT.symptom",
- "klass": "DEFECT",
- "value": ".changeset/*.md frontmatter pr: value is the issue number, a guess made before PR opened, or a stale stacked-PR number",
- "line": 807
- },
- {
- "id": "DEFECT.DEFAULT-FLIP-DOCUMENTATION.detect",
- "klass": "DEFECT",
- "value": "any PR that changes a default value in CONFIG_DEFAULTS or buildNewProjectConfig; check that PR body Breaking Changes section explicitly covers (a) when the new default takes effect, (b) opt-back-in command, (c) effect on in-flight artifacts",
- "line": 844
- },
- {
- "id": "DEFECT.DEFAULT-FLIP-DOCUMENTATION.examples",
- "klass": "DEFECT",
- "value": "#3309 v2 default flip from mid-flight to end-of-phase",
- "line": 843
- },
- {
- "id": "DEFECT.DEFAULT-FLIP-DOCUMENTATION.fix-forward",
- "klass": "DEFECT",
- "value": "template — \"new default takes effect when .planning/config.json is rewritten (config-set, fresh project, regenerated config); existing artifacts continue to work; opt-back-in: gsd config-set \"",
- "line": 845
- },
- {
- "id": "DEFECT.DEFAULT-FLIP-DOCUMENTATION.symptom",
- "klass": "DEFECT",
- "value": "PR flips a config default but does not call out the migration semantics (when does the new default take effect; existing configs vs new configs; what the opt-back-in looks like)",
- "line": 842
- },
- {
- "id": "DEFECT.FORMAT",
- "klass": "DEFECT",
- "value": "class.sub-key=value | classes are greppable; each class carries detect / fix / anchor sub-keys when applicable",
- "line": 759
- },
- {
- "id": "DEFECT.FRONTMATTER-SCALAR-BROAD-GREP.detect",
- "klass": "DEFECT",
- "value": "grep \"^:\" on a *.md whose result is compared to exact tokens, with no frontmatter scoping and no -m1; one body line beginning : is enough to break it",
- "line": 857
- },
- {
- "id": "DEFECT.FRONTMATTER-SCALAR-BROAD-GREP.examples",
- "klass": "DEFECT",
- "value": "#586/PR #650 ship.md verification gate — grep \"^status:\" also matched body status: lines, yielding passed+gaps_found+human_needed instead of passed and blocking a passed phase; execute-phase.md has since been fixed to the frontmatter-scoped form (#651)",
- "line": 856
- },
- {
- "id": "DEFECT.FRONTMATTER-SCALAR-BROAD-GREP.fix-forward",
- "klass": "DEFECT",
- "value": "scope to the leading frontmatter block and take the first match: sed -n '/^---$/,/^---$/p' \"$f\" | grep -m1 \"^:\" | cut -d: -f2 | tr -d ' '; fix every parallel copy in the same change or consolidate behind one queryable seam (#651)",
- "line": 858
- },
- {
- "id": "DEFECT.FRONTMATTER-SCALAR-BROAD-GREP.symptom",
- "klass": "DEFECT",
- "value": "a YAML-frontmatter scalar (e.g. VERIFICATION.md status) read with grep \"^key:\" over the WHOLE markdown report instead of the frontmatter block; a key: line in the body (code block, copied artifact, example) returns extra matches that concatenate after cut|tr into a value matching no expected token, so a valid state is misrouted",
- "line": 855
- },
- {
- "id": "DEFECT.GENERATIVE-EXEMPLAR",
- "klass": "DEFECT",
- "value": "tests/runtime-launcher-parity.test.cjs (asserts every workflow bash block uses the canonical gsd_run launcher — the in-repo pattern for enforcing equality across parallel surfaces)",
- "line": 853
- },
- {
- "id": "DEFECT.GENERATIVE-FIX",
- "klass": "DEFECT",
- "value": "for any new constant/array/parser shared between two parallel surfaces (two workflow surfaces, or a generated artifact and its hand-authored source), the same commit MUST add a parity assertion that fails when the two diverge",
- "line": 852
- },
- {
- "id": "DEFECT.GENERATIVE-PRIORITY",
- "klass": "DEFECT",
- "value": "these defect classes share a common root: parallel implementations diverge silently because no parity test enforces equality at the test layer",
- "line": 851
- },
- {
- "id": "DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION.detect",
- "klass": "DEFECT",
- "value": "two gsd-test-summary --both runs in flight; UnicodeDecodeError in parse_events_from_string traceback; /tmp/gsd-test-*.jsonl size mismatch vs total events emitted",
- "line": 1003
- },
- {
- "id": "DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION.fix-forward",
- "klass": "DEFECT",
- "value": "set per-invocation LOCAL_OUT=/tmp/gsd-test--local.jsonl DOCKER_OUT=/tmp/gsd-test--docker.jsonl env vars; or serialize the runs; upstream fix tracked in #3545 (default to tempfile.mkstemp + advisory flock)",
- "line": 1004
- },
- {
- "id": "DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION.root-cause",
- "klass": "DEFECT",
- "value": "gsd-test-summary lines 126-127 default LOCAL_OUT/DOCKER_OUT to fixed /tmp/gsd-test-{local,docker}.jsonl; concurrent line-buffered writers interleave bytes mid-multibyte → split UTF-8 sequence → decoder explodes on f.read()",
- "line": 1002
- },
- {
- "id": "DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION.symptom",
- "klass": "DEFECT",
- "value": "two simultaneous gsd-test-summary --both invocations (e.g. one per worktree) both crash with UnicodeDecodeError in parse_events_from_file; \"local exit=1 docker exit=1\" reported even though remote containers ran fine",
- "line": 1001
- },
- {
- "id": "DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION.upstream",
- "klass": "DEFECT",
- "value": "open-gsd/gsd-test-runner#4 (moved from #3545 in the predecessor repo, filed in the wrong repo; now CLOSED/COMPLETED — fix shipped)",
- "line": 1005
- },
- {
- "id": "DEFECT.GSD-TEST-HOST-MID-RUN-DEATH.detect",
- "klass": "DEFECT",
- "value": "gsd-test-summary's task output file at /private/tmp/claude-*/tasks/.output stays 0 bytes for >5 min after launch; ps shows the test still alive; ssh -o ConnectTimeout=5 true now times out",
- "line": 971
- },
- {
- "id": "DEFECT.GSD-TEST-HOST-MID-RUN-DEATH.examples",
- "klass": "DEFECT",
- "value": "2026-05-16 redshirt probed up at 12:48 UTC, gsd-test-summary picked it, docker container spawned, then redshirt's ssh daemon stopped responding — banner-exchange timeout. Test stalled 20+ minutes with the wrapper's output file at 0 bytes",
- "line": 970
- },
- {
- "id": "DEFECT.GSD-TEST-HOST-MID-RUN-DEATH.fix-forward",
- "klass": "DEFECT",
- "value": "TaskStop the wrapper; pkill -f gsd-test-summary + pkill -f \"ssh \"; re-run gsd-test-summary so pick_host re-randomizes from the live set (probe each ~/.config/gsd-test/hosts entry first to confirm). Upstream fix candidate: gsd-test should add a heartbeat read on the ssh-stdin channel and abort + retry on a different host after N silent seconds",
- "line": 972
- },
- {
- "id": "DEFECT.GSD-TEST-HOST-MID-RUN-DEATH.related",
- "klass": "DEFECT",
- "value": "DEFECT.GSD-TEST-MIRROR-POISONED (legacy bind-mount ownership); GSD-TEST-CONCURRENT-OUTPUT-COLLISION (file collision) — host-mid-run-death is the third independent gsd-test infra failure mode this month",
- "line": 973
- },
- {
- "id": "DEFECT.GSD-TEST-HOST-MID-RUN-DEATH.symptom",
- "klass": "DEFECT",
- "value": "pick_host succeeds at probe time (ssh -o ConnectTimeout=3 -o BatchMode=yes \"$h\" true); subsequent ssh \"$h\" 'docker run ...' hangs indefinitely because the chosen host went unreachable between probe and exec; gsd-test-summary buffers stderr until the wrapper exits, so the operator sees no progress at all",
- "line": 969
- },
- {
- "id": "DEFECT.GSD-TEST-MIRROR-POISONED.detect",
- "klass": "DEFECT",
- "value": "docker stderr shows rsync: [generator] delete_file: unlink(...) failed: Permission denied (13) OR [receiver] mkstemp \".gsd-*.\" failed",
- "line": 995
- },
- {
- "id": "DEFECT.GSD-TEST-MIRROR-POISONED.recovery",
- "klass": "DEFECT",
- "value": "ssh 'docker run --rm -v ~/gsd-mirror-gsd-core:/work gsd-test:node22 chown -R : /work'; remote-uid is the SSH user's uid on the remote (1000 on holodeck, NOT local Mac 501)",
- "line": 997
- },
- {
- "id": "DEFECT.GSD-TEST-MIRROR-POISONED.root-cause",
- "klass": "DEFECT",
- "value": "container ran without --user; build:hooks wrote into bind-mount as root; chown-back-before-exec patch closes forward path but not legacy hosts",
- "line": 996
- },
- {
- "id": "DEFECT.GSD-TEST-MIRROR-POISONED.symptom",
- "klass": "DEFECT",
- "value": "gsd-test-summary --both exits docker=23 (rsync partial transfer) with mkstemp Permission denied on remote mirror files; mirror has root-owned artifacts from prior cold runs",
- "line": 994
- },
- {
- "id": "DEFECT.GSD-TEST-MIRROR-POISONED.upstream",
- "klass": "DEFECT",
- "value": "trek-e/gsd-test-runner#1 — proposes self-healing init-time chown probe",
- "line": 998
- },
- {
- "id": "DEFECT.HALT-COST-PATTERN.detect",
- "klass": "DEFECT",
- "value": "any subagent-spawning workflow with mid-flight pause-and-resume that does not preserve subagent context",
- "line": 834
- },
- {
- "id": "DEFECT.HALT-COST-PATTERN.examples",
- "klass": "DEFECT",
- "value": "#3309 checkpoint:human-verify (mid-flight halt = full executor cold-start per round-trip; reporter measured \"tens of thousands of tokens\" per halt)",
- "line": 833
- },
- {
- "id": "DEFECT.HALT-COST-PATTERN.fix-forward",
- "klass": "DEFECT",
- "value": "offer config flag for end-of-phase aggregation; if cost dominates make end-of-phase the default; route deferred items through existing verifier surface, do not invent new writer",
- "line": 835
- },
- {
- "id": "DEFECT.HALT-COST-PATTERN.symptom",
- "klass": "DEFECT",
- "value": "architecturally-sound checkpoint pattern produces hidden token cost because subagent context is discarded across the pause and respawn",
- "line": 832
- },
- {
- "id": "DEFECT.HOOK-OVER-ENFORCEMENT.detect",
- "klass": "DEFECT",
- "value": "hook re-fires on each invocation regardless of session-state read receipts",
- "line": 839
- },
- {
- "id": "DEFECT.HOOK-OVER-ENFORCEMENT.examples",
- "klass": "DEFECT",
- "value": "this session repeatedly hit \"Refusing to run gh issue create|edit / gh pr create|edit\" despite reading every listed file",
- "line": 838
- },
- {
- "id": "DEFECT.HOOK-OVER-ENFORCEMENT.fix-forward",
- "klass": "DEFECT",
- "value": "use gh api -X PATCH repos/{owner}/{repo}/pulls/{N} or repos/{owner}/{repo}/issues/{N} directly — same effect, hook regex does not match",
- "line": 840
- },
- {
- "id": "DEFECT.HOOK-OVER-ENFORCEMENT.read-tool-tracking",
- "klass": "DEFECT",
- "value": "gh-templates-first PreToolUse hook tracks Read tool invocations specifically; Bash cat/head of the same file does NOT satisfy the hook; future-self must use Read tool from the first contact with template files",
- "line": 1000
- },
- {
- "id": "DEFECT.HOOK-OVER-ENFORCEMENT.symptom",
- "klass": "DEFECT",
- "value": "PreToolUse hook keeps blocking gh pr edit / gh issue edit even after all required files are read in the session",
- "line": 837
- },
- {
- "id": "DEFECT.HOOK-OVER-ENFORCEMENT.write-bypass",
- "klass": "DEFECT",
- "value": "security_reminder_hook can block Write on substring match (e.g. a literal child-process call-expression token); workaround is heredoc to /tmp then mv into place, or use Edit instead — Edit hooks are more lenient than Write hooks",
- "line": 1019
- },
- {
- "id": "DEFECT.HOST-RESERVED-DIR-NAME",
- "klass": "DEFECT",
- "value": "a host runtime reserves a directory NAME that GSD also writes verbatim, so the mere presence of GSD's directory trips the host's own reserved-name detection regardless of contents; example: pi (#3023) treats GSD's shared-hooks bundle dir hooks/ as its own deprecated extension location and printed a startup warning purely because checkDeprecatedExtensionDirs() in packages/coding-agent/src/migrations.ts gates on a bare existsSync(hooksDir) with no readdir/emptiness check (unlike its tools/ sibling); fix-forward=make the shared-hooks directory name descriptor-driven (hostBehaviors.sharedHooksDirName, default hooks) and override it per-runtime when a name collision is detected (pi sets gsd-hooks), with adapters probing the new name then falling back to the legacy name for dev/half-upgraded trees",
- "line": 902
- },
- {
- "id": "DEFECT.INVENTORY-DRIFT.detect",
- "klass": "DEFECT",
- "value": "tests/inventory-manifest-sync.test.cjs fails with \"New surfaces not in manifest\"; tests/inventory-headings-countfree.test.cjs fails if a (N shipped) count is re-added to a heading",
- "line": 799
- },
- {
- "id": "DEFECT.INVENTORY-DRIFT.examples",
- "klass": "DEFECT",
- "value": "#3309 planner-human-verify-mode.md (caught by tests/inventory-manifest-sync.test.cjs)",
- "line": 798
- },
- {
- "id": "DEFECT.INVENTORY-DRIFT.fix-forward",
- "klass": "DEFECT",
- "value": "update INVENTORY.md row entry; run node scripts/gen-inventory-manifest.cjs --write to regen INVENTORY-MANIFEST.json (all eight families.* arrays are canonical — see RULESET.MANIFEST-CANONICAL-KEY); a workflow SUB-file (gsd-core/workflows//steps/*.md or modes/*.md) lands in workflow_steps/workflow_modes, not in workflows, which is keyed by bare basename and cannot hold a nested path",
- "line": 800
- },
- {
- "id": "DEFECT.INVENTORY-DRIFT.symptom",
- "klass": "DEFECT",
- "value": "new file added under gsd-core/references/ or gsd-core/workflows/ without updating docs/INVENTORY.md row AND docs/INVENTORY-MANIFEST.json",
- "line": 797
- },
- {
- "id": "DEFECT.NAME-COLLISION.detect",
- "klass": "DEFECT",
- "value": "trace every CLI/test caller of the canonical name → if any caller's argv shape differs from the rebound handler's args[0] expectation, the migration broke the legacy contract",
- "line": 942
- },
- {
- "id": "DEFECT.NAME-COLLISION.examples",
- "klass": "DEFECT",
- "value": "#3577 config-ensure-section (legacy = no-arg full-default init via ensureConfigFile→buildNewProjectConfig; the rebound configEnsureSection = single-section ensure requiring args[0]; all CLI callers pass no args; handler throws \"Usage: config-ensure-section \")",
- "line": 941
- },
- {
- "id": "DEFECT.NAME-COLLISION.fix-forward",
- "klass": "DEFECT",
- "value": "either (a) bind the dispatch to a handler whose body mirrors legacy semantics (e.g. configNewProject when no args), or (b) keep the dispatch case calling the original handler directly (precedent: 7d5dfa9d codex runtime carve-out). Whichever path, add a behavioral test that round-trips the legacy invocation shape to lock the contract",
- "line": 943
- },
- {
- "id": "DEFECT.NAME-COLLISION.symptom",
- "klass": "DEFECT",
- "value": "a router migration rebinds CLI dispatch for a canonical command name to a handler with a different positional-arg shape; every legacy no-arg / wrong-arg caller then errors out at the new handler's own validation throw",
- "line": 940
- },
- {
- "id": "DEFECT.PARSER-BRITTLE-MARKER-WHITELIST.detect",
- "klass": "DEFECT",
- "value": "any parser with hard-coded marker list; any parser that returns empty for non-matching input without warning",
- "line": 829
- },
- {
- "id": "DEFECT.PARSER-BRITTLE-MARKER-WHITELIST.examples",
- "klass": "DEFECT",
- "value": "ac518646/#3263 code-review SUMMARY parser rejected BL-/blocker variants",
- "line": 828
- },
- {
- "id": "DEFECT.PARSER-BRITTLE-MARKER-WHITELIST.fix-forward",
- "klass": "DEFECT",
- "value": "accept variants explicitly (case-insensitive, hyphen/space alternatives); on unknown marker emit a structured WARN with the original line so the human can fix the source",
- "line": 830
- },
- {
- "id": "DEFECT.PARSER-BRITTLE-MARKER-WHITELIST.symptom",
- "klass": "DEFECT",
- "value": "human-output parser whitelists known markers (severity, status); silently drops unfamiliar markers as malformed",
- "line": 827
- },
- {
- "id": "DEFECT.PHASE-DIR-PREFIX-DRIFT.anchor",
- "klass": "DEFECT",
- "value": "tests/phase.test.cjs (expected_phase_dir assertions; consolidated from tests/bug-3298-phase-dir-prefix-drift-in-workflows.test.cjs into the Phase Lifecycle Module test suite in #3741)",
- "line": 775
- },
- {
- "id": "DEFECT.PHASE-DIR-PREFIX-DRIFT.detect",
- "klass": "DEFECT",
- "value": "grep mkdir/touch/path.join with {NN}-{slug} or padded_phase + phase_slug; if not consuming expected_phase_dir from init.* JSON it is drifting",
- "line": 773
- },
- {
- "id": "DEFECT.PHASE-DIR-PREFIX-DRIFT.examples",
- "klass": "DEFECT",
- "value": "#3287 (init.phase-op + init.plan-phase first-touch), #3306/PRED.k015 (plan-milestone-gaps + import + add-backlog), #3297/#3298 (sibling reports)",
- "line": 772
- },
- {
- "id": "DEFECT.PHASE-DIR-PREFIX-DRIFT.fix-forward",
- "klass": "DEFECT",
- "value": "consume expected_phase_dir from init.phase-op / init.plan-phase output; never re-construct from padded_phase + slug in workflow steps",
- "line": 774
- },
- {
- "id": "DEFECT.PHASE-DIR-PREFIX-DRIFT.symptom",
- "klass": "DEFECT",
- "value": "multiple workflow files independently construct .planning/phases/{NN}-{slug} paths; project_code prefix or slug normalization missing in some surfaces",
- "line": 771
- },
- {
- "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION-WITH-TESTS.detect",
- "klass": "DEFECT",
- "value": "CI security lane (Prompt injection scan step) reports FAIL: tests/.test.cjs with a line number pointing at a string literal; the literal is inside an assert.throws() or array of malicious inputs; the test file name is not in scripts/prompt-injection-scan.sh ALLOWLIST",
- "line": 892
- },
- {
- "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION-WITH-TESTS.examples",
- "klass": "DEFECT",
- "value": "PR #1622 commit 4ed208e74 added convertClaudeCommandToWindsurfWorkflow commandName validation with 22 malicious-name fixtures; scanner matched an instruction-override phrase at tests/windsurf-conversion.test.cjs:122; CI security lane failed even though the test is the security control",
- "line": 891
- },
- {
- "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION-WITH-TESTS.fix-forward",
- "klass": "DEFECT",
- "value": "ADD the test file to scripts/prompt-injection-scan.sh ALLOWLIST array with a comment citing this defect class; for large fixture sets, move them to tests/fixtures/adversarial/security/ (auto-allowlisted dir) and load via readFileSync; never weaken or fragment the payload to evade the scanner — that defeats the test's purpose; ALSO when documenting this defect in CONTEXT.md, do NOT quote the literal pattern — describe it generically (the scanner scans CONTEXT.md too)",
- "line": 893
- },
- {
- "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION-WITH-TESTS.prevention",
- "klass": "DEFECT",
- "value": "when writing a security regression test that uses real injection payloads as fixtures, immediately add the test file path to scripts/prompt-injection-scan.sh ALLOWLIST in the same commit; when documenting this defect class anywhere under scanner scope (CONTEXT.md, docs/, agent .md), use descriptive references like 'scanner-matching payload' rather than quoting the literal pattern; ref DEFECT.PROMPT-INJECTION-SCAN-COLLISION (the older XML-tag-collision variant)",
- "line": 894
- },
- {
- "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION-WITH-TESTS.symptom",
- "klass": "DEFECT",
- "value": "scripts/prompt-injection-scan.sh flags a NEW test file as a finding because the test contains real injection payloads as fixtures (strings that match one of the scanner's PATTERNS — see scripts/prompt-injection-scan.sh lines 18-64) to prove the validator under test rejects them; scanner cannot distinguish fixture from real injection; CI security lane fails on the test that ADDS the security validation",
- "line": 890
- },
- {
- "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION.detect",
- "klass": "DEFECT",
- "value": "any new bare tag in agents/*.md",
- "line": 794
- },
- {
- "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION.examples",
- "klass": "DEFECT",
- "value": "#3309 added a bare 'human' element (angle-bracket-wrapped) for verify-block harvesting; tests/prompt-injection-scan.security.test.cjs flags angle-bracket-wrapped names matching system|assistant|human (open or close form)",
- "line": 793
- },
- {
- "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION.fix-forward",
- "klass": "DEFECT",
- "value": "hyphenate the tag (, ) — scanner regex matches bare names only",
- "line": 795
- },
- {
- "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION.symptom",
- "klass": "DEFECT",
- "value": "custom XML element name in agent .md file matches scripts/scan-prompt-injection regex; legitimate agent vocabulary trips the security gate",
- "line": 792
- },
- {
- "id": "DEFECT.REMOVED-BUT-NEEDED.detect",
- "klass": "DEFECT",
- "value": "before deletion, grep filename across .github/workflows, gsd-core/, docs/, package.json scripts; if any reference exists removal is incomplete",
- "line": 763
- },
- {
- "id": "DEFECT.REMOVED-BUT-NEEDED.examples",
- "klass": "DEFECT",
- "value": "#3316 root package-lock.json (root package.json declares deps; workflows use cache:'npm' + npm ci), e3b52c70 docs referenced removed /gsd-new-workspace",
- "line": 762
- },
- {
- "id": "DEFECT.REMOVED-BUT-NEEDED.fix-forward",
- "klass": "DEFECT",
- "value": "restore the file or update every consumer in the same commit; do not paper over with --no-package-lock or workflow workarounds that lose reproducibility",
- "line": 764
- },
- {
- "id": "DEFECT.REMOVED-BUT-NEEDED.symptom",
- "klass": "DEFECT",
- "value": "file/key removed because \"no longer used\" without verifying every consumer (workflows, docs, manifests, npm scripts)",
- "line": 761
- },
- {
- "id": "DEFECT.RESEARCH-PROVIDER-PROSE-DRIFT",
- "klass": "DEFECT",
- "value": "provider waterfall duplicated across N researcher agent .md files drifts independently (META.RULE.brief-no-paraphrase); fix-forward=research-provider.cjs single source of truth + generated agents (#657)",
- "line": 358
- },
- {
- "id": "DEFECT.SCOPE.window",
- "klass": "DEFECT",
- "value": "PRs #3306..#3325 + sibling fixes #3240/#3242/#3245/#3257/#3261/#3267/#3286/#3287",
- "line": 758
- },
- {
- "id": "DEFECT.SDK-PORT-NAME-COLLISION.generative-tie",
- "klass": "DEFECT",
- "value": "instance of DEFECT.GENERATIVE-PRIORITY — parity assertion at the test layer between CJS handler shape and SDK handler shape would have failed at PR open",
- "line": 944
- },
- {
- "id": "DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.detect",
- "klass": "DEFECT",
- "value": "grep tests for fs.unlinkSync|rmSync|writeFileSync|renameSync|cpSync targeting paths resolved from the repo root (join(__dirname,'..',...)) under gsd-core/bin/lib or a shared committed fixture, instead of a mkdtempSync temp dir; any build helper (e.g. ensureBuiltArtifacts) invoked with real-tree paths during the concurrent test phase; any tsBuildInfoFile / build-cache path that lands inside a copied/shipped dir (gsd-core/bin/)",
- "line": 955
- },
- {
- "id": "DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.examples",
- "klass": "DEFECT",
- "value": "#996/88e30d53 — bug-969 hardening tests fs.unlinkSync'd + restored the real gsd-core/bin/lib/core.cjs and set tsBuildInfoFile inside gsd-core/bin/ → next red across the full-test matrix (macOS/Windows) + ubuntu-24 coverage leg, ~40-50 MODULE_NOT_FOUND/ENOENT per leg; reproduced locally on iteration 1; fixed #1001/#1002",
- "line": 954
- },
- {
- "id": "DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.fix-forward",
- "klass": "DEFECT",
- "value": "tests mutate ONLY isolated mkdtempSync copies — never delete/rewrite shared real build outputs while node --test runs files concurrently; parameterize build helpers to accept {root,srcDir,outDir,tsBuildInfoPath,tsconfigPath} overrides and point the test at a throwaway temp project (precedent: #1002 ensureBuiltArtifacts(overrides)); keep mutable build state (tsbuildinfo) OUTSIDE copied/shipped trees (repo root, gitignored) + best-effort self-heal of stale bin-local copies; this is the concrete instance of the RULESET.TESTS.delete-bad-tests real-race class",
- "line": 956
- },
- {
- "id": "DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.symptom",
- "klass": "DEFECT",
- "value": "a test deletes/rewrites a SHARED REAL build artifact or fixture (e.g. gsd-core/bin/lib/*.cjs, the build tsbuildinfo) that other test files require; node --test runs files concurrently, so innocent concurrent tests intermittently fail with \"Cannot find module\" / ENOENT while the racy test itself passes (victim-not-culprit, leg-asymmetric red); placing mutable build state inside a copied/shipped tree (gsd-core/bin/) additionally races install-test fs.cpSync copies → copyfile ENOENT",
- "line": 953
- },
- {
- "id": "DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.test-anchor",
- "klass": "DEFECT",
- "value": "tests/run-tests-harness.test.cjs (hermetic temp-project rewrite); regression gate = 10x concurrent run of that suite + tests/state.test.cjs + tests/install.test.cjs must be clean (reproduces on iter 1 when racy)",
- "line": 957
- },
- {
- "id": "DEFECT.SOURCE-GREP-IN-NEW-TESTS.detect",
- "klass": "DEFECT",
- "value": "npm run lint (AST ESLint rule local/no-source-grep, eslint-rules/no-source-grep.cjs) fails with a line-number-precise violation",
- "line": 848
- },
- {
- "id": "DEFECT.SOURCE-GREP-IN-NEW-TESTS.fix-forward",
- "klass": "DEFECT",
- "value": "replace with runGsdTools(...) behavioral test capturing JSON; if asserting agent .md content (which IS the runtime contract) add // allow-test-rule: source-text-is-the-product with one-line justification",
- "line": 849
- },
- {
- "id": "DEFECT.SOURCE-GREP-IN-NEW-TESTS.symptom",
- "klass": "DEFECT",
- "value": "new test file uses readFileSync + .includes() / .match() against source code (RULESET.TESTS.no-source-grep); contradicts the test rule lint script",
- "line": 847
- },
- {
- "id": "DEFECT.STACKED-PR-AUTO-RETARGET.detect",
- "klass": "DEFECT",
- "value": "ls-remote shows base ref absent; PR base still points at the deleted ref; mergeable=CONFLICTING with no real diff conflicts",
- "line": 779
- },
- {
- "id": "DEFECT.STACKED-PR-AUTO-RETARGET.examples",
- "klass": "DEFECT",
- "value": "#3311 base fix/3255-add-json-errors-mode-gsd-tools deleted after #3304 merged",
- "line": 778
- },
- {
- "id": "DEFECT.STACKED-PR-AUTO-RETARGET.fix-forward",
- "klass": "DEFECT",
- "value": "PATCH /repos/{owner}/{repo}/pulls/{N} -f base=main; rebase head onto current main; resolve carry-over commits (parent commits will auto-drop as patch contents already upstream)",
- "line": 780
- },
- {
- "id": "DEFECT.STACKED-PR-AUTO-RETARGET.symptom",
- "klass": "DEFECT",
- "value": "PR #N is stacked on branch B; branch B merges to main and is deleted; GitHub does not reliably auto-retarget #N to main; PR shows DIRTY/CONFLICTING with phantom conflicts",
- "line": 777
- },
- {
- "id": "DEFECT.STACKED-PR-CANNOT-STAND-ALONE.anti-pattern",
- "klass": "DEFECT",
- "value": "blindly running git rebase --onto origin/main on the patch branch — produces \"conflicts\" that are really \"the scaffolding doesn't exist yet\"; resolving them means reinventing the upstream PR's contribution, which duplicates work and creates merge hazards. Recognize the shape early via cat-file probe before rebasing",
- "line": 963
- },
- {
- "id": "DEFECT.STACKED-PR-CANNOT-STAND-ALONE.detect",
- "klass": "DEFECT",
- "value": "gh pr view --json baseRefName shows non-main base; OR git rebase --onto origin/main produces real (not whitespace) conflicts at files the patch claims to modify; OR git cat-file -e origin/main: errors with \"does not exist in origin/main\"",
- "line": 961
- },
- {
- "id": "DEFECT.STACKED-PR-CANNOT-STAND-ALONE.examples",
- "klass": "DEFECT",
- "value": "#3639 + #3637 both targeted base=feat/3575-enforcement-hardening (the Phase 6 PR #3577); #3639 modifies SDK-bridge calls in 6 family-router files that on main do NOT have any SDK-bridge call yet; #3637 patches scripts/lint-shared-module-handsync.cjs which does not exist on main at all",
- "line": 960
- },
- {
- "id": "DEFECT.STACKED-PR-CANNOT-STAND-ALONE.fix-forward",
- "klass": "DEFECT",
- "value": "user policy (this session, 2026-05-16): every PR must stand alone. Resolution = cherry-pick the patch's unique commits onto the upstream PR head, push to upstream PR branch, close patch PR with \"subsumed by #\". Alternatives explicitly rejected: leaving stacked open (\"no, fold them in\") and closing-without-folding (\"we want the fix\")",
- "line": 962
- },
- {
- "id": "DEFECT.STACKED-PR-CANNOT-STAND-ALONE.symptom",
- "klass": "DEFECT",
- "value": "patch PR was authored against scaffolding (handler files, lint scripts, generated modules) that exists only on an unmerged upstream feature branch; the PR's \"base\" on GitHub is the feature branch, not main; merging requires the upstream PR to land first",
- "line": 959
- },
- {
- "id": "DEFECT.STATE-TRAMPLE.detect",
- "klass": "DEFECT",
- "value": "any state writer that calls buildStateFrontmatter without preserving existing progress.* keys; any mutation surface that does not honor shouldPreserveExistingProgress",
- "line": 768
- },
- {
- "id": "DEFECT.STATE-TRAMPLE.examples",
- "klass": "DEFECT",
- "value": "#3242 (Last Activity overwrote progress.completed_plans), #3257 (nested plans/ files uncounted), #3261 (buildStateFrontmatter), #3265 (canonical fields), #3286 (record-metric/add-decision sections)",
- "line": 767
- },
- {
- "id": "DEFECT.STATE-TRAMPLE.fix-forward",
- "klass": "DEFECT",
- "value": "route through state-document.cjs/.ts shouldPreserveExistingProgress + normalizeProgressNumbers (extracted in #3316; the sdk/ tree that PR originally targeted has since been fully retired per ADR-0174 — these functions now live solely in src/state-document.cts)",
- "line": 769
- },
- {
- "id": "DEFECT.STATE-TRAMPLE.symptom",
- "klass": "DEFECT",
- "value": "state-mutation paths overwrite curated values when body-derived computation is narrower than what's stored in frontmatter",
- "line": 766
- },
- {
- "id": "DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL.anchor",
- "klass": "DEFECT",
- "value": "lesson: cross-turn task notifications are delivered only to the top-level orchestrator, never to a sub-agent — load-bearing for multi-worktree parallel fix dispatch (the CLAUDE.md passage this entry previously quoted verbatim has since been removed/rewritten; no live replacement citation exists)",
- "line": 1009
- },
- {
- "id": "DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL.detect",
- "klass": "DEFECT",
- "value": "sub-agent returns prematurely with text like \"I should wait for the notification per CLAUDE.md\" and incomplete work in its worktree (commits absent, push absent, PR absent)",
- "line": 1007
- },
- {
- "id": "DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL.fix-forward",
- "klass": "DEFECT",
- "value": "keep gsd-test-summary --both at the top-level orchestrator; sub-agents either run it foreground with timeout: 1500000 (25min) and block, OR delegate the test step back to the orchestrator (write commits + return); never have a sub-agent fire-and-await a backgrounded long task",
- "line": 1008
- },
- {
- "id": "DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL.symptom",
- "klass": "DEFECT",
- "value": "spawned sub-agent kicks off gsd-test-summary --both via Bash run_in_background, then stops on the harness \"you will be notified\" message; never receives the notification because cross-turn task-notifications are only delivered to the top-level orchestrator",
- "line": 1006
- },
- {
- "id": "DEFECT.SUPERSEDED-CONCURRENT-PRS.detect",
- "klass": "DEFECT",
- "value": "after a fix lands on main, grep recently-merged PR title for shared keyword/issue; check open PRs touching same files; if open PRs are subsets of merged work they are superseded",
- "line": 789
- },
- {
- "id": "DEFECT.SUPERSEDED-CONCURRENT-PRS.examples",
- "klass": "DEFECT",
- "value": "#3303 + #3307 superseded by #3306 (all addressing #3297/#3298 project_code prefix family)",
- "line": 788
- },
- {
- "id": "DEFECT.SUPERSEDED-CONCURRENT-PRS.fix-forward",
- "klass": "DEFECT",
- "value": "close superseded PRs via gh api PATCH state=closed; do not comment on self-authored PRs (k101); the link to the merged PR makes supersession discoverable in PR history",
- "line": 790
- },
- {
- "id": "DEFECT.SUPERSEDED-CONCURRENT-PRS.symptom",
- "klass": "DEFECT",
- "value": "multiple in-flight PRs attack overlapping subsets of the same issue; the broadest one merges first; narrower siblings remain open with phantom conflicts",
- "line": 787
- },
- {
- "id": "DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.detect",
- "klass": "DEFECT",
- "value": "test does readFileSync(md).match for a bash fence with literal \\n, OR execFileSync('bash',...) gated only on a bash-presence probe; also verifying a new test with a file-scoped run instead of the full suite hides repo-wide static guards; now enforced at write-time + CI by local/no-crlf-fragile-split (CRLF fence/frontmatter regex + readFileSync split-on-\\n) and local/no-unguarded-nonportable-exec (bash+chmod), eslint, ADR-1703",
- "line": 861
- },
- {
- "id": "DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.examples",
- "klass": "DEFECT",
- "value": "#586/PR #650 tests/ship-586-verification-routing.test.cjs — the fence \\n offender failed ubuntu-24/macos/coverage, then the Windows tmpdir-path glob failed full test (windows-latest,22) at fail 3; both were invisible to file-scoped gsd-test-both runs because the parity guard is only scanned by the full suite",
- "line": 860
- },
- {
- "id": "DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.fix-forward",
- "klass": "DEFECT",
- "value": "match the fence with \\r?\\n and normalize the captured block to LF; gate pipeline execution on process.platform !== 'win32' && hasBash since the extraction LOGIC is platform-independent and POSIX coverage suffices; run the full suite (or the parity/lint guards) before push when adding a test file",
- "line": 862
- },
- {
- "id": "DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.symptom",
- "klass": "DEFECT",
- "value": "a test that parses a workflow bash block out of a *.md and runs it via execFileSync('bash',...) breaks on Windows two ways: the fence regex uses a literal \\n after the bash fence that will not match CRLF and is flagged by local/no-crlf-fragile-split (the windows-test-parity-guard ratchet it formerly tripped was deleted in ADR-1703 Phase 4 #1726); and git-bash exists so a bash-presence probe is true, but an os.tmpdir() Windows path (C:\\...) is un-globbable in bash so the pipeline returns empty and assertions fail",
- "line": 859
- },
- {
- "id": "DEFECT.UNBOUNDED-SUBPROCESS.detect",
- "klass": "DEFECT",
- "value": "execSync/execFileSync/spawnSync without timeout option in non-test code; especially git list-worktrees, git fetch, npm view",
- "line": 824
- },
- {
- "id": "DEFECT.UNBOUNDED-SUBPROCESS.examples",
- "klass": "DEFECT",
- "value": "a33cbe72 worktree fix bound git subprocesses with timeout",
- "line": 823
- },
- {
- "id": "DEFECT.UNBOUNDED-SUBPROCESS.fix-forward",
- "klass": "DEFECT",
- "value": "add timeout (5-30s for git, 60s for npm); on timeout return degraded result + structured warning rather than throw",
- "line": 825
- },
- {
- "id": "DEFECT.UNBOUNDED-SUBPROCESS.symptom",
- "klass": "DEFECT",
- "value": "git/npm subprocess shelled out without timeout; CLI hangs indefinitely on stuck remote, large repo, or missing network",
- "line": 822
- },
- {
- "id": "DEFECT.WINDOWS-ARGV-OVERFLOW.detect",
- "klass": "DEFECT",
- "value": "Windows CI job at \"Run unit tests\" exits with code 1 within seconds of starting, no node:test output between \"run-tests: suite=… files=N: …\" line and \"Process completed with exit code 1\"; same job on Linux/macOS runs full duration",
- "line": 948
- },
- {
- "id": "DEFECT.WINDOWS-ARGV-OVERFLOW.examples",
- "klass": "DEFECT",
- "value": "#3649 scripts/run-tests.cjs spawning 546 paths (~85 chars each ≈ 46 KB); Linux ARG_MAX 2 MB allows it, Windows aborts in ~70 ms with zero test output making the failure look like the runner itself crashed",
- "line": 947
- },
- {
- "id": "DEFECT.WINDOWS-ARGV-OVERFLOW.fix-forward",
- "klass": "DEFECT",
- "value": "chunk argv into batches whose total length stays under 28,000 chars (headroom under the 32,767 ceiling); run each chunk sequentially; aggregate exit codes (first non-zero wins). Expose RUN_TESTS_MAX_CMDLINE_CHARS env override so cross-platform regression tests can force chunking with short tmp paths",
- "line": 949
- },
- {
- "id": "DEFECT.WINDOWS-ARGV-OVERFLOW.prevention",
- "klass": "DEFECT",
- "value": "a RUNTIME argv-length property (args-array size not statically knowable) — NOT AST-lint-enforceable; addressed at the source by the production run-tests.cjs chunking under RUN_TESTS_MAX_CMDLINE_CHARS plus its test-anchor (tests/run-tests-harness.test.cjs). ADR-1703 Phase 3 (#1720) evaluated and dropped a no-oversized-test-argv lint rule as unsound (it could not detect the canonical execFileSync(node,[...paths]) array overflow)",
- "line": 951
- },
- {
- "id": "DEFECT.WINDOWS-ARGV-OVERFLOW.symptom",
- "klass": "DEFECT",
- "value": "execFileSync(node, ['--test', ...N paths]) succeeds on Linux/macOS, instantly exits with code 1 and no test output on Windows when N×avg(path_len) exceeds 32,767 chars (CreateProcess lpCommandLine cap)",
- "line": 946
- },
- {
- "id": "DEFECT.WINDOWS-ARGV-OVERFLOW.test-anchor",
- "klass": "DEFECT",
- "value": "tests/run-tests-harness.test.cjs \"Windows argv-overflow chunking (issue #3597)\" — 30 long-named fixture files + RUN_TESTS_MAX_CMDLINE_CHARS=2000 → asserts run-tests: chunk N/M marker in stderr; pattern works on every platform",
- "line": 950
- },
- {
- "id": "DEFECT.WINDOWS-FS-OPS.detect",
- "klass": "DEFECT",
- "value": "ADR-1703 Phase 6: enforced by local/require-fs-op-fallback (AST ESLint rule, error) over src/**/*.cts + bin/install.js + scripts/build-hooks.js — flags an unguarded fs.rename/fs.renameSync (the atomic-publish primitive named in .symptom) that lacks a transient-errno retry or a Windows platform guard; a catch that silently swallows or cleans-up-and-rethrows without an errno check does NOT satisfy the .fix-forward clause. copyFile/unlink are the fallback primitives (out of scope); delegated retry helpers (retryRenameSync from shell-command-projection) are the recognized compliant shape",
- "line": 819
- },
- {
- "id": "DEFECT.WINDOWS-FS-OPS.examples",
- "klass": "DEFECT",
- "value": "c47c2c5d build-hooks rename → copy fallback, d2412271 install Windows persistent SDK shim",
- "line": 818
- },
- {
- "id": "DEFECT.WINDOWS-FS-OPS.fix-forward",
- "klass": "DEFECT",
- "value": "catch EPERM/EBUSY/EACCES, fall back to copy + unlink with retry, surface degraded-mode message; never silently swallow; the canonical production cure is retryRenameSync (shell-command-projection.cjs) or a bounded RENAME_RETRY_ERRNOS = new Set(['EPERM','EBUSY','EACCES']) loop",
- "line": 820
- },
- {
- "id": "DEFECT.WINDOWS-FS-OPS.symptom",
- "klass": "DEFECT",
- "value": "fs.renameSync / fs.copyFileSync hits EPERM/EBUSY on Windows when antivirus or another process holds a transient handle on the target",
- "line": 817
- },
- {
- "id": "DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.detect",
- "klass": "DEFECT",
- "value": "any function returning a filesystem path that flows into markdown/text body substitution; grep for path.join/raw resolvedTarget/${configDir}/ in code paths writing workflow .md, agent .md, or generated docs; smoke pattern is ${resolvedTarget}/ or ${configDir}/... templates that bypass normalization; NOW enforced at write-time + CI by local/normalize-path-in-content (eslint, error, src/**/*.cts; ADR-1703 Phase 5 #1733) — flags a path-returning fn result (path.basename excluded — returns a separator-less filename) interpolated DIRECTLY into @-reference content (shape a: @~/, @$, @/) or into a template immediately followed by a /…\\.md or /…\\.json quasi (shape b); INDIRECT data-flow (path stored in a variable/object field then interpolated, e.g. ${entry.ref}) is NOT detected by the rule — normalize at the assignment source or at the emit site; one known indirect leak (src/init.cts cmdAgentSkills entry.ref) fixed in PR #1733 by normalizing at emit; zero opt-out (the out-of-band disable-ban scans src/**/*.cts too)",
- "line": 878
- },
- {
- "id": "DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.examples",
- "klass": "DEFECT",
- "value": "PR #1622 computePathPrefix returned ${resolvedTarget}/ verbatim — rewrites of @~/.claude/gsd-core/commands/gsd/X.md wrote @C:\\...\\gsd-ial-windsurf-XXX\\gsd-core/commands/gsd/help.md (trailing forward slashes from the original literal survived, prefix backslashes did not); tests/install-runtime-artifacts.test.cjs:318 + tests/install.test.cjs:1323 failed on windows-latest only",
- "line": 877
- },
- {
- "id": "DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.fix-forward",
- "klass": "DEFECT",
- "value": "normalize at the SOURCE not the test: posixTarget=String(resolvedTarget).replace(/\\\\/g,'/'), posixHome=homeDir?String(homeDir).replace(/\\\\/g,'/'):homeDir; markdown body is POSIX-only; .replace(/\\\\/g,'/') is idempotent on POSIX (no backslashes present) so safe to apply unconditionally; isWindowsHost arg is a no-op tripwire (enh-1511) — do NOT branch on it, normalize always",
- "line": 879
- },
- {
- "id": "DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.prevention",
- "klass": "DEFECT",
- "value": "enforced by local/normalize-path-in-content (eslint, error; ADR-1703 Phase 5 #1733) per RULESET.CONTENT-PATH-NORMALIZATION; tests are downstream signal, never the fix; ref DEFECT.WINDOWS-TEST-PORTABILITY for test-side parity (normalize expected substrings too: ${configDir}/foo.replace(/\\\\/g,'/'))",
- "line": 880
- },
- {
- "id": "DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.symptom",
- "klass": "DEFECT",
- "value": "path.join() result on Windows (backslashes) substituted verbatim into markdown body (@-references, workflow files, generated docs); content gains mixed separators; cross-platform substring assertions fail on windows-latest CI lane only; macOS/Linux CI green so defect ships undetected",
- "line": 876
- },
- {
- "id": "DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.detect",
- "klass": "DEFECT",
- "value": "any assert*/expect call whose ACTUAL operand is a call to a path-returning fn (path.join, path.resolve, resolveAgentDir, getPathX, computePathPrefix, os.homedir(), path.dirname/basename) AND whose EXPECTED operand is a string literal containing '/' that does NOT first flow through .replace(/\\\\/g,'/'); the literal-vs-fnCall shape is the tripwire — assert.equal(pathFn(...), '/hardcoded/posix/path') is the violation; assert.equal(String(pathFn(...)).replace(/\\\\/g,'/'), '/hardcoded/posix/path') is the compliant form; NOW mechanically enforced by the AST ESLint rule local/no-path-literal-in-assert (eslint-rules/no-path-literal-in-assert.cjs, ADR-1703 Phase 1 #1707) — platform-guard-aware (won't flag an assertion control-dependent on a process.platform !== 'win32' guard; eslint-rules/lib/platform-guard.cjs), fn list single-sourced as eslint-rules/lib/portability-vocab.cjs PATH_RETURNING_FNS (drift-guarded vs src/runtime-homes.cts)",
- "line": 886
- },
- {
- "id": "DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.examples",
- "klass": "DEFECT",
- "value": "PR #1692 tests/stale-bake-guard.test.cjs resolveAgentDir suite: assert.equal(resolveAgentDir('opencode',{homedir:()=>'/H'}), '/H/.config/opencode/agent') — green on macOS+ubuntu (docker gate PASS 21101/21101), red on test (windows-latest,24) + full test (windows-latest,22, shard 2/3); same root cause as DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT but on the TEST side against a function return, not the production-markdown side",
- "line": 885
- },
- {
- "id": "DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.fix-forward",
- "klass": "DEFECT",
- "value": "normalize the ACTUAL value to POSIX before comparing: assert.equal(String(pathFn(...)).replace(/\\\\/g,'/'), '/posix/literal'). Do NOT instead path.join the expected value to match the platform separator — that passes on every platform but masks a malformed backslash-on-POSIX return (both sides wrong together). The .replace is idempotent on POSIX so it is safe unconditionally. For values that are conceptually never paths (null/undefined/numbers), no normalization needed.",
- "line": 887
- },
- {
- "id": "DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.prevention",
- "klass": "DEFECT",
- "value": "enforced at write-time (editor) and in CI by the AST ESLint rule local/no-path-literal-in-assert (error, scoped to tests/**/*.test.cjs in eslint.config.mjs; ADR-1703 Phase 1 #1707); inline suppression is banned out-of-band by tests/portability-rule-disable-ban.test.cjs (zero escape hatches — structure platform-specific code behind a recognized process.platform guard, never opt out); run npm run lint before push; treat the CI windows-latest lane as the only true Windows signal — gsd-test (Mac/Linux only) cannot substitute; ref umbrella DEFECT.WINDOWS-TEST-PORTABILITY and production-side analogue DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT",
- "line": 888
- },
- {
- "id": "DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.symptom",
- "klass": "DEFECT",
- "value": "an assertion compares the return value of a path-returning function (resolveAgentDir, path.join, path.resolve, getPathX, computePathPrefix, etc.) to a HARDCODED forward-slash string literal like '/H/.config/opencode/agent' or 'C:/Users/...' — passes on POSIX (macOS/linux/ubuntu CI incl. gsd-test docker mirror, where path.join emits forward slashes so literal == actual), FAILS on windows-latest CI lane where path.join emits backslashes so literal != actual",
- "line": 884
- },
- {
- "id": "DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.detect",
- "klass": "DEFECT",
- "value": "grep tests for \\`.mode & 0o777\\` / \\`.mode) === 0o\\` / \\`writeFileSync(...{ mode: 0o\\` / \\`chmodSync\\` paired with a strict-equality assertion on the resulting mode; any such assertion is a POSIX-only fact that will diverge on Windows (write reads back as 0o666); NOW mechanically enforced by the AST ESLint rule local/no-posix-mode-bit-assert (eslint-rules/no-posix-mode-bit-assert.cjs, ADR-1703 Phase 2 #1711) — flags a .mode-vs-octal-literal equality assertion unless control-dependent on a process.platform !== 'win32' guard (eslint-rules/lib/platform-guard.cjs); zero opt-outs (tests/portability-rule-disable-ban.test.cjs)",
- "line": 872
- },
- {
- "id": "DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.examples",
- "klass": "DEFECT",
- "value": "#1634/PR #1638 tests/capability-lifecycle.test.cjs \"a .cjs hook command is node-prefixed so it runs without the executable bit\" failed windows-latest,24 on \"precondition: file staged without +x\" (expected 420/0o644, got 438/0o666); the node-prefix behavioral assertion was correct — only the mode-bit precondition was the POSIX-only fact",
- "line": 871
- },
- {
- "id": "DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.fix-forward",
- "klass": "DEFECT",
- "value": "gate the mode-bit precondition on if (process.platform !== 'win32') — the executable-bit/mode is a POSIX concept meaningless on Windows; KEEP the platform-independent behavioral assertion (the actual behavior under test) running on every OS; do NOT delete the precondition, scope it to POSIX",
- "line": 873
- },
- {
- "id": "DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.prevention",
- "klass": "DEFECT",
- "value": "ref DEFECT.WINDOWS-TEST-PORTABILITY — gsd-test is Mac/Linux only (no Windows host), only the CI windows-latest lane catches this; enforced at write-time + CI by the AST ESLint rule local/no-posix-mode-bit-assert (eslint, error; ADR-1703 Phase 2 #1711); run npm run lint before push; prefer asserting the BEHAVIOR (command shape, runnability) over the filesystem mode bit",
- "line": 874
- },
- {
- "id": "DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.symptom",
- "klass": "DEFECT",
- "value": "a test writes a file with a POSIX mode (fs.writeFileSync(p, data, {mode: 0o644}) or fs.chmodSync) then asserts fs.statSync(p).mode & 0o777 === ; passes on macOS/Linux/ubuntu CI, FAILS on the windows-latest CI lane — Windows fs does NOT honor POSIX write modes, Node reports the mode derived from the DOS readonly attribute (0o666 for writable / 0o444 for readonly), never the requested 0o644/0o755",
- "line": 870
- },
- {
- "id": "DEFECT.WINDOWS-TEST-PORTABILITY.detect",
- "klass": "DEFECT",
- "value": "npm run lint (eslint) runs the local/* AST portability rules (ADR-1703): local/no-unguarded-nonportable-exec flags a test that chmods an exec bit AND runs it via sh/bash -c without a process.platform !== 'win32' guard (the retired scripts/lint-windows-test-portability.cjs tripwire, migrated to AST in #1720); local/no-path-literal-in-assert + local/no-posix-mode-bit-assert cover the assertion shapes; local/no-crlf-fragile-split (CRLF file-content split/regex), local/no-hardcoded-tmp (/tmp literal → os.tmpdir()), local/no-bare-npm-exec (npm needs shell:true on Windows) and local/require-userprofile-with-home (set USERPROFILE alongside HOME) replace the deleted windows-test-parity-guard ratchet (#1726); all are platform-guard-aware with zero opt-out (tests/portability-rule-disable-ban.test.cjs); watch CI windows matrix green before declaring a PR done",
- "line": 866
- },
- {
- "id": "DEFECT.WINDOWS-TEST-PORTABILITY.examples",
- "klass": "DEFECT",
- "value": "PR #1084 (chmod 0o755 + bare-command execution failed on windows lane); PR #1692 tests/stale-bake-guard.test.cjs resolveAgentDir assertions hardcoded '/H/.config/opencode/agent' forward-slash literals against a path.join return — passed macOS/linux/ubuntu CI (incl. gsd-test docker mirror), failed windows-latest,24 + full test windows-latest,22 shard 2/3; test files that assert path.join result without normalizing to forward slashes",
- "line": 865
- },
- {
- "id": "DEFECT.WINDOWS-TEST-PORTABILITY.fix-forward",
- "klass": "DEFECT",
- "value": "gate platform-specific execution with if (process.platform !== 'win32'); normalize path expectations to forward slashes with .replace(/\\\\/g, '/'); invoke scripts via explicit interpreter (sh ) rather than relying on exec-bit; there is NO opt-out for the local/* portability rules — structure platform-specific code behind a recognized process.platform !== 'win32' guard (ADR-1703 zero escape hatch)",
- "line": 867
- },
- {
- "id": "DEFECT.WINDOWS-TEST-PORTABILITY.prevention",
- "klass": "DEFECT",
- "value": "run npm run lint (the local/* AST portability rules, ADR-1703) before opening a PR; treat the CI windows lane as the only true Windows signal — gsd-test (Mac/Linux only) cannot substitute for it",
- "line": 868
- },
- {
- "id": "DEFECT.WINDOWS-TEST-PORTABILITY.symptom",
- "klass": "DEFECT",
- "value": "local gsd-test runs Mac+Linux only (no Windows host); Windows-only test failures (chmod exec-bit not honored for PATH-executing extension-less scripts in Git Bash msys2; / vs \\ path-separator in assertions; Git Bash msys2 shell semantics) surface ONLY in CI test (windows-latest,*) / full test (windows-latest,*) lanes, never locally",
- "line": 864
- },
- {
- "id": "DEFECT.WORKFLOW-DELEGATION-TARGET-NOT-INSTALLED.detect",
- "klass": "DEFECT",
- "value": "after install, for every workflow .md file under //workflows/, extract the @ reference from the body and assert fs.existsSync(path); if any reference target is absent, this defect is present",
- "line": 898
- },
- {
- "id": "DEFECT.WORKFLOW-DELEGATION-TARGET-NOT-INSTALLED.examples",
- "klass": "DEFECT",
- "value": "PR #1622 (issue #1615) shipped Windsurf /gsd-* workflow wrappers that all reference /.windsurf/gsd-core/commands/gsd/X.md; that directory was never populated; none of the reviews (security, Codex adversarial, Memtrace) caught it; a #1629 regression test verifying 'every workflow @- reference target exists on disk' surfaced it post-merge",
- "line": 897
- },
- {
- "id": "DEFECT.WORKFLOW-DELEGATION-TARGET-NOT-INSTALLED.fix-forward",
- "klass": "DEFECT",
- "value": "copy the canonical command source (commands/gsd/*.md) into /gsd-core/commands/gsd/ during install, gated on the runtime that uses workflow delegation (currently Windsurf local only); use copyWithPathReplacement to apply the same path+brand rewrites as the rest of the install; verify with a regression test that every workflow's @-reference resolves",
- "line": 899
- },
- {
- "id": "DEFECT.WORKFLOW-DELEGATION-TARGET-NOT-INSTALLED.prevention",
- "klass": "DEFECT",
- "value": "any new converter that emits a wrapper file delegating to another file MUST verify the delegation target is actually written by the same install; add a post-install invariant test: for every @ reference in every generated wrapper, assert the target exists; the workflow converter's hardcoded path was copy-pasted from Claude's skill pattern without verifying the target exists for the new runtime",
- "line": 900
- },
- {
- "id": "DEFECT.WORKFLOW-DELEGATION-TARGET-NOT-INSTALLED.symptom",
- "klass": "DEFECT",
- "value": "workflow wrapper file (e.g. Windsurf convertClaudeCommandToWindsurfWorkflow) delegates to a command body at /gsd-core/commands/gsd/X.md via a hardcoded @~/.claude/gsd-core/commands/gsd/ path that _applyRuntimeRewrites rewrites to the install target; the source gsd-core/ dir ships without commands/ (it lives at package-root commands/gsd/); install completes successfully, workflow files appear in the / menu, but invocation tells the LLM to read a file that does not exist; the slash commands silently fail",
- "line": 896
- },
- {
- "id": "DEFECT.WORKTREE-FETCH-SHA-DIVERGENCE.detect",
- "klass": "DEFECT",
- "value": "git rev-parse HEAD~1 vs git rev-parse origin/ — if they differ despite fetch the local copy was rewritten by some checkout-time hook",
- "line": 814
- },
- {
- "id": "DEFECT.WORKTREE-FETCH-SHA-DIVERGENCE.examples",
- "klass": "DEFECT",
- "value": "this session, branch fix/3309-... and pr-3316",
- "line": 813
- },
- {
- "id": "DEFECT.WORKTREE-FETCH-SHA-DIVERGENCE.fix-forward",
- "klass": "DEFECT",
- "value": "git checkout --detach origin/ directly; do work from detached HEAD; push HEAD:",
- "line": 815
- },
- {
- "id": "DEFECT.WORKTREE-FETCH-SHA-DIVERGENCE.symptom",
- "klass": "DEFECT",
- "value": "in a worktree, git fetch origin pull/N/head:pr-N produces commits with SHAs different from the actual remote PR head SHA; force-push rejected as non-fast-forward despite recent fetch",
- "line": 812
+ "line": 614
},
{
"id": "EXEC.CLASSIFY.classes",
"klass": "EXEC",
"value": "{class:'quota-exceeded'|'classify-handoff-bug'|'unknown-failure', sentinel?, retryAfterSeconds?}",
- "line": 987
+ "line": 831
},
{
"id": "EXEC.CLASSIFY.cross-runtime",
"klass": "EXEC",
"value": "Anthropic/CC: usage limit|rate limit|quota|429|retry-after; Copilot CLI: rate_limit (stem); Codex CLI: 429|usage_limit_reached|too many requests",
- "line": 989
+ "line": 833
},
{
"id": "EXEC.CLASSIFY.handler",
"klass": "EXEC",
"value": "gsd-core/bin/lib/agent-command-router.cjs:classifyAgentFailure (registered via command-aliases.cjs; mutation:false outputMode:json)",
- "line": 985
+ "line": 829
},
{
"id": "EXEC.CLASSIFY.precedence",
"klass": "EXEC",
"value": "quota sentinel wins over classifyHandoffIfNeeded bug when both appear",
- "line": 990
+ "line": 834
},
{
"id": "EXEC.CLASSIFY.proactive-signal-not-usable",
"klass": "EXEC",
"value": "Anthropic exposes anthropic-ratelimit-* headers + Agent SDK RateLimitEvent; Claude Code subprocess does NOT forward to hooks/statusline today (upstream #33820, #22407, #32796)",
- "line": 992
+ "line": 836
},
{
"id": "EXEC.CLASSIFY.retry-after-parser",
"klass": "EXEC",
"value": "\\bretry[-_ ]after[:\\s]+(\\d+)\\b avoids embedded-word false matches like noretry-after",
- "line": 991
+ "line": 835
},
{
"id": "EXEC.CLASSIFY.sentinel-order",
"klass": "EXEC",
"value": "most specific first: 429 beats too-many-requests; resource_exhausted beats quota (array order in src/agent-command-router.cts QUOTA_SENTINELS checks resource_exhausted before quota); case-insensitive; canonical sentinel value is lower-cased form",
- "line": 988
+ "line": 832
},
{
"id": "EXEC.CLASSIFY.workflow",
"klass": "EXEC",
"value": "gsd-core/workflows/execute-phase.md step 7; class-distinct prompts (quota-to-wait-for-reset; classify-handoff-bug-to-spot-check; unknown-to-continue/stop)",
- "line": 986
+ "line": 830
},
{
"id": "GSD-RESEARCH.CONTEXT-DISCIPLINE",
"klass": "GSD-RESEARCH",
"value": "less-context levers: subagent isolation + compact provider output + fetches-to-disk + cache-returns-digest; API clear_tool_uses/memory tool are the conceptual model, not a Claude Code harness knob",
- "line": 357
+ "line": 374
},
{
"id": "GSD-RESEARCH.INTEGRATION.L2-hybrid",
"klass": "GSD-RESEARCH",
"value": "code owns cache+legitimacy+confidence+provider-pick (gsd-tools query research-plan/research-store/package-legitimacy); MCP owns the fetch; agent returns RESEARCH.md path, never raw fetches",
- "line": 355
+ "line": 372
},
{
"id": "GSD-RESEARCH.MODULE.package-legitimacy",
"klass": "GSD-RESEARCH",
"value": "registry-API verdicts (npm/PyPI/crates.io injectable adapters) computed from thresholds {minAgeDays:30,minWeeklyDownloads:1000,requireRepo:true}; verdict OK|SUS|SLOP per package; slopcheck=optional adapter that can only escalate, never the install-or-degrade gate",
- "line": 354
+ "line": 371
},
{
"id": "GSD-RESEARCH.MODULE.research-provider",
"klass": "GSD-RESEARCH",
"value": "single source of truth PROVIDER_WATERFALL (docs Context7->Ref->Jina->websearch; web Exa->Tavily->Perplexity->Brave->websearch; scrape Firecrawl->Jina); planResearch returns cache-hits+fetch-plan; classifyConfidence stamps HIGH|MEDIUM|LOW by provider AUTHORITY + verification EVIDENCE (HIGH requires code-computed ground-truth corroboration e.g. legitimacyVerdict OK; provider authority alone caps at MEDIUM; SLOP caps at LOW); Firecrawl is scrape-only (not in docs/web discovery)",
- "line": 353
+ "line": 370
},
{
"id": "GSD-RESEARCH.MODULE.research-store",
"klass": "GSD-RESEARCH",
"value": "content-addressed cache; key=sha256(ecosystem+library+version+query+kind); getResearch->{hit,stale} never throws (mirrors graphify staleness); ttlForSource curated HIGH 30d|MED 7d|web LOW 1d; tiers: curated-doc kinds -> ~/.gsd/research-cache (cross-project), web/synthesis -> project .planning/research/.cache",
- "line": 352
+ "line": 369
},
{
"id": "GSD-RESEARCH.PROVIDER.availability",
"klass": "GSD-RESEARCH",
"value": "config flags brave_search/exa_search/firecrawl/tavily_search/ref_search/perplexity/jina (env _API_KEY or ~/.gsd/_api_key); context7/jina/websearch always available; planResearch falls through waterfall to websearch terminal",
- "line": 356
+ "line": 373
},
{
"id": "LEARNING.prompt-budget.boundary-gap",
"klass": "LEARNING",
"value": "PR #3708 commit 2df566ed reserved NOTE_RESERVE_TOKENS in pressure-threshold AND in minSet pre-check; both buggy paths only fire when baseTokens ∈ (effectiveBudget - NOTE_RESERVE_TOKENS, effectiveBudget]; original test suite used budgets far from that band so neither path was exercised; fix bde1ae8f confines NOTE_RESERVE accounting to post-trim assembly path only; future budget/limit code MUST add boundary fixtures per RULESET.TESTS.boundary-coverage.fixtures",
- "line": 509
+ "line": 531
},
{
"id": "LIVE-CONFIG.GUARD.SEAM.ci-blind",
"klass": "LIVE-CONFIG",
"value": "the AMBIENT-ENV half stays CI-blind — CI never has these vars set, so green CI is not evidence for it; what strict mode catches in CI is the suite's own default-root leaks (HOME/USERPROFILE-derived), the guard remains the only loud signal for ambient-var escapes",
- "line": 602
+ "line": 624
},
{
"id": "LIVE-CONFIG.GUARD.SEAM.module",
"klass": "LIVE-CONFIG",
"value": "scripts/live-config-guard.cjs (deliberately NOT scripts/lib/, which the installer copies to users wholesale while uninstall removes only an allowlist; excluded from the npm tarball via package.json files[] together with its whole require chain run-tests.cjs/affected-tests-lib.cjs/run-affected-tests.cjs — a partial exclusion trips the #2858 shipped-requires-only-shipped gate); exports [resolveLiveConfigRoots, resolveExtraWatchTargets, snapshotLiveConfig, diffLiveConfig, formatViolations, newestMtime]; driven by scripts/run-tests.cjs pre/post suite",
- "line": 597
+ "line": 619
},
{
"id": "LIVE-CONFIG.GUARD.SEAM.non-root-targets",
"klass": "LIVE-CONFIG",
"value": "resolveExtraWatchTargets covers THREE live write surfaces that are not runtime config ROOTS (skills bases are a DELIBERATE non-target — the config-root layout misfires beneath them, so they need their own layout): $GSD_HOME/.gsd watched WHOLESALE (exclusively GSD-owned, so the shared-root trap does not apply) plus ONE config.toml per NON_REGISTRY_CONFIG_HOME_DESCRIPTORS entry, each watched as a SINGLE FILE (those roots belong to their products) — today three targets, since #2755 split Kimi CLI (~/.kimi, KIMI_SHARE_DIR) from Kimi Code (~/.kimi-code, KIMI_CODE_HOME); the targets are DERIVED by iterating that array, never by calling a named resolver, so a further descriptor is picked up without editing the guard PROVIDED it owns the same NON_REGISTRY_OWNED_FILE ('config.toml') — one that owns a different filename needs a per-descriptor mapping, the named residual the guard states at its own definition. SECOND RESIDUAL: config.toml is not all GSD writes into those roots — installSharedHooksBundle also populates /hooks/, which is UNWATCHED; closing it is a layout decision, like skills bases; passed to snapshotLiveConfig explicitly so a fixture-root caller cannot pull the real ~/.gsd into its snapshot",
- "line": 599
+ "line": 621
},
{
"id": "LIVE-CONFIG.GUARD.SEAM.scope",
"klass": "LIVE-CONFIG",
"value": "ownership-based, never whole-root: GSD_OWNED_ENTRIES top-level footprint + children whose name startsWith GSD_ARTIFACT_PREFIX ('gsd-') under GSD_PREFIXED_PARENTS (dirs shared with the host agent); watching a shared root wholesale false-positives on the host's own writes and a guard that cries wolf gets disabled",
- "line": 598
+ "line": 620
},
{
"id": "LIVE-CONFIG.GUARD.SEAM.severity",
"klass": "LIVE-CONFIG",
"value": "reports by default locally; CI wires GSD_STRICT_LIVE_CONFIG_GUARD=1 on Linux/macOS lanes (test.yml, all three test jobs) so a suite-produced leak FAILS those runs; Windows lanes stay report-only pending the documented pre-existing USERPROFILE sweep (~190 test sites sandbox HOME alone) — promote once that lands; skipped by GSD_SKIP_LIVE_CONFIG_GUARD=1",
- "line": 601
+ "line": 623
},
{
"id": "LIVE-CONFIG.GUARD.SEAM.truncation",
"klass": "LIVE-CONFIG",
"value": "MAX_ENTRIES/MAX_DEPTH bound the walk; a bound hit sets truncated and diffLiveConfig emits kind:'unverified' — a truncated scan MUST NOT read as clean; boundary covered at {limit-1,limit,limit+1} via newestMtime's injected budget plus fast-check monotonicity, per RULESET.TESTS.boundary-coverage + RULESET.TESTS.property-based-testing",
- "line": 600
+ "line": 622
},
{
"id": "META.RULE.brief-must-cite-doc",
"klass": "META",
"value": "agent prompts MUST quote the canonical doc line being applied; paraphrasing from predicate memory drifts and produces violations",
- "line": 653
+ "line": 675
},
{
"id": "META.RULE.brief-no-paraphrase",
"klass": "META",
"value": "writing \"k040 — never leave changelog box unchecked\" caused 5 of 8 agents to edit CHANGELOG.md in violation of CONTRIBUTING.md L110",
- "line": 654
+ "line": 676
},
{
"id": "META.RULE.canonical-source-precedence",
"klass": "META",
"value": "CONTRIBUTING.md > docs/adr/* > CONTEXT.md > agent memory",
- "line": 651
+ "line": 673
},
{
"id": "META.RULE.read-contributing-first",
"klass": "META",
"value": "read CONTRIBUTING.md sections \"Pull Request Guidelines\" + \"CHANGELOG Entries\" before EVERY agent dispatch",
- "line": 652
+ "line": 674
},
{
"id": "PLANNING.PATH.PARITY.project-scope",
"klass": "PLANNING",
"value": ".planning/ (never .planning/projects/); mirror planning-workspace.cjs planningDir()",
- "line": 587
+ "line": 609
},
{
"id": "PLANNING.PATH.SEAM.helpers",
"klass": "PLANNING",
"value": "helpers.planningPaths delegates to workspacePlanningPaths + resolveWorkspaceContext; precedence explicit-ws > env-ws > env-project > root",
- "line": 588
+ "line": 610
},
{
"id": "PLANNING.PATH.SEAM.init-handlers",
"klass": "PLANNING",
"value": "[initExecutePhase, initPlanPhase, initPhaseOp, initMilestoneOp] consume helpers.planningPaths().planning (no direct relPlanningPath join)",
- "line": 589
+ "line": 611
},
{
"id": "PR.3267.POSTMORTEM.recovery",
"klass": "PR",
"value": "[issue#3270 created, label approved-enhancement applied, PR reopened, body includes \"Closes #3270\", label no-changelog applied]",
- "line": 566
+ "line": 588
},
{
"id": "PR.3267.POSTMORTEM.root-cause",
"klass": "PR",
"value": "[missing issue link, missing changeset/no-changelog]",
- "line": 565
+ "line": 587
},
{
"id": "PRED.k320.canonical-source",
"klass": "PRED",
"value": "CONTRIBUTING.md L193-211",
- "line": 657
+ "line": 679
},
{
"id": "PRED.k320.ci-enforcement",
"klass": "PRED",
"value": "scripts/changeset/lint.cjs",
- "line": 663
+ "line": 685
},
{
"id": "PRED.k320.ci-paths-monitored",
"klass": "PRED",
"value": "bin/ gsd-core/ src/ agents/ commands/ hooks/ sdk/src/ sdk/prompts/",
- "line": 664
+ "line": 686
},
{
"id": "PRED.k320.cure",
"klass": "PRED",
"value": "drop .changeset/--.md fragment ONLY",
- "line": 659
+ "line": 681
},
{
"id": "PRED.k320.evidence",
"klass": "PRED",
"value": "PR #3302 merge-conflict against #3308 CHANGELOG.md row 2026-05-09",
- "line": 666
+ "line": 688
},
{
"id": "PRED.k320.opt-out-label",
"klass": "PRED",
"value": "no-changelog",
- "line": 662
+ "line": 684
},
{
"id": "PRED.k320.recovery",
"klass": "PRED",
"value": "open Removed-typed cleanup PR deleting only the redundant row",
- "line": 665
+ "line": 687
},
{
"id": "PRED.k320.rule",
"klass": "PRED",
"value": "do not edit CHANGELOG.md in feature/fix/enhancement PRs",
- "line": 658
+ "line": 680
},
{
"id": "PRED.k320.signal",
"klass": "PRED",
"value": "changelog-direct-edit-forbidden",
- "line": 656
+ "line": 678
},
{
"id": "PRED.k320.tool",
"klass": "PRED",
"value": "npm run changeset -- --type --pr --body \"...\"",
- "line": 660
+ "line": 682
},
{
"id": "PRED.k320.types",
"klass": "PRED",
"value": "Added|Changed|Deprecated|Removed|Fixed|Security",
- "line": 661
+ "line": 683
},
{
"id": "PRED.k321.evidence",
"klass": "PRED",
"value": "PRs #3304/#3305 (2026-05-09): real Minor/Major findings in body, 0 threads",
- "line": 672
+ "line": 694
},
{
"id": "PRED.k321.poll-shape",
"klass": "PRED",
"value": "parse pulls//reviews body AND graphql reviewThreads",
- "line": 670
+ "line": 692
},
{
"id": "PRED.k321.resolution",
"klass": "PRED",
"value": "address in code; no GraphQL resolveReviewThread needed for body-only findings",
- "line": 671
+ "line": 693
},
{
"id": "PRED.k321.shape",
"klass": "PRED",
"value": "CR posts \"[!CAUTION] outside the diff\" findings in review BODY, not in reviewThreads",
- "line": 669
+ "line": 691
},
{
"id": "PRED.k321.signal",
"klass": "PRED",
"value": "cr-outside-diff-range-finding",
- "line": 668
+ "line": 690
},
{
"id": "PRED.k322.cure-1",
"klass": "PRED",
"value": "2nd retrigger ~10min after first ack",
- "line": 677
+ "line": 699
},
{
"id": "PRED.k322.cure-2",
"klass": "PRED",
"value": "if silent at 50min, treat as silent-pass with maintainer flag in merge-commit body",
- "line": 678
+ "line": 700
},
{
"id": "PRED.k322.distinct-from",
"klass": "PRED",
"value": "k080",
- "line": 675
+ "line": 697
},
{
"id": "PRED.k322.evidence",
"klass": "PRED",
"value": "PR #3306 (2026-05-09): 0 reviews after 50min + 2 retriggers",
- "line": 680
+ "line": 702
},
{
"id": "PRED.k322.merge-gate-impact",
"klass": "PRED",
"value": "k070 real_coderabbit_review_present unsatisfied; requires maintainer judgment",
- "line": 679
+ "line": 701
},
{
"id": "PRED.k322.shape",
"klass": "PRED",
"value": "ack posted, real review never lands within [5s, 410s] cooldown after burst of N PRs <15min",
- "line": 676
+ "line": 698
},
{
"id": "PRED.k322.signal",
"klass": "PRED",
"value": "cr-sustained-throttle",
- "line": 674
+ "line": 696
},
{
"id": "PRED.k323.cure-alt",
"klass": "PRED",
"value": "consolidate into single PR when 2+ issues share root cause",
- "line": 685
+ "line": 707
},
{
"id": "PRED.k323.cure-pre-dispatch",
"klass": "PRED",
"value": "brief one agent canonical-owner; brief others to EXCLUDE shared site",
- "line": 684
+ "line": 706
},
{
"id": "PRED.k323.evidence",
"klass": "PRED",
"value": "#3300 (#3297) overlapped #3306 (#3298) on add-backlog.md hunks 2026-05-09",
- "line": 687
+ "line": 709
},
{
"id": "PRED.k323.recovery",
"klass": "PRED",
"value": "close smaller PR as \"subsumed by #N\" or rebase second to drop overlap hunk",
- "line": 686
+ "line": 708
},
{
"id": "PRED.k323.shape",
"klass": "PRED",
"value": "2+ open issues touch same canonical bug site; each fix's sibling-audit produces overlapping diff",
- "line": 683
+ "line": 705
},
{
"id": "PRED.k323.signal",
"klass": "PRED",
"value": "sibling-audit-cross-pr-overlap",
- "line": 682
+ "line": 704
},
{
"id": "PRED.k324.cure",
"klass": "PRED",
"value": "verify via gh api on every agent-completion notification; never trust narrative",
- "line": 691
+ "line": 713
},
{
"id": "PRED.k324.evidence",
"klass": "PRED",
"value": "2026-05-09 session: 5+ mid-monitor terminations across PRs #3232/#3271/#3251/#3255/#3262",
- "line": 693
+ "line": 715
},
{
"id": "PRED.k324.k095-restatement",
"klass": "PRED",
"value": "k095 confirmed shape: agent reports \"waiting for monitor\" / \"tests still running\" then terminates",
- "line": 690
+ "line": 712
},
{
"id": "PRED.k324.poll-shape",
"klass": "PRED",
"value": "gh pr view --json mergeStateStatus,statusCheckRollup + pulls//reviews + graphql reviewThreads + issues//comments tail",
- "line": 692
+ "line": 714
},
{
"id": "PRED.k324.signal",
"klass": "PRED",
"value": "agent-terminates-mid-monitor",
- "line": 689
+ "line": 711
},
{
"id": "PRED.k325.cleanup",
"klass": "PRED",
"value": "git worktree remove --force for aged agent worktrees",
- "line": 698
+ "line": 720
},
{
"id": "PRED.k325.cure",
"klass": "PRED",
"value": "detached-HEAD: git checkout --detach $(git ls-remote origin ); modify; commit; git push --force-with-lease=: origin HEAD:refs/heads/",
- "line": 697
+ "line": 719
},
{
"id": "PRED.k325.evidence",
"klass": "PRED",
"value": "2026-05-09 CHANGELOG.md strip on PRs #3300/#3302/#3304/#3305 required detached-HEAD",
- "line": 699
+ "line": 721
},
{
"id": "PRED.k325.shape",
"klass": "PRED",
"value": "git checkout errors \"already used by worktree at \"",
- "line": 696
+ "line": 718
},
{
"id": "PRED.k325.signal",
"klass": "PRED",
"value": "worktree-branch-lock-on-force-push",
- "line": 695
+ "line": 717
},
{
"id": "PRED.k326.cure",
"klass": "PRED",
"value": "quote canonical doc verbatim in brief; mentally simulate \"if all N agents follow this brief literally, do they violate any rule?\"",
- "line": 703
+ "line": 725
},
{
"id": "PRED.k326.evidence",
"klass": "PRED",
"value": "2026-05-09 brief \"k040 — update CHANGELOG.md\" → 5 of 8 agents violated CONTRIBUTING.md L110",
- "line": 704
+ "line": 726
},
{
"id": "PRED.k326.shape",
"klass": "PRED",
"value": "N parallel agents amplify a single brief-vs-doc contradiction into N violations",
- "line": 702
+ "line": 724
},
{
"id": "PRED.k326.signal",
"klass": "PRED",
"value": "brief-contradicts-canonical-doc",
- "line": 701
+ "line": 723
},
{
"id": "PRED.k327.ack-shape",
"klass": "PRED",
"value": "body \"✅ Actions performed - Full review triggered\"",
- "line": 707
+ "line": 729
},
{
"id": "PRED.k327.cooldown-normal",
"klass": "PRED",
"value": "[5s, 410s]",
- "line": 710
+ "line": 732
},
{
"id": "PRED.k327.cooldown-throttled",
"klass": "PRED",
"value": "k322",
- "line": 711
+ "line": 733
},
{
"id": "PRED.k327.distinguish-key",
"klass": "PRED",
"value": "len(pulls//reviews) — ack=0, real=≥1",
- "line": 709
+ "line": 731
},
{
"id": "PRED.k327.real-review-shape",
"klass": "PRED",
"value": "body starts \"Actionable comments posted: N\" OR \"[!CAUTION] Some comments are outside the diff\"",
- "line": 708
+ "line": 730
},
{
"id": "PRED.k327.signal",
"klass": "PRED",
"value": "cr-ack-vs-real-review",
- "line": 706
+ "line": 728
},
{
"id": "PRED.k328.audit-list",
"klass": "PRED",
"value": "[heading-matches-class, closing-keyword-present, changeset-fragment-or-no-changelog-label]",
- "line": 716
+ "line": 738
},
{
"id": "PRED.k328.canonical-source",
"klass": "PRED",
"value": "CONTRIBUTING.md L48,L64,L81 (template links) + .github/PULL_REQUEST_TEMPLATE/{fix,enhancement,feature}.md L1 (heading text)",
- "line": 714
+ "line": 736
},
{
"id": "PRED.k328.k100-restatement",
"klass": "PRED",
"value": "heading must match issue class: bug→## Fix PR, enhancement→## Enhancement PR, feature→## Feature PR",
- "line": 715
+ "line": 737
},
{
"id": "PRED.k328.signal",
"klass": "PRED",
"value": "pr-template-typed-heading-required",
- "line": 713
+ "line": 735
},
{
"id": "PRED.k329.body",
"klass": "PRED",
"value": "**** — . (#)",
- "line": 722
+ "line": 744
},
{
"id": "PRED.k329.canonical-source",
"klass": "PRED",
"value": "CONTRIBUTING.md L196-202 + .changeset/README.md",
- "line": 719
+ "line": 741
},
{
"id": "PRED.k329.filename",
"klass": "PRED",
"value": ".changeset/--.md",
- "line": 720
+ "line": 742
},
{
"id": "PRED.k329.frontmatter",
"klass": "PRED",
"value": "---\\\\ntype: \\\\npr: \\\\n---",
- "line": 721
+ "line": 743
},
{
"id": "PRED.k329.observed-clean",
"klass": "PRED",
"value": "#3299 sunny-ibex-wave, #3301 sturdy-rams-caper, #3306 3298-phase-dir-prefix-drift-workflows",
- "line": 723
+ "line": 745
},
{
"id": "PRED.k329.signal",
"klass": "PRED",
"value": "changeset-fragment-canonical-shape",
- "line": 718
+ "line": 740
},
{
"id": "PRED.k330.fallback",
"klass": "PRED",
"value": "append predicate-format findings directly to CONTEXT.md",
- "line": 727
+ "line": 749
},
{
"id": "PRED.k330.shape",
"klass": "PRED",
"value": "mempalace MCP tools require explicit user call; AI cannot trigger",
- "line": 726
+ "line": 748
},
{
"id": "PRED.k330.signal",
"klass": "PRED",
"value": "mempalace-diary-not-callable-by-ai",
- "line": 725
+ "line": 747
},
{
"id": "PRED.k331.cure",
"klass": "PRED",
"value": "gh pr close with NO --comment flag",
- "line": 732
+ "line": 754
},
{
"id": "PRED.k331.evidence",
"klass": "PRED",
"value": "2026-05-09 wave-3: violation on #3300 close, deleted within 30s",
- "line": 734
+ "line": 756
},
{
"id": "PRED.k331.k101-restatement",
"klass": "PRED",
"value": "k101 includes close-time --comment flag; rationale belongs in subsuming PR's squash-merge body",
- "line": 731
+ "line": 753
},
{
"id": "PRED.k331.recovery",
"klass": "PRED",
"value": "if violation lands, gh api -X DELETE repos///issues/comments/",
- "line": 733
+ "line": 755
},
{
"id": "PRED.k331.shape",
"klass": "PRED",
"value": "instruction \"close with no comment (rationale)\" — parenthetical is rationale, NOT comment body",
- "line": 730
+ "line": 752
},
{
"id": "PRED.k331.signal",
"klass": "PRED",
"value": "close-with-no-comment-is-literal",
- "line": 729
+ "line": 751
},
{
"id": "PROBE.ci.surface",
"klass": "PROBE",
"value": "the contract (parse/validate, projection round-trip, fail-closed guards), NEVER the LLM judgment (ADR-550 D5)",
- "line": 478
+ "line": 500
},
{
"id": "PROBE.core.seam",
"klass": "PROBE",
"value": "analyzeCoverage(items,resolutions?,validators) ingests ALREADY-proposed items; does NOT assume deterministic propose (ADR-550 D7b)",
- "line": 471
+ "line": 493
},
{
"id": "PROBE.edge.verification",
"klass": "PROBE",
"value": "explicit|backstop",
- "line": 473
+ "line": 495
},
{
"id": "PROBE.family",
"klass": "PROBE",
"value": "edge-probe(shape-axis)+prohibition-probe(must-NOT-axis)+ui-consideration-probe(UI-state-axis), shared probe-core, run as spec-phase/ui-phase soft gates (ADR-550 D7; #1867)",
- "line": 469
+ "line": 491
},
{
"id": "PROBE.item.axes",
"klass": "PROBE",
"value": "status{resolved|dismissed|unresolved} x verification{|null} — orthogonal; the lifecycle enum carries no verification fact (ADR-550 D7a)",
- "line": 472
+ "line": 494
},
{
"id": "PROBE.principle",
"klass": "PROBE",
"value": "verifier-reach-equals-spec-reach (a goal-backward verifier only checks assertions that exist; probes make omitted assertions exist before code) — ADR-857 verification-substrate boundary; docs/design/verifier-reach.md",
- "line": 468
+ "line": 490
},
{
"id": "PROBE.prohib.verification",
"klass": "PROBE",
"value": "test|judgment",
- "line": 474
+ "line": 496
},
{
"id": "PROBE.protocol",
"klass": "PROBE",
"value": "recall(adversarial over-generate)->precision(drop routine-engineering); dismissals require a non-empty reason",
- "line": 470
+ "line": 492
},
{
"id": "PROBE.ui.axis",
"klass": "PROBE",
"value": "MIXED — closed compiled shape-rooted 8 (empty/loading/error/populated/partial/overflow/zero-one-many/long-text) via ui-consideration-probe adapter; open UX (real-time/a11y/i18n-RTL) prose-owned in references/domain-probes.md, NOT compiled (#1867)",
- "line": 476
+ "line": 498
},
{
"id": "PROBE.ui.seam",
"klass": "PROBE",
"value": "ui-phase Step 9.5 post-verification: element-cue classify -> propose-then-confirm (partial-cue mitigation, Goodhart) -> autoResolve --auto floor (never dismiss; unclassified stays unresolved #1110) -> ## UI Considerations write-back -> plan-phase `## UI Considerations` lift rule (#1867)",
- "line": 477
+ "line": 499
},
{
"id": "PROBE.ui.verification",
"klass": "PROBE",
"value": "explicit|backstop",
- "line": 475
+ "line": 497
},
{
"id": "PROC.AGENT-DISPATCH.completion-verify",
"klass": "PROC",
"value": "run k324.poll-shape on every agent-completion notification",
- "line": 738
+ "line": 760
},
{
"id": "PROC.AGENT-DISPATCH.parallel-overlap-audit",
"klass": "PROC",
"value": "before dispatching N sibling-audit fixers, compute file-set union and assign canonical owners",
- "line": 737
+ "line": 759
},
{
"id": "PROC.AGENT-DISPATCH.preflight",
"klass": "PROC",
"value": "[read-CONTRIBUTING.md-fresh, read-relevant-ADRs, cite-specific-line-in-brief, require-closing-keyword, require-changeset-fragment, forbid-CHANGELOG.md-edit, require-isolation-worktree, forbid-self-PR-comment, mandate-trust-but-verify]",
- "line": 736
+ "line": 758
},
{
"id": "PROC.MERGE-WAVE.changelog-strip-pattern",
"klass": "PROC",
"value": "detached-HEAD per k325 + git checkout main -- CHANGELOG.md + commit + force-with-lease",
- "line": 742
+ "line": 764
},
{
"id": "PROC.MERGE-WAVE.merge-tool",
"klass": "PROC",
"value": "gh pr merge --squash --delete-branch",
- "line": 743
+ "line": 765
},
{
"id": "PROC.MERGE-WAVE.merge-tool-warning",
"klass": "PROC",
"value": "delete-branch may fail with \"used by worktree at\" — harmless; remote branch still deleted",
- "line": 744
+ "line": 766
},
{
"id": "PROC.MERGE-WAVE.ordering",
"klass": "PROC",
"value": "[wave1: isolated-files, wave2: CHANGELOG-only-overlap (better: strip per k320), wave3: same-file-overlap with explicit decision]",
- "line": 740
+ "line": 762
},
{
"id": "PROC.MERGE-WAVE.preflight",
"klass": "PROC",
"value": "gh pr view --json files for every PR; identify overlap pairs; surface to maintainer",
- "line": 741
+ "line": 763
},
{
"id": "PROC.PARALLEL-FIX-DISPATCH.observed",
"klass": "PROC",
"value": "#3541 + #3542 dispatched simultaneously this session; PRs #3546 #3547 opened green; one syntax slip caught by AGENT-RETIRED-SLASH-SYNTAX-DRIFT and fixed before second PR opened",
- "line": 1017
+ "line": 840
},
{
"id": "PROC.PARALLEL-FIX-DISPATCH.pattern",
"klass": "PROC",
"value": "bot triage brief → worktree per branch → parallel sub-agents do rubber-duck/RCA/TDD implementation only → top-level orchestrator owns commit + gsd-test + push + PR + changeset-pr-backfill",
- "line": 1015
+ "line": 838
},
{
"id": "PROC.PARALLEL-FIX-DISPATCH.rationale",
"klass": "PROC",
"value": "long-running test runs need cross-turn notifications (orchestrator-only); CONTRIBUTING.md gh-templates-first hook requires session-scoped Read calls sub-agents wouldn't otherwise make; sequencing test runs avoids GSD-TEST-CONCURRENT-OUTPUT-COLLISION",
- "line": 1016
+ "line": 839
},
{
"id": "PROC.TRIAGE.comment-shape",
"klass": "PROC",
"value": "lead with \"duplicate of #NNNN, fixed by PR #MMMM, in v1.X.Y\"; show current code snippet proving bug-surface gone; give @latest and @next upgrade commands; close",
- "line": 1022
+ "line": 843
},
{
"id": "PROC.TRIAGE.no-duplicate-label",
"klass": "PROC",
"value": "this repo has no duplicate label; framing lives in comment text + closing the issue",
- "line": 1023
+ "line": 844
},
{
"id": "PROC.TRIAGE.routing-incoming",
"klass": "PROC",
"value": "stale-bug-already-fixed to close as duplicate of originating issue + cite fix PR + first stable tag; release-publish-or-backport to ready-for-human; reporter-can-self-test to awaiting-retest",
- "line": 1021
+ "line": 842
},
{
"id": "PROHIB.canon-referral",
"klass": "PROHIB",
"value": "OWASP/GDPR/fairness-canon are REFERRED to /gsd:secure-phase+eslint, never minted as prohibitions (ADR-550 D6)",
- "line": 480
+ "line": 502
},
{
"id": "PROHIB.descriptor.shape",
"klass": "PROHIB",
"value": "5 FLAT scalars (check_kind,check_target,check_rule,check_violation_fixture,check_clean_fixture) — NEVER a nested check:{} (parseMustHavesBlock is a flat parser, src/frontmatter.cts)",
- "line": 485
+ "line": 507
},
{
"id": "PROHIB.enforce.adr",
"klass": "PROHIB",
"value": "docs/adr/1606 (verify-time enforcement seam) + docs/adr/550 (spec-phase contract)",
- "line": 488
+ "line": 510
},
{
"id": "PROHIB.enforce.causation",
"klass": "PROHIB",
"value": "clean-fixture control proves the red is content-caused not env-var-set; MANDATORY for node-test (#1906 supersedes #1346 opt-in) — absent clean-fixture ⇒ node-test un-provable/fail-closed; lint-rule needs none (its subject IS the linted file)",
- "line": 484
+ "line": 506
},
{
"id": "PROHIB.enforce.failfirst",
"klass": "PROHIB",
"value": "MACHINE-PROVEN against an author-supplied violation fixture (#1279); caller failFirst attestation DEMOTED to a non-authoritative hint (FF-08)",
- "line": 483
+ "line": 505
},
{
"id": "PROHIB.enforce.green-rule",
"klass": "PROHIB",
"value": "passed iff provenFailFirst===true && run.passed===true (runProhibitionEnforcement); every miss/fail/un-provable HARD-GATES both modes via dispositionForProhibition's fail-closed default",
- "line": 481
+ "line": 503
},
{
"id": "PROHIB.enforce.kinds",
"klass": "PROHIB",
"value": "node-test (non-vacuous red via isNonVacuousNodeTestRed; pass-side vacuity via isNonVacuousNodeTestPass) | lint-rule (eslint --format json filtered by ruleId)",
- "line": 482
+ "line": 504
},
{
"id": "PROHIB.judgment-tier",
"klass": "PROHIB",
"value": "never-silent / never-hard-halt soft gate; autonomous emits \"unverified-prohibition — human review recommended\" (exogenous grading, ADR-550 D4)",
- "line": 487
+ "line": 509
},
{
"id": "PROHIB.rail",
"klass": "PROHIB",
"value": "core verify rail, non-toggleable (ADR-857 verification-substrate boundary / decision #6); the verifier<->predicate contract is NOT an off-by-default capability",
- "line": 486
+ "line": 508
},
{
"id": "PROHIB.recall",
"klass": "PROHIB",
"value": "LLM-prose; no compiled prohibition-probe recall engine (only the schema/projection layer is code, ADR-550 D7b)",
- "line": 479
+ "line": 501
},
{
"id": "RELEASE-NOTES.ANTI-PATTERN",
"klass": "RELEASE-NOTES",
"value": "raw \"What's Changed\" PR list as final body for hotfix or feature release; \"Full Changelog only\" body for tagged release with >0 user-facing fixes",
- "line": 633
+ "line": 655
},
{
"id": "RELEASE-NOTES.ANTI-PATTERN.implementation-first",
"klass": "RELEASE-NOTES",
"value": "do not lead bullet with file path or function name; lead with symptom/user-visible behavior",
- "line": 634
+ "line": 656
},
{
"id": "RELEASE-NOTES.ANTI-PATTERN.risk-commentary",
"klass": "RELEASE-NOTES",
"value": "do not include \"may break\", \"be careful\", \"test thoroughly\" - release notes state what changed, not hedges about what might go wrong",
- "line": 635
+ "line": 657
},
{
"id": "RELEASE-NOTES.DEFAULT-STATE",
"klass": "RELEASE-NOTES",
"value": "auto-generated body is \"What's Changed\" PR list + Full Changelog link; treat as draft, not final",
- "line": 609
+ "line": 631
},
{
"id": "RELEASE-NOTES.EXAMPLE.hotfix",
"klass": "RELEASE-NOTES",
"value": "v1.41.1 (https://github.com/open-gsd/gsd-core/releases/tag/v1.41.1) - 14 fixes grouped by 6 subgroups",
- "line": 637
+ "line": 659
},
{
"id": "RELEASE-NOTES.EXAMPLE.minor-auto-acceptable",
"klass": "RELEASE-NOTES",
"value": "v1.41.0 - kept auto-generated body; many small fixes with clean conventional-commit titles",
- "line": 639
+ "line": 661
},
{
"id": "RELEASE-NOTES.EXAMPLE.rc",
"klass": "RELEASE-NOTES",
"value": "v1.7.0-rc.1 (https://github.com/open-gsd/gsd-core/releases/tag/v1.7.0-rc.1) - intro + Added/Changed/Fixed/Documentation taxonomy",
- "line": 638
+ "line": 660
},
{
"id": "RELEASE-NOTES.GATE.hotfix",
"klass": "RELEASE-NOTES",
"value": "manual edit required; auto-generated body for vX.Y.{Z>0} is \"Full Changelog only\" and must be replaced with structured body",
- "line": 610
+ "line": 632
},
{
"id": "RELEASE-NOTES.GATE.minor",
"klass": "RELEASE-NOTES",
"value": "auto-generated body acceptable when PR titles are clean; promote to structured body when >20 PRs or contains feature+refactor+fix mix",
- "line": 612
+ "line": 634
},
{
"id": "RELEASE-NOTES.GATE.rc",
"klass": "RELEASE-NOTES",
"value": "manual edit recommended; auto-generated PR list is acceptable for early RCs but final RC before vX.Y.0 should match standard",
- "line": 611
+ "line": 633
},
{
"id": "RELEASE-NOTES.RELEASE-STREAM.main-branch",
"klass": "RELEASE-NOTES",
"value": "next (RCs) + latest (stable); install via @next or @latest",
- "line": 644
+ "line": 666
},
{
"id": "RELEASE-NOTES.RELEASE-STREAM.rule",
"klass": "RELEASE-NOTES",
"value": "streams do not mix; do not document @next in hotfix/stable notes",
- "line": 645
+ "line": 667
},
{
"id": "RELEASE-NOTES.SCOPE",
"klass": "RELEASE-NOTES",
"value": "GitHub Releases body for tags vX.Y.Z, vX.Y.Z-rc.N; not CHANGELOG.md (changeset workflow owns that)",
- "line": 608
+ "line": 630
},
{
"id": "RELEASE-NOTES.SOURCE.changesets",
"klass": "RELEASE-NOTES",
"value": ".changeset/*.md (frontmatter pr: + body bullets)",
- "line": 624
+ "line": 646
},
{
"id": "RELEASE-NOTES.SOURCE.commits",
"klass": "RELEASE-NOTES",
"value": "git log .. --pretty=format:'%s%n%n%b' --no-merges",
- "line": 623
+ "line": 645
},
{
"id": "RELEASE-NOTES.SOURCE.pr-bodies",
"klass": "RELEASE-NOTES",
"value": "gh pr view --json title,body for fixes lacking a changeset",
- "line": 625
+ "line": 647
},
{
"id": "RELEASE-NOTES.SOURCE.precedence",
"klass": "RELEASE-NOTES",
"value": "changeset body > commit body > PR body > commit subject (prefer authored content over auto-generated)",
- "line": 626
+ "line": 648
},
{
"id": "RELEASE-NOTES.STANDARD.bullet-shape",
"klass": "RELEASE-NOTES",
"value": "**Bold user-visible change** — explanation of what was broken or what's new, leading with symptom not implementation. Trailing (#NNN) PR ref.",
- "line": 616
+ "line": 638
},
{
"id": "RELEASE-NOTES.STANDARD.footer.full-changelog",
"klass": "RELEASE-NOTES",
"value": "**Full Changelog**: https://github.com/open-gsd/gsd-core/compare/...",
- "line": 620
+ "line": 642
},
{
"id": "RELEASE-NOTES.STANDARD.footer.hotfix",
"klass": "RELEASE-NOTES",
"value": "Install/upgrade: \\`npx @opengsd/gsd-core@latest\\`",
- "line": 618
+ "line": 640
},
{
"id": "RELEASE-NOTES.STANDARD.footer.rc",
"klass": "RELEASE-NOTES",
"value": "Install for testing: \\`npx @opengsd/gsd-core@next\\` (per branch->dist-tag policy)",
- "line": 619
+ "line": 641
},
{
"id": "RELEASE-NOTES.STANDARD.heading-level",
"klass": "RELEASE-NOTES",
"value": "## for category, ### for subgroup (area), - for bullet",
- "line": 615
+ "line": 637
},
{
"id": "RELEASE-NOTES.STANDARD.intro",
"klass": "RELEASE-NOTES",
"value": "optional one-paragraph framing for RC/feature releases; omit for pure-fix hotfixes",
- "line": 621
+ "line": 643
},
{
"id": "RELEASE-NOTES.STANDARD.subgroups",
"klass": "RELEASE-NOTES",
"value": "phase-planning-state | workstream | query-dispatch-cli | code-review | install | capture | docs | architecture | security",
- "line": 617
+ "line": 639
},
{
"id": "RELEASE-NOTES.STANDARD.taxonomy",
"klass": "RELEASE-NOTES",
"value": "Keep-a-Changelog 1.1.0: Added | Changed | Deprecated | Removed | Fixed | Security | Documentation",
- "line": 614
+ "line": 636
},
{
"id": "RELEASE-NOTES.TEMPLATE.hotfix",
"klass": "RELEASE-NOTES",
"value": "## Fixed\\n\\n### \\n- **** — . (#)\\n\\n---\\n\\nInstall/upgrade: \\`npx @opengsd/gsd-core@latest\\`\\n\\n**Full Changelog**: ",
- "line": 641
+ "line": 663
},
{
"id": "RELEASE-NOTES.TEMPLATE.rc",
"klass": "RELEASE-NOTES",
"value": "\\n\\n## Added\\n### \\n- **** — . (#)\\n\\n## Changed\\n### Architecture\\n- **** — . (#)\\n\\n## Fixed\\n### \\n- **** — . (#