From bcf7b04864f1a7b73d2ae557868ad731a8240f61 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 10 Aug 2026 12:55:52 -0400 Subject: [PATCH] chore(#2896): convert CONTEXT.md prose defect registry into enforced gates (#3325) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(#2896): convert CONTEXT.md prose defect registry into enforced gates Squashes the prior 4-commit sequence and fixes defects found while resuming this branch: 5 orphaned/corrupted DEFECT fragment lines left by an earlier botched edit, 17 "Source of truth: Memtrace `find_symbol`" placeholders that had destroyed real file-path citations, and 3 DEFECT.GENERATIVE-* entries merged into one RULESET.GENERATIVE-FIX predicate (policy, not an unenforced defect) to satisfy the zero DEFECT..= acceptance criterion. Six mechanizable defects get real gates: DEFECT.UNBOUNDED-SUBPROCESS (eslint-rules/require-subprocess-timeout.cjs), DEFECT.CANARY-VERSION-LEAK (scripts/lint-canary-version-leak.cjs + version-gate.yml), DEFECT.CHANGESET-PR-FIELD-DRIFT (findPrFieldDrift in changeset/lint.cjs), DEFECT.FRONTMATTER-SCALAR-BROAD-GREP, DEFECT.REMOVED-BUT-NEEDED, and DEFECT.DEFAULT-FLIP-DOCUMENTATION (new lint scripts, wired into lint:ci). Already-enforced and unenforceable prose entries are deleted; the gate is the record. Co-Authored-By: Claude Sonnet 5 * chore(#2896): route the new lint tests' subprocess calls through the bounded process-seam helper The 4 new test files for this PR's lint checks called cp.spawnSync/ execFileSync directly with no timeout, tripping this repo's own existing local/no-unbounded-spawn ESLint rule. Route every one through runNode/gitOrThrow (tests/helpers/process-seam.cjs, tests/helpers/git-fixture.cjs) instead, matching the pattern already used elsewhere in the suite (e.g. tests/changeset-lint.test.cjs). Co-Authored-By: Claude Sonnet 5 * fix: register claude-orchestration.cjs and regenerate stale generated indexes Pre-existing drift on next, unrelated to this PR's own change, surfaced by running lint:ci as part of verifying #2896: two cli_modules (claude-orchestration.cjs, write-set.cjs) landed without a manifest regen, and CONTEXT.md's own edits in this PR staled its two generated indexes. Adds the missing docs/INVENTORY.md row for claude-orchestration.cjs (write-set.cjs already had one — only its manifest entry was stale) and regenerates docs/INVENTORY-MANIFEST.json, docs/CONTEXT-INDEX.json, and examples/dynamic-context-management/CONTEXT-INDEX.json. Co-Authored-By: Claude Sonnet 5 * fix(#2896): default-flip-documentation lint's local fallback base was main, not next Found in review: every other base-ref fallback in this repo (see scripts/changeset/lint.cjs's DEFAULT_BASE, #2988) defaults to `next`, the integration branch every PR actually targets — `main` is the release branch. This script's local fallback (used only when GITHUB_BASE_REF is unset, i.e. never in CI, but potentially on a local or direct invocation) diffed against the wrong ref. No test exercised the unset-env-var path, so it shipped unnoticed; every e2e test sets GITHUB_BASE_REF explicitly and is unaffected by this fix. Co-Authored-By: Claude Sonnet 5 * fix(#2896): stale eslint comment, overclaiming CONTEXT.md wording, and an incompletely-regenerated manifest Found by the isolated Standards code-review pass: - eslint.config.mjs's require-subprocess-timeout comment said "'warn' for now... flip to 'error' once migrated" while the rule already shipped as 'error' with all 8 sites migrated in the same commit — described a state that never existed. - The CONTEXT.md pointer block claimed the rule's bounded call sites "never throw", but roadmap-upgrade.cts's pre-mutation clean-tree check correctly still throws on failure (it gates a destructive real-run migration; degrading to "assume clean" would risk clobbering uncommitted work) — softened the claim to describe both shapes accurately instead of overclaiming one. - docs/INVENTORY-MANIFEST.json's claude-orchestration.cjs/write-set.cjs entries from the prior "fix: register claude-orchestration.cjs..." commit didn't actually land — re-running the generator now includes them; lint:generated-sync is green. Co-Authored-By: Claude Sonnet 5 * chore(#2896): backfill changeset pr field with the real PR number Co-Authored-By: Claude Sonnet 5 * fix(#2896): normalize buildCorpus file paths to POSIX in lint-removed-but-needed Windows CI caught it: path.relative(root, abs) returns backslash- separated paths on Windows, but findSurvivingReferences's package-lock special case does file.startsWith('.github/workflows') — a forward- slash literal. On Windows the check silently never matched, so tests/removed-but-needed-lint.test.cjs's real-defect-shape fixture got exit 0 instead of the expected exit 1. Normalize at the production source (RULESET.CONTENT-PATH-NORMALIZATION) rather than the test side. Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: sim Co-authored-by: Claude Sonnet 5 --- .changeset/patient-dogs-sprint.md | 5 + .../workflows/default-flip-documentation.yml | 42 + .github/workflows/version-gate.yml | 21 +- CONTEXT.md | 237 +-- docs/CONTEXT-INDEX.json | 850 +-------- docs/INVENTORY.md | 1 + eslint-rules/require-subprocess-timeout.cjs | 225 +++ eslint.config.mjs | 7 + .../CONTEXT-INDEX.json | 1539 +++-------------- package.json | 4 +- scripts/changeset/lint.cjs | 63 +- scripts/lint-canary-version-leak.cjs | 73 + scripts/lint-default-flip-documentation.cjs | 193 +++ .../lint-frontmatter-scalar-broad-grep.cjs | 237 +++ scripts/lint-removed-but-needed.cjs | 208 +++ src/check-command-router.cts | 8 +- src/roadmap-upgrade.cts | 2 +- src/shell-command-projection.cts | 1 + src/smart-entry.cts | 1 + tests/canary-version-leak-lint.test.cjs | 109 ++ tests/changeset-lint.test.cjs | 156 +- .../default-flip-documentation-lint.test.cjs | 197 +++ tests/eslint-rules.test.cjs | 146 +- ...int-frontmatter-scalar-broad-grep.test.cjs | 181 ++ tests/removed-but-needed-lint.test.cjs | 229 +++ 25 files changed, 2391 insertions(+), 2344 deletions(-) create mode 100644 .changeset/patient-dogs-sprint.md create mode 100644 .github/workflows/default-flip-documentation.yml create mode 100644 eslint-rules/require-subprocess-timeout.cjs create mode 100644 scripts/lint-canary-version-leak.cjs create mode 100644 scripts/lint-default-flip-documentation.cjs create mode 100644 scripts/lint-frontmatter-scalar-broad-grep.cjs create mode 100644 scripts/lint-removed-but-needed.cjs create mode 100644 tests/canary-version-leak-lint.test.cjs create mode 100644 tests/default-flip-documentation-lint.test.cjs create mode 100644 tests/lint-frontmatter-scalar-broad-grep.test.cjs create mode 100644 tests/removed-but-needed-lint.test.cjs 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- **** — . (#)\\n\\n## Documentation\\n- **** — . (#)\\n\\n---\\n\\nThis is a release candidate. Install for testing:\\n\\`\\`\\`bash\\nnpx @opengsd/gsd-core@next\\n\\`\\`\\`\\n\\n**Full Changelog**: ", - "line": 642 + "line": 664 }, { "id": "RELEASE-NOTES.WORKFLOW.edit", "klass": "RELEASE-NOTES", "value": "gh release edit --notes-file ", - "line": 628 + "line": 650 }, { "id": "RELEASE-NOTES.WORKFLOW.idempotency", "klass": "RELEASE-NOTES", "value": "gh release edit overwrites body wholesale; safe to re-run after refining", - "line": 631 + "line": 653 }, { "id": "RELEASE-NOTES.WORKFLOW.token", "klass": "RELEASE-NOTES", "value": "must use .envrc GITHUB_TOKEN per RULESET.GH.AUTH.DEFAULT (this doc); never ambient gh auth", - "line": 630 + "line": 652 }, { "id": "RELEASE-NOTES.WORKFLOW.view", "klass": "RELEASE-NOTES", "value": "gh release view --json body --jq .body", - "line": 629 + "line": 651 }, { "id": "RULESET.ADR-HEADER", "klass": "RULESET", "value": "every docs/adr/NNNN-*.md must open with - **Status:** Accepted|Proposed|Superseded (by [ADR-NNNN](file.md))|Legacy + - **Date:** YYYY-MM-DD immediately after title", - "line": 533 + "line": 555 }, { "id": "RULESET.AGENT_SIZE_BUDGET", "klass": "RULESET", "value": "agent-size-budget (#1074; sibling of WORKFLOW_SIZE_BUDGET; BYTES not lines per #717/#683, rebased from lines in PR 3/3) = differential attribution size ratchet (PRIMARY anti-creep since #2724/ADR-2719 §4, same mechanism and same ack fragments (tests/emitted-drift-acks/, #2914; legacy tests/emitted-drift-ack.json still honored) as WORKFLOW_SIZE_BUDGET, scoped to agents/gsd-*.md) + loose tier hard caps (red lines, never raised on approach: XL<=57344 / LARGE<=49152 / DEFAULT<=24576); net-new agents are DEFAULT-tier (no separate new-file cap). Sizes are measured via the shared scripts/workflow-size.cjs measureMdFiles(dir,predicate) counter (tests/helpers/emitted-runtime.cjs's currentSizes() and the guard's own tier-cap checks both import it). A grown agent fails the differential guard — ack + justify, or extract LAZILY to gsd-core/references/. DISTINCT from DEFECT.AGENT-FILE-SIZE-CAP-BREACH (a separate 45K-CHAR extraction-evidence threshold on gsd-planner via planner-decomposition/reachability tests): that guard proves mode-sections were extracted; this one bounds total agent bytes. Two guards, two units (chars vs bytes), two purposes. The prior per-file baseline (tests/agent-size-baseline.json, `npm run size:baseline`) is REMOVED by #2724", - "line": 522 + "line": 544 }, { "id": "RULESET.ALLOWED-TOOLS-FRONTMATTER", "klass": "RULESET", "value": "command's allowed-tools must cover every tool the workflow calls (including Write for file creation); thin-wrapper pattern makes this easy to miss", - "line": 529 + "line": 551 }, { "id": "RULESET.ARGUMENTS-SANITIZE", "klass": "RULESET", "value": "any workflow step constructing .planning/.../{SLUG}.md path from user input ($ARGUMENTS, parsed remainder) must sanitize inline ([a-z0-9-] only, reject ..//\\\\, max-length) — \"(already sanitized)\" must trace back to explicit guard; RESUME/fallback modes need own guards", - "line": 530 + "line": 552 }, { "id": "RULESET.AUDIT.search-source-not-generated", "klass": "RULESET", "value": "verify an invariant/validation EXISTS by searching the AUTHORED source (src/*.cts OR the scripts/gen-*.cjs generator), never the generated bin/lib/*.cjs (gitignored, ADR-457); gen-time checks live in gen-*.cjs not the .cts it consumes → search BOTH before declaring absent; read generated .cjs only for output drift. Repro: grep src/*.cts for VALID_CONVERTER_NAMES → false \"5e ConverterName unenforced\"; actually enforced in gen-capability-registry.cjs. cf RULESET.TESTS.no-source-grep", - "line": 518 + "line": 540 }, { "id": "RULESET.CAPABILITY.cutover-self-gating", "klass": "RULESET", "value": "a phase-6 per-feature cutover moves the host's phase-context detection + mode/flag logic INTO the skill (self-gating, per ADR-894); the loop hook is intentionally COARSE — \"invoke skill X at point Y when config Z\" — and carries no detection/mode. WORKED EXAMPLE: plan-phase.md §5.6 UI gate (frontend-detection via ui-safety-gate.cjs + --auto/manual branch + --skip-ui bypass) must move into gsd-ui-phase before its plan:pre hook can replace the inline call without behavior loss. Spike #1018 finding.", - "line": 307 + "line": 324 }, { "id": "RULESET.CAPABILITY.off-means-off", "klass": "RULESET", "value": "the host derives shared outputs from the ACTIVE hook set (via loop.render-hooks); a hook may ADD a labeled block or be COUNTED into a host-computed aggregate (e.g. a score denominator), but NEVER mutates host source — so a disabled capability yields the base output by construction, not by authoring discipline. Ratify in ADR-894; proven by spike #1018.", - "line": 305 + "line": 322 }, { "id": "RULESET.CAPABILITY.precedence-engine-single-owner", "klass": "RULESET", "value": "the config-key four-level precedence walk (loadConfig result → workstream config.json → root config.json → registry.configSchema default → absent) is owned solely by src/capability-activation.cts: raw-value primitive resolveConfigKey(dotKey, {config,cwd,registry}) and boolean wrapper _resolveActivationValue(dotKey,config,cwd,registry); loop-resolver.cts imports the engine (no duplicate); resolveConfigValues in loop-resolver.cts delegates to resolveConfigKey; resolveCapabilityRuntimeState does NOT return registry/config — callers import capability-registry.cjs and call loadConfig(cwd) directly.", - "line": 311 + "line": 328 }, { "id": "RULESET.CAPABILITY.step-additive-gate-blocks", "klass": "RULESET", "value": "a `step` hook is purely additive (invoke skill + produce artifacts, NEVER halts the host); host-blocking preconditions are `gate`s (blocking:true, onError:halt); runtime/mode context (auto/chain vs manual) self-gates IN THE SKILL, not via `when` (config-only). §5.6 = plan:pre step (ui-phase; skill self-gates on frontend+pipeline, auto-fires only in pipelines) + a NEW plan:pre gate (frontend-and-no-UI-SPEC → halt, when:workflow.ui_safety_gate); the loop.render-hooks dispatch template handles steps AND gates. Resolves #1022.", - "line": 309 + "line": 326 }, { "id": "RULESET.CODERABBIT.GUARD.COMPLETE", "klass": "RULESET", "value": "required_checks_green && coderabbit_check_pass && graphQL(reviewThreads.unresolved_count)==0", - "line": 555 + "line": 577 }, { "id": "RULESET.CODERABBIT.GUARD.GRAPHQL", "klass": "RULESET", "value": "reviewThreads(first:100){nodes{id isResolved comments{nodes{author body path line originalLine url}}}}; use unresolved threads as authoritative, not badge text alone", - "line": 556 + "line": 578 }, { "id": "RULESET.CODERABBIT.GUARD.OPEN_PRS", "klass": "RULESET", "value": "gh pr list --repo open-gsd/gsd-core --author @me --state open; repeat near end because open PR set can change mid-run", - "line": 554 + "line": 576 }, { "id": "RULESET.CODERABBIT.GUARD.RERUN", "klass": "RULESET", "value": "after every push wait for CodeRabbit completion, then re-query unresolved threads; CodeRabbit can add new findings after earlier threads were resolved", - "line": 557 + "line": 579 }, { "id": "RULESET.CODERABBIT.GUARD.RESOLVE", "klass": "RULESET", "value": "fix validated finding -> focused tests -> commit/push -> resolveReviewThread(threadId) -> wait CI/CodeRabbit -> final unresolved_count query", - "line": 558 + "line": 580 }, { "id": "RULESET.CODERABBIT.GUARD.SCOPE", "klass": "RULESET", "value": "if a new @me open PR appears during final list, include it in the same guard pass before declaring all-open-PRs complete", - "line": 559 + "line": 581 }, { "id": "RULESET.CONTENT-PATH-NORMALIZATION", "klass": "RULESET", "value": "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)", - "line": 882 + "line": 782 }, { "id": "RULESET.CONTRIB.CLASSIFY.enhancement", "klass": "RULESET", "value": "requires approved-enhancement before implementation", - "line": 548 + "line": 570 }, { "id": "RULESET.CONTRIB.CLASSIFY.feature", "klass": "RULESET", "value": "requires approved-feature before implementation", - "line": 549 + "line": 571 }, { "id": "RULESET.CONTRIB.CLASSIFY.fix", "klass": "RULESET", "value": "requires confirmed-bug before implementation (legacy 'confirmed' label is back-compat only for duplicate-sweep exemption, not a valid implementation gate)", - "line": 547 + "line": 569 }, { "id": "RULESET.CONTRIB.GATE.ORDER", "klass": "RULESET", "value": "issue-first -> approval-label -> code -> PR-link -> changeset/no-changelog", - "line": 546 + "line": 568 }, { "id": "RULESET.CR-THREAD-RESOLVE", "klass": "RULESET", "value": "after adding // allow-test-rule: to silence lint, resolve existing inline CR threads via graphql resolveReviewThread mutation before merge — open threads mislead future reviewers; pattern: gh api graphql -f query='mutation { resolveReviewThread(input:{threadId:\"PRRT_...\"}) { thread { isResolved } } }'", - "line": 540 + "line": 562 }, { "id": "RULESET.EMITTED_ATTRIBUTION", "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`", - "line": 523 + "line": 545 + }, + { + "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)", + "line": 780 }, { "id": "RULESET.GH.AUTH.DEFAULT", "klass": "RULESET", "value": "source .envrc GITHUB_TOKEN before gh; exception=ambient allowed only when user explicitly says machine-only fallback", - "line": 553 + "line": 575 }, { "id": "RULESET.HARNESS.test-memory-guard", "klass": "RULESET", "value": "~/.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", - "line": 975 + "line": 819 }, { "id": "RULESET.MANIFEST-CANONICAL-KEY", "klass": "RULESET", "value": "docs/INVENTORY-MANIFEST.json has a single top-level key: families; ALL EIGHT families.* arrays (agents/commands/workflows/references/cli_modules/hooks flat, plus workflow_modes/workflow_steps nested — #2996, epic #1671 Phase 6.5) are canonical, consumed by test suites — tests/inventory-manifest-sync.test.cjs reads all eight, edit-phase/enh-2380/enh-2430 tests read commands+workflows; the six flat families are keyed by BARE BASENAME while the two nested families are keyed by // path, deliberately, because two workflows may each own a same-named step file and a basename key would silently drop one under a JSON-equality comparison; recursion is bounded at exactly one named subdirectory, never a general walk; the family tables live ONCE in scripts/gen-inventory-manifest.cjs and are IMPORTED by the test (the test formerly redeclared them, a DEFECT.GENERATIVE-FIX divergence that let a new family be verified by nobody while still reporting green); the old generated date field and the stale top-level workflows key are both gone; regen via node scripts/gen-inventory-manifest.cjs --write, AFTER build:lib", - "line": 534 + "line": 556 }, { "id": "RULESET.PR-FLOW.docker-before-push", "klass": "RULESET", "value": "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.", - "line": 977 + "line": 821 }, { "id": "RULESET.PR-FLOW.templates-mandatory", "klass": "RULESET", "value": "every gh pr create|edit|gh issue create|edit MUST first invoke the gh-templates-first skill and Read (Read tool, not Bash cat — k321 read-tracking) the matching template in .github/. Apply ALL required sections; never write freeform bodies. Repo enforces this via gsd-pr-template-policy GitHub Action which flags any non-templated body — the bot allows the PR to stay open only because authors are contributors-or-higher, but the warning is a real complaint that must be cured. Source: user feedback 2026-05-16 (multi-message escalation) — \"the whole reason i have that github action is because you fucking blow through and ignore using the templates\"", - "line": 979 + "line": 823 }, { "id": "RULESET.PR-SCOPE.one-concern-per-pr", "klass": "RULESET", "value": "split unrelated changes into separate PRs; cherry-pick doc changes to dedicated docs/ branch immediately, then force-push original to remove the commit", - "line": 536 + "line": 558 }, { "id": "RULESET.SHARED-HELPERS-LINT-VS-TEST", "klass": "RULESET", "value": "when a lint script and test suite both implement same constant (CANONICAL_TOOLS) or parser (parseFrontmatter, executionContextRefs), extract to scripts/*-helpers.cjs required by both — silent divergence otherwise", - "line": 531 + "line": 553 }, { "id": "RULESET.TESTS.CODERABBIT_FIX", "klass": "RULESET", "value": "prefer exported-function behavioral tests over source-grep; lint-no-source-grep rejects readFileSync source assertions without allow-test-rule", - "line": 560 + "line": 582 }, { "id": "RULESET.TESTS.boundary-coverage", "klass": "RULESET", "value": "tests MUST exercise inputs at and near the threshold/limit, not only trivial-fit and trivial-overflow; pick inputs where N ∈ {limit-1, limit, limit+1} and where pre-trim/pre-check accumulators ≈ effective limit; \"very small\" and \"very large\" inputs alone do not constitute edge-case coverage and routinely miss off-by-one + reservation-accounting bugs", - "line": 505 + "line": 527 }, { "id": "RULESET.TESTS.boundary-coverage.anti-pattern", "klass": "RULESET", "value": "test suites that pair budget:1_000_000 (trivially fits) with budget:1 (trivially overflows) and skip the boundary region; failure mode that shipped PR #3708 UNNEEDED_TRIM + FALSE_HARDFAIL regressions (commit 2df566ed, fixed bde1ae8f)", - "line": 508 + "line": 530 }, { "id": "RULESET.TESTS.boundary-coverage.fixtures", "klass": "RULESET", "value": "for any code with budget/limit/quota/threshold parameter, test suite MUST include: (a) input where SUT estimate == limit exactly, (b) input where estimate == limit - 1, (c) input where estimate == limit + 1, (d) input where any internal reserve/safety constant pushes baseline within reserve-distance of limit (catches early-pressure firing)", - "line": 507 + "line": 529 }, { "id": "RULESET.TESTS.clock-seam", "klass": "RULESET", "value": "concurrency logic must accept an optional {clock=Date} parameter; tests control time via t.mock.timers.enable(['Date']) + t.mock.timers.setTime(0) + t.mock.timers.tick(N); real OS scheduler races are not a permitted test pattern after ADR 456 (2026-05-28); real-race tests are deleted once deterministic seam tests cover the same logical path; clock.cjs realClock adds nowIso() (→ new Date(this.now()).toISOString()) and today() (→ nowIso().split('T')[0]) so all date-stamping in state.cjs routes through the seam; subprocess time-pin adapter: set GSD_TEST_MODE=1 + GSD_NOW_MS= in runGsdTools env to pin the date written by the SUT without touching real wall-clock (issue #474)", - "line": 512 + "line": 534 }, { "id": "RULESET.TESTS.coderabbit-fix-prefer", "klass": "RULESET", "value": "behavioral tests (call exported fn, capture JSON, assert typed fields) over source-grep", - "line": 503 + "line": 525 }, { "id": "RULESET.TESTS.delete-bad-tests", "klass": "RULESET", "value": "pass-always / vacuous-truth / source-grep / elapsed-time / real-race / permanent-allow-test-rule tests are DELETED and replaced with compliant tests in the same PR; not skipped, not commented out, not permanently exempted; replacement must cover the same logical path via typed-surface assertion or clock-seam pattern", - "line": 515 + "line": 537 }, { "id": "RULESET.TESTS.diagnostics", "klass": "RULESET", "value": "after JSON.parse, assert output shape (Array.isArray(output.phases)) with raw-output-prefix diagnostics before .map() — prevents opaque TypeErrors when CLI output shape changes", - "line": 504 + "line": 526 }, { "id": "RULESET.TESTS.escape-regex", "klass": "RULESET", "value": "new RegExp(\"prefix${var}\") must escapeRegex(var); phase-id.cjs exports escapeRegex (core.cjs re-export spine retired in epic #1267); phase IDs like 5.1 contain . which is metacharacter", - "line": 500 + "line": 522 }, { "id": "RULESET.TESTS.eslint-harness", "klass": "RULESET", "value": "ADR 452 (2026-05-28): ESLint flat config + typescript-eslint + eslint-plugin-n + eslint-plugin-no-only-tests + local plugin at eslint-rules/ (repo root, NOT scripts/eslint-rules/); replaces scripts/lint-*.cjs regex scanners (fully removed in #632); of the three test-rigor rules, local/no-source-grep and local/no-magic-sleep-in-tests are already promoted to error in tests/**/*.test.cjs scope (post-cleanup), local/no-elapsed-assertion remains at warn pending open epic #1885 (its dedicated ratchet issue #453 already merged without completing this promotion; follow-up #1888 was closed not-planned and folded into #1885)", - "line": 516 + "line": 538 }, { "id": "RULESET.TESTS.feedback-loop-convergence", "klass": "RULESET", "value": "when a feature's OUTPUT feeds back into its own INPUT (calibration, retry backoff, adaptive budgets, ratchets, any self-correcting signal), step-wise tests are NOT sufficient evidence of correctness: they assert `given X return Y` while the defect lives in the TRAJECTORY across iterations. Required: a closed-loop test that (a) drives the REAL end-to-end surface — not the pure core alone, since composition bugs live between surfaces — for N >= 2x the loop's window, (b) asserts convergence on the known-true value, (c) asserts the fixed point (an already-correct history must produce NO correction), and (d) asserts boundedness under an adversarial/oscillating history. Two defects shipped past a green ~26,800-test suite in epic #1952 for want of exactly this: calibration applied twice across two surfaces (factor^2, #2631) and calibration measured against its own corrected output so it oscillated to ~1.41 instead of converging on 2.0 (#2632). Every unit, boundary, property and round-trip test passed for both. HOW TO SPOT ONE (the detection tell, not a judgment call): the feature's own acceptance criterion carries a TEMPORAL QUANTIFIER — \"after N phases\", \"subsequent\", \"over time\", \"improves\", \"learns\", \"adapts\". That phrasing means the claim is about a TRAJECTORY, so a step-wise `given X return Y` test does not test the claim that was made. #1952's AC4 read \"After N phases, the error is computed and applied as a correction to SUBSEQUENT estimates\" — the tell was in plain sight and was still tested as a point. Survey of this repo (2026-07): estimation calibration is the ONLY true instance; size/mutation ratchets are exempt because they fail on both growth AND shrinkage (cannot self-satisfy), and retry ladders (node_repair_budget, plan_bounce_passes, provider_escalation) terminate rather than feed back. Test anchor: tests/estimate-loop-convergence.test.cjs", - "line": 506 + "line": 528 }, { "id": "RULESET.TESTS.guard-toplevel-readFileSync", "klass": "RULESET", "value": "module-level const src = readFileSync(...) throws before any test() registers — wrap in try/catch in test() or use lazy load", - "line": 502 + "line": 524 }, { "id": "RULESET.TESTS.mutation-score", "klass": "RULESET", "value": "Stryker runs incremental (--since origin/next) on ubuntu-latest/Node24 CI leg; default threshold 80% killed/total; surviving mutants in scope block merge unless path is listed in stryker.config.mjs with documented reason; treat surviving mutant as a failing test specification", - "line": 514 + "line": 536 }, { "id": "RULESET.TESTS.no-dead-regex-in-includes", "klass": "RULESET", "value": "src.includes(\"foo.*bar\") is always false — .* is regex metacharacter not wildcard; use new RegExp(...).test(src) or delete", - "line": 501 + "line": 523 }, { "id": "RULESET.TESTS.no-duplicate-fold-marker", "klass": "RULESET", "value": "local/no-duplicate-fold-marker ESLint AST rule (eslint-rules/no-duplicate-fold-marker.cjs, #3271) reports the 2nd and every later __foldDescribe(\"folded: ...\") call carrying a marker already seen in the SAME file, naming the first occurrence's line; error in tests/**/*.cjs. The key is the WHITESPACE-delimited token after folded:, NOT a [a-z0-9-]* slice — a slice truncates at \".\" and collides feat-443-effort-fast-mode.integration with feat-443-effort-fast-mode (two distinct suites coexisting in tests/model-resolver.test.cjs), and NOT the whole title, so a re-fold under a different batch label (\"B1 #1970\" vs \"B5 #1975\") is still caught. Deliberately silent on: a __foldDescribe title with no folded: prefix (the alias is reused for one ordinary describe in tests/review-default-reviewers-workflow.test.cjs), a plain describe(), a non-literal title, and the same marker in two DIFFERENT files (the defect class is intra-file).", - "line": 497 + "line": 519 }, { "id": "RULESET.TESTS.no-duplicate-fold-marker.why", "klass": "RULESET", "value": "consolidation epic #1969 folds are self-contained blocks, so a second verbatim copy parses, registers and PASSES twice — nothing reports it; #3271 found 25 such copies (~5,800 lines) in tests/install.test.cjs (18), tests/install-minimal-hooks.test.cjs (5) and tests/install-write-confinement.test.cjs (2), all from one stale-base re-application in 6d072435d (#1975 re-applying #1970's hunks, 2026-07-03). Ref DEFECT.GENERATIVE-FIX: the two copies drift apart silently when a contributor fixes one and leaves the other asserting the old behavior, with the suite still green.", - "line": 498 + "line": 520 }, { "id": "RULESET.TESTS.no-source-grep", "klass": "RULESET", "value": "local/no-source-grep ESLint AST rule (eslint-rules/no-source-grep.cjs) rejects readFileSync of a source .cjs/.js/.ts path bound to a var later hit with .includes()/.match()/.startsWith()/.endsWith()/.indexOf()/.search(); error in tests/**/*.test.cjs, warn in gsd-core/bin/**/*.cjs + scripts/**/*.cjs (ADR 452 retired the old regex script, removed for good in #632)", - "line": 494 + "line": 516 }, { "id": "RULESET.TESTS.no-source-grep.exemption", "klass": "RULESET", "value": "// allow-test-rule: with one-line justification; reserved for tests where the file content IS the product surface (STATE.md, config.toml, hooks.json, agent .md). Migration to typed-IR parser tracked in #2974.", - "line": 495 + "line": 517 }, { "id": "RULESET.TESTS.no-source-grep.tmp-file-traps", "klass": "RULESET", "value": "reading tmp files written by the SUT in tests still trips lint; round-trip through CLI (e.g. frontmatter get) instead of readFileSync+.includes()", - "line": 496 + "line": 518 }, { "id": "RULESET.TESTS.no-timing-assertion", "klass": "RULESET", "value": "do not assert on wall-clock elapsed time (Date.now() delta, performance.now(), process.hrtime() comparison); such assertions test the host machine not the SUT and flake on loaded CI runners; enforcement: local/no-elapsed-assertion ESLint rule, currently warn (promotion to error tracked under open epic #1885, not #453 which already merged without completing it); canonical replacement: clock-seam pattern with node:test mock.timers", - "line": 511 + "line": 533 }, { "id": "RULESET.TESTS.property-based-testing", "klass": "RULESET", "value": "modules implementing parsing / transformation / budget-limit / bijective contracts must include at least one fast-check (fc) property test asserting a domain invariant; invariant categories: round-trip, monotonicity, boundary-containment, idempotency; property tests live in *.test.cjs alongside unit tests; CI signal: Stryker mutation score below 80% blocks merge", - "line": 513 + "line": 535 }, { "id": "RULESET.TRIAGE-EXISTING-WORK", "klass": "RULESET", "value": "before writing agent brief for confirmed bug, check (1) local branches git branch -a | grep , (2) untracked/modified files on that branch, (3) stash, (4) open PRs with matching head branch — recover existing work rather than re-implement", - "line": 538 + "line": 560 }, { "id": "RULESET.WORKFLOW.COVERAGE-METADATA", "klass": "RULESET", "value": "#1602 SUMMARY frontmatter `coverage:` block (list of {id,description,requirement?,verification:[{kind∈unit|integration|e2e|automated_ui|manual_procedural|other, ref, status∈pass|fail|unknown}],human_judgment:bool,rationale?}) is the per-deliverable RTM consumed DETERMINISTICALLY by verify-work extract_tests via `gsd-tools uat classify-coverage --summary ` (src/coverage.cts → bin/lib/coverage.cjs). AUTHORING: execute-plan create_summary populates it from task results; every deliverable MUST be classified; fail-safe default = human_judgment:true + rationale. CLASSIFY CONTRACT: auto-pass (skip human) ONLY when human_judgment===false (strict boolean) AND verification non-empty AND every status==='pass' AND zero validation errors — else PRESENT to human. mode:legacy (no block) ⇒ byte-identical prose `## Accomplishments` fall-through; `coverage: []` ⇒ mode:coverage, zero entries (single-confirmation). Frozen IR: MODE/PRESENT_REASON/ERROR_CODE enums locked by tests/coverage-metadata-parser.test.cjs. extractFrontmatter CANNOT parse it (scalars-only `-` items) → dedicated parser, sibling of parseMustHavesBlock. Asymmetry by design: false-negative=redundant prompt (status quo); false-positive=shipped bug UAT existed to catch", - "line": 527 + "line": 549 }, { "id": "RULESET.WORKFLOW_EXECUTE_END_TO_END", "klass": "RULESET", "value": "standard for single-workflow commands is \"Execute end-to-end.\" (no bolded **Follow the X workflow** fragments); flag-dispatch routing uses \"execute the X workflow end-to-end.\" in routing bullets — convention verified live across ~20 commands/gsd/*.md files; no ADR currently documents this specific phrasing rule (ADR-0002 covers the adjacent but distinct command-contract/@-ref-resolution seam, not this convention)", - "line": 526 + "line": 548 }, { "id": "RULESET.WORKFLOW_EXECUTION_CONTEXT", "klass": "RULESET", "value": "@-ref in commands/gsd/*.md must resolve to an existing file on disk; regression test in tests/docs-update.test.cjs (folds former \\`bug-3135-capture-backlog-workflow\\`, consolidation epic #1969); INVENTORY.md row + INVENTORY-MANIFEST.json families.workflows must stay in sync; \"Invoked by\" attribution must move when a flag absorbs a micro-skill", - "line": 525 + "line": 547 }, { "id": "RULESET.WORKFLOW_FILE_NAMES", "klass": "RULESET", "value": "workflow files use hyphens; XML attributes must match (extract-learnings not extract_learnings); tests should pin exact hyphenated name", - "line": 524 + "line": 546 }, { "id": "RULESET.WORKFLOW_MARKDOWN.FENCES", "klass": "RULESET", "value": "preserve opening language fence when editing shell snippets in workflow markdown; malformed fence creates fresh CR threads (MD040)", - "line": 520 + "line": 542 }, { "id": "RULESET.WORKFLOW_SIZE_BUDGET", "klass": "RULESET", "value": "workflow size enforcement (#1074; BYTES not lines per #717; LF-normalized per #683) = differential attribution size ratchet (PRIMARY anti-creep since #2724/ADR-2719 §4: tests/emitted-attribution.test.cjs's real-tree test reports growth in any gsd-core/workflows/*.md with its exact byte delta vs `next`, no committed snapshot, requires an ack entry — a fragment under tests/emitted-drift-acks/, #2914; the legacy tests/emitted-drift-ack.json is still honored and unioned in) + loose tier hard caps (outer red lines, NEVER raised on approach: XL<=98304 / LARGE<=61440 / DEFAULT<=40960) + discuss-phase<32000; a file that grew fails the differential guard — add an ack entry naming the file and reason, justify the growth in the PR (or extract LAZILY-loaded content; eager @-imports don't reduce loaded context); crossing a hard cap means EXTRACT, not bump. The prior per-file baseline (tests/workflow-size-baseline.json, `npm run size:baseline`) is REMOVED by #2724. Its new-file cap (ADR-1610 Decision point 3, un-baselined files <=32768, the Codex anchor) is REVIVED inside the differential's size ratchet itself (`NEW_FILE_CAP` in tests/helpers/emitted-diff.cjs) rather than lost: \"not yet baselined\" is exactly \"present in sizeCurrent, absent from sizeBaseline\", a signal the ratchet already computes for its own reasons. NOT ack-able — same as the tier hard caps, the fix is extraction. Narrower than the original: this check cannot see XL/LARGE tiering (tests/workflow-size-budget.test.cjs's classification, invisible to the pure differential module), so a legitimately large NEW file must extract rather than tier in, one release earlier than an existing file would need to — a disclosed, deliberate simplification", - "line": 521 + "line": 543 }, { "id": "SESSION.2026-05-05", "klass": "SESSION", "value": "[PRED.k320..k331 introduced; DEFECT.SOURCE-GREP-IN-NEW-TESTS, DEFECT.CHANGESET-PR-FIELD-DRIFT, DEFECT.PHASE-DIR-PREFIX-DRIFT, DEFECT.PROMPT-INJECTION-SCAN-COLLISION; ADR-0002 thin-wrapper pattern findings folded into RULESET.WORKFLOW_*]", - "line": 930 + "line": 809 }, { "id": "SESSION.2026-05-05.sdk-bridge", "klass": "SESSION", "value": "PR #3158 SDK Runtime Bridge — observability isolation rule; strict-mode dispatchMode reporting invariant; transport decision ordering (guard before event emission); folded into Dispatch Policy Module glossary", - "line": 931 + "line": 810 }, { "id": "SESSION.2026-05-09", "klass": "SESSION", "value": "[8-PR triage wave, 7 merged + 1 subsumed; META.RULE.* introduced; WAVE.LESSON.* captured; k320/k322/k323/k326/k331 evidence; AI Ops Memory predicate format established]", - "line": 932 + "line": 811 }, { "id": "SESSION.2026-05-10", "klass": "SESSION", "value": "[ai-ops memory consolidation; release-notes standard taxonomy + templates; RELEASE-NOTES.* predicates introduced]", - "line": 933 + "line": 812 }, { "id": "SESSION.2026-05-13", "klass": "SESSION", "value": "[Shell Command Projection Module expansion (#3465-#3468); ADR-0009 superseded; new exports for subprocess dispatch and platform file I/O; phase-gated migration plan; PR #3464 three-gate invariant CI+CR+unresolved=0; PR #3470 stash-include-untracked rebase pattern]", - "line": 934 + "line": 813 }, { "id": "SESSION.2026-05-14", "klass": "SESSION", "value": "[#3095/PR #3490 EXEC.CLASSIFY.* introduced (Anthropic/Copilot/Codex/Gemini [runtime removed #1928] cross-runtime rate-limit sentinel coverage); #3489/PR #3499 DEFECT.STATE-TRAMPLE.idempotency-oracle (STATE.md current_phase field is oracle for state.complete-phase); #3488/PR #3501 DAG resolver same-phase short-form depends_on (shortFormToId index added to sdk/src/query/phase.ts); #3491/PR #3502 DEFECT.NESTED-GIT-INIT (gitWorktreeInfoInternal helper); #3493/PR #3500 extractCurrentMilestone generic Phase Details continuation past planned-milestone siblings; #3503/PR #3504 DEFECT.PATH-SUBSTRING-CHECK (trailing-slash anchor for homedir checks); #3346/PR #3505 codex AoT TOML leaf-key via extractFlatHookEventName; #3506/PR #3507 label-scoped stale-bot sub-job pattern; multi-PR triage operational lessons folded into PROC.TRIAGE.*; #3508 DEFECT.AGENT-ISOLATION-SILENT-FAIL; gsd-test image-missing auto-build (locally-built image via embedded heredoc Dockerfile); refined PRED.k322 threshold to 3 PRs/<10min]", - "line": 935 + "line": 814 }, { "id": "SESSION.2026-05-15", "klass": "SESSION", "value": "[#3537/PR #3538 DEFECT.PHASE-REGEX-FANOUT — phaseMarkdownRegexSource promoted to core.cjs and wired to 7 sites; parity-style regression test established as DEFECT.GENERATIVE-FIX exemplar; trek-e/gsd-test-runner#1 filed for DEFECT.GSD-TEST-MIRROR-POISONED — chown-back-before-exec legacy gap (poisoned holodeck mirror unstuck via authorized docker chown to remote 1000:1000); RULESET.PR-FLOW.* codified from project CLAUDE.md load-bearing rule; first dispatch under run-tests-before-create held cleanly (PR #3520 worker stopped on Docker exit 12 infra failure, orchestrator opened PR after unblock); CONTEXT.md refactored from 882 lines of mixed prose+predicates into ~500 lines of pure-predicate format with chronological session log]", - "line": 936 + "line": 815 }, { "id": "SESSION.2026-05-15.parallel-fix-dispatch", "klass": "SESSION", "value": "[#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]", - "line": 937 + "line": 816 }, { "id": "SESSION.2026-05-16", "klass": "SESSION", "value": "[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]", - "line": 938 + "line": 817 }, { "id": "WAVE.LESSON.agent-narrative-unreliable", "klass": "WAVE", "value": "k095/k324 confirmed at scale: 5 of 8 agents terminated mid-monitor with stale claims requiring direct verification", - "line": 751 + "line": 773 }, { "id": "WAVE.LESSON.changelog-policy-violation-multiplier", "klass": "WAVE", "value": "brief contradicting CONTRIBUTING.md's changelog-fragment policy (\"CHANGELOG Entries — Drop a Fragment\" section) produced violations on 5 of 8 PRs (#3300, #3302, #3304, #3305, #3308); k326 + k320 capture", - "line": 748 + "line": 770 }, { "id": "WAVE.LESSON.cr-throttle-burst-correlation", "klass": "WAVE", "value": "8 PRs in <15min triggered k322 sustained-throttle on multiple PRs (#3306 worst case)", - "line": 749 + "line": 771 }, { "id": "WAVE.LESSON.k101-still-trips", "klass": "WAVE", "value": "even after CONTEXT.md k101 reinforcement, agent of record posted self-PR comment on close; k331 adds explicit close-time literal-instruction guard", - "line": 752 + "line": 774 }, { "id": "WAVE.LESSON.sibling-audit-overlap", "klass": "WAVE", "value": "k015-family parallel dispatch on #3297 + #3298 produced k323 add-backlog.md cross-PR overlap", - "line": 750 + "line": 772 }, { "id": "WORKSTREAM.INVARIANT.migrate-name", "klass": "WORKSTREAM", "value": "must normalize through canonical slug policy", - "line": 574 + "line": 596 }, { "id": "WORKSTREAM.INVARIANT.slug-contract", "klass": "WORKSTREAM", "value": "all .planning/workstreams/ must be addressable by set/get/status/complete", - "line": 575 + "line": 597 }, { "id": "WORKSTREAM.NAME.POLICY.cjs-module", "klass": "WORKSTREAM", "value": "gsd-core/bin/lib/workstream-name-policy.cjs owns toWorkstreamSlug + active-name/path-segment validation", - "line": 590 + "line": 612 }, { "id": "WORKSTREAM.POINTER.SEAM.cjs-module", "klass": "WORKSTREAM", "value": "gsd-core/bin/lib/active-workstream-store.cjs owns read/write self-heal for .planning/active-workstream", - "line": 591 + "line": 613 }, { "id": "WORKSTREAM.REGRESSION.test-anchor", "klass": "WORKSTREAM", "value": "tests/workstream.test.cjs::normalizes --migrate-name to a valid workstream slug", - "line": 576 + "line": 598 }, { "id": "WORKTREE.SEAM.caller-rule", "klass": "WORKTREE", "value": "verify.cjs must consume inspectWorktreeHealth for W017 classification; no ad-hoc porcelain parsing in callers", - "line": 584 + "line": 606 }, { "id": "WORKTREE.SEAM.current", "klass": "WORKTREE", "value": "Worktree Safety Policy Module", - "line": 568 + "line": 590 }, { "id": "WORKTREE.SEAM.decision-1", "klass": "WORKTREE", "value": "retain non-destructive default; destructive path only as explicit future opt-in scaffold", - "line": 572 + "line": 594 }, { "id": "WORKTREE.SEAM.default-prune-policy", "klass": "WORKTREE", "value": "metadata_prune_only (non-destructive)", - "line": 571 + "line": 593 }, { "id": "WORKTREE.SEAM.files", "klass": "WORKTREE", "value": "[gsd-core/bin/lib/worktree-safety.cjs]", - "line": 569 + "line": 591 }, { "id": "WORKTREE.SEAM.interface", "klass": "WORKTREE", "value": "[resolveWorktreeContext, parseWorktreePorcelain, planWorktreePrune, executeWorktreePrunePlan, planWorktreeRecordAgent, cmdWorktreeRecordAgent]", - "line": 570 + "line": 592 }, { "id": "WORKTREE.SEAM.invariant", "klass": "WORKTREE", "value": "parser failure must degrade to metadata_prune_only and never escalate to destructive removal", - "line": 582 + "line": 604 }, { "id": "WORKTREE.SEAM.inventory-interface", "klass": "WORKTREE", "value": "[listLinkedWorktreePaths, inspectWorktreeHealth]", - "line": 583 + "line": 605 }, { "id": "WORKTREE.SEAM.inventory-snapshot", "klass": "WORKTREE", "value": "snapshotWorktreeInventory(repoRoot,{staleAfterMs,nowMs}) is canonical linked-worktree health snapshot for callers", - "line": 586 + "line": 608 }, { "id": "WORKTREE.SEAM.test-anchor-w017", "klass": "WORKTREE", "value": "tests/orphan-worktree-detection.test.cjs + tests/worktree-safety-policy.test.cjs", - "line": 585 + "line": 607 }, { "id": "WORKTREE.SEAM.test-anchors", "klass": "WORKTREE", "value": "[resolveWorktreeContext:has_local_planning|linked_worktree|not_git_repo|main_worktree, planWorktreePrune:git_list_failed|worktrees_present|no_worktrees|parser_throw_fallback, executeWorktreePrunePlan:missing_plan|skip_passthrough|unsupported_action|metadata_prune_only]", - "line": 581 + "line": 603 }, { "id": "WORKTREE.SEAM.test-policy", "klass": "WORKTREE", "value": "cover all decision branches in policy module before changing prune behavior", - "line": 580 + "line": 602 } ], "duplicates": [] diff --git a/package.json b/package.json index 0487671e4..b6a650277 100644 --- a/package.json +++ b/package.json @@ -112,7 +112,9 @@ "lint": "eslint . --cache --cache-location node_modules/.cache/eslint/ --max-warnings 0", "lint:fix": "eslint . --fix", "lint:table-schema-drift": "node scripts/lint-table-schema-drift.cjs", - "lint:ci": "npm run lint && npm run lint:skill-deps && npm run lint:generated-sync && node scripts/lint-test-file-count.cjs && node scripts/lint-command-contract.cjs && node scripts/lint-pr-check-project-dir.cjs && npm run lint:legacy-name && node scripts/lint-regression-test-names.cjs && node scripts/lint-allow-test-rule-refs.cjs && node scripts/lint-resolution-provenance.cjs && node scripts/lint-emitted-drift-ack.cjs && node scripts/lint-portable-timeout.cjs && node scripts/validate-registry.cjs && node scripts/lint-table-schema-drift.cjs && node scripts/lint-fix-has-regression-test.cjs && node scripts/lint-example-parser-parity.cjs && node scripts/lint-docs-command-form.cjs && node scripts/lint-plan-count-drift.cjs && node scripts/lint-milestone-window-drift.cjs && node scripts/lint-phase-enumeration-drift.cjs && node scripts/lint-planning-prompt-drift.cjs && node scripts/lint-completion-ratio-drift.cjs && node scripts/lint-state-field-drift.cjs && node scripts/lint-completion-predicate-drift.cjs", + "lint:frontmatter-scalar-broad-grep": "node scripts/lint-frontmatter-scalar-broad-grep.cjs", + "lint:removed-but-needed": "node scripts/lint-removed-but-needed.cjs", + "lint:ci": "npm run lint && npm run lint:skill-deps && npm run lint:generated-sync && node scripts/lint-test-file-count.cjs && node scripts/lint-command-contract.cjs && node scripts/lint-pr-check-project-dir.cjs && npm run lint:legacy-name && node scripts/lint-regression-test-names.cjs && node scripts/lint-allow-test-rule-refs.cjs && node scripts/lint-resolution-provenance.cjs && node scripts/lint-emitted-drift-ack.cjs && node scripts/lint-portable-timeout.cjs && node scripts/validate-registry.cjs && node scripts/lint-table-schema-drift.cjs && node scripts/lint-fix-has-regression-test.cjs && node scripts/lint-example-parser-parity.cjs && node scripts/lint-docs-command-form.cjs && node scripts/lint-plan-count-drift.cjs && node scripts/lint-milestone-window-drift.cjs && node scripts/lint-phase-enumeration-drift.cjs && node scripts/lint-planning-prompt-drift.cjs && node scripts/lint-completion-ratio-drift.cjs && node scripts/lint-state-field-drift.cjs && node scripts/lint-completion-predicate-drift.cjs && node scripts/lint-frontmatter-scalar-broad-grep.cjs && node scripts/lint-removed-but-needed.cjs", "lint:allow-test-rule-refs": "node scripts/lint-allow-test-rule-refs.cjs", "lint:regression-names": "node scripts/lint-regression-test-names.cjs", "lint:descriptions": "node scripts/lint-descriptions.cjs", diff --git a/scripts/changeset/lint.cjs b/scripts/changeset/lint.cjs index 933d5fe55..7413bff5c 100755 --- a/scripts/changeset/lint.cjs +++ b/scripts/changeset/lint.cjs @@ -22,6 +22,7 @@ const LINT_REASON = Object.freeze({ OK_NO_USER_FACING_CHANGES: 'ok_no_user_facing_changes', FAIL_MISSING_FRAGMENT: 'fail_missing_fragment', FAIL_INVALID_FRAGMENT: 'fail_invalid_fragment', + FAIL_PR_FIELD_DRIFT: 'fail_pr_field_drift', }); const OPT_OUT_LABEL = 'no-changelog'; @@ -53,10 +54,47 @@ function isFragment(file) { return /^\.changeset\/[^/]+\.md$/.test(file) && !file.endsWith('/README.md'); } -function evaluateLint({ changedFiles, labels, fragmentFailures = [] }) { +/** + * DEFECT.CHANGESET-PR-FIELD-DRIFT (#3316, #3325): a fragment's `pr:` field is + * a guess (issue number, stacked-PR leftover) that never got backfilled to + * the real PR number after `gh api POST /pulls` returned it. + * + * Pure: given `prEntries` (`[{ file, pr }]`, one entry per successfully + * parsed changed fragment — `pr` is always a positive integer, since + * `parseFragment` already rejects `pr: 0` / non-numeric values as + * `invalid_pr` before a fragment ever reaches this function) and + * `realPrNumber` (the PR this run belongs to, or `null` when unknown — + * push/non-PR runs), returns every fragment whose `pr` disagrees with + * `realPrNumber`. `realPrNumber == null` always yields `[]`: with no PR + * event payload to compare against, there is nothing to drift-check. + * + * `pr === 0` is always silent regardless of `realPrNumber` — CONTRIBUTING.md + * documents `pr: 0` as the deliberate placeholder used "during initial + * commit" before `gh api POST /pulls` returns the real number, so it is not + * yet a drifted value, just an unbackfilled one. (In practice `parseFragment` + * already rejects `pr: 0` as `invalid_pr` before a fragment reaches this + * function via `main()`'s wiring — this guard documents and locks in the + * pure function's own contract independent of that upstream check.) + */ +function findPrFieldDrift(prEntries, realPrNumber) { + if (realPrNumber == null) return []; + const drift = []; + for (const { file, pr } of prEntries) { + if (pr === 0) continue; + if (pr !== realPrNumber) { + drift.push({ file, found: pr, expected: realPrNumber }); + } + } + return drift; +} + +function evaluateLint({ changedFiles, labels, fragmentFailures = [], prFieldDrift = [] }) { if (fragmentFailures.length > 0) { return { ok: false, reason: LINT_REASON.FAIL_INVALID_FRAGMENT, failures: fragmentFailures }; } + if (prFieldDrift.length > 0) { + return { ok: false, reason: LINT_REASON.FAIL_PR_FIELD_DRIFT, drift: prFieldDrift }; + } if (changedFiles.some(isFragment)) { return { ok: true, reason: LINT_REASON.OK_FRAGMENT_PRESENT }; } @@ -78,10 +116,18 @@ function main() { // GitHub Actions event payload path const eventPath = process.env.GITHUB_EVENT_PATH; let labels = []; + // DEFECT.CHANGESET-PR-FIELD-DRIFT: the real PR number this run belongs to, + // read from the same event payload. `null` on a push / non-PR run (no + // `pull_request` in the payload, or no payload at all) — the drift check + // below is a no-op in that case, it never fails a push run. + let realPrNumber = null; if (eventPath && fs.existsSync(eventPath)) { try { const event = JSON.parse(fs.readFileSync(eventPath, 'utf8')); labels = (event.pull_request?.labels || []).map((l) => l.name); + if (event.pull_request && Number.isInteger(event.pull_request.number)) { + realPrNumber = event.pull_request.number; + } } catch { /* fall through */ } } // #2988: local fallback must match the repo's integration branch (`next`), @@ -107,6 +153,7 @@ function main() { // Validate the content of every changed fragment file. const fragmentFailures = []; + const prEntries = []; for (const file of changedFiles) { if (!isFragment(file)) continue; // A fragment path in the diff that no longer exists on disk was deleted in @@ -125,10 +172,13 @@ function main() { const result = parseFragment(src); if (!result.ok) { fragmentFailures.push({ file, reason: result.reason, detail: result.detail }); + continue; } + prEntries.push({ file, pr: result.fragment.pr }); } - const verdict = evaluateLint({ changedFiles, labels, fragmentFailures }); + const prFieldDrift = findPrFieldDrift(prEntries, realPrNumber); + const verdict = evaluateLint({ changedFiles, labels, fragmentFailures, prFieldDrift }); if (process.argv.includes('--json')) { process.stdout.write(JSON.stringify({ ...verdict, changedFiles, labels }, null, 2) + '\n'); } else if (verdict.ok) { @@ -141,6 +191,13 @@ function main() { process.stderr.write(` ${f.file}: ${f.reason}${detail}\n`); } process.stderr.write(`Fix the fragment(s) above before merging.\n`); + } else if (verdict.reason === LINT_REASON.FAIL_PR_FIELD_DRIFT) { + process.stderr.write(`\nERROR changeset-lint: ${verdict.reason}\n`); + process.stderr.write(`The following .changeset fragment(s) have a stale \`pr:\` field (DEFECT.CHANGESET-PR-FIELD-DRIFT):\n`); + for (const d of verdict.drift) { + process.stderr.write(` ${d.file}: pr: ${d.found}, expected pr: ${d.expected}\n`); + } + process.stderr.write(`Backfill \`pr:\` with this PR's real number (see .changeset/README.md), then push again.\n`); } else { process.stderr.write(`\nERROR changeset-lint: ${verdict.reason}\n`); process.stderr.write(`PR touches user-facing files but does not include a .changeset/*.md fragment.\n`); @@ -152,4 +209,4 @@ function main() { if (require.main === module) runMain(main); -module.exports = { evaluateLint, LINT_REASON, OPT_OUT_LABEL, isUserFacing, isFragment, DEFAULT_BASE }; +module.exports = { evaluateLint, LINT_REASON, OPT_OUT_LABEL, isUserFacing, isFragment, DEFAULT_BASE, findPrFieldDrift }; diff --git a/scripts/lint-canary-version-leak.cjs b/scripts/lint-canary-version-leak.cjs new file mode 100644 index 000000000..12c5fde5b --- /dev/null +++ b/scripts/lint-canary-version-leak.cjs @@ -0,0 +1,73 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Canary-version-leak lint (DEFECT.CANARY-VERSION-LEAK, CONTEXT.md). + * + * `package.json` `.version` on `main` must never carry a `-canary.` + * suffix — that suffix is a dev-branch/prerelease marker. Nothing published + * depends on the string at runtime, but every consumer of the version + * metadata (release flow, install banners, statusline) surfaces the + * dev-channel label as if it were the shipped release. The 2026-05-16 audit + * found `origin/main` at `"version": "1.50.0-canary.0"`, landed by a fix PR + * that accidentally carried a version bump from a dev-branch base (commit + * 2d32ad82, #3206). + * + * Modeled on scripts/lint-package-identity-drift.cjs / scripts/lint-table-schema-drift.cjs: + * a standalone node script (not a node:test), exit 0 clean / exit 1 + message + * on a leaked canary version. Wired to run only for PRs targeting `main` + * (.github/workflows/version-gate.yml) — a canary version is expected and + * harmless on every other branch. + */ + +const fs = require('node:fs'); +const path = require('node:path'); + +const CANARY_RE = /-canary\.\d+/; + +/** + * Pure: does this version string carry a `-canary.` suffix? + * @param {string} version + * @returns {boolean} + */ +function isCanaryVersion(version) { + return typeof version === 'string' && CANARY_RE.test(version); +} + +/** + * Read `/package.json` and return its `.version`, or null if the file + * is missing/unreadable/unparsable. + * @param {string} root + * @returns {string|null} + */ +function readPackageVersion(root) { + try { + const raw = fs.readFileSync(path.join(root, 'package.json'), 'utf8'); + const pkg = JSON.parse(raw); + return typeof pkg.version === 'string' ? pkg.version : null; + } catch { + return null; + } +} + +function main() { + const root = path.join(__dirname, '..'); + const version = readPackageVersion(root); + if (version == null) { + process.stderr.write('canary-version-leak: could not read/parse package.json .version\n'); + process.exitCode = 1; + return; + } + if (!isCanaryVersion(version)) { + process.stdout.write(`ok canary-version-leak: package.json version '${version}' carries no -canary. suffix\n`); + return; + } + process.stderr.write(`canary-version-leak: package.json version '${version}' carries a -canary. suffix (DEFECT.CANARY-VERSION-LEAK).\n`); + process.stderr.write('A -canary. version must never land on main. Reset .version to the canonical\n'); + process.stderr.write('pre-canary stable before merging — see CONTEXT.md DEFECT.CANARY-VERSION-LEAK.\n'); + process.exitCode = 1; +} + +if (require.main === module) main(); + +module.exports = { isCanaryVersion, readPackageVersion, CANARY_RE }; diff --git a/scripts/lint-default-flip-documentation.cjs b/scripts/lint-default-flip-documentation.cjs new file mode 100644 index 000000000..68062f164 --- /dev/null +++ b/scripts/lint-default-flip-documentation.cjs @@ -0,0 +1,193 @@ +#!/usr/bin/env node +'use strict'; + +/** + * lint-default-flip-documentation.cjs — DEFECT.DEFAULT-FLIP-DOCUMENTATION + * (CONTEXT.md). + * + * ## Why + * + * A PR flips a config default but doesn't call out the migration semantics + * (when the new default takes effect; existing configs vs new configs; what + * the opt-back-in looks like — #3309, the v2 default flip from mid-flight to + * end-of-phase). + * + * ## Scope (deliberately narrower than the full DEFECT.detect clause) + * + * The DEFECT text names two surfaces: `CONFIG_DEFAULTS` and + * `buildNewProjectConfig`. This check covers ONLY the single-source-of-truth + * defaults manifest, `gsd-core/bin/shared/config-defaults.manifest.json` + * (what `CONFIG_DEFAULTS` in `src/configuration.cts` / `src/config.cts` + * actually loads at runtime) — because it is pure JSON, a resolved + * key→value-map diff between base and head is trivially reliable: no line + * movement, reordering, or refactor can ever produce a false "value changed" + * verdict, only an actual value change can. + * + * `buildNewProjectConfig`'s `hardcoded` object literal in `src/config.cts` + * is DELIBERATELY OUT OF SCOPE here. It mixes literal values with + * environment-derived branches (`hasBraveSearch`, etc.) and spreads of + * `CONFIG_DEFAULTS.*` — there is no reliable way to compute its *resolved* + * value map from source text alone without executing the compiled module at + * both refs, and a line/AST-level diff of that literal would inherit exactly + * the false-positive risk (a harmless refactor that moves or restructures + * the literal reads as a "flip") this check exists to avoid. Per the audit's + * own risk callout, a noisy check here is worse than no check — the + * `buildNewProjectConfig` half of the DEFECT stays prose-only. + * + * ## What this checks + * + * If any *value* differs between the base and head resolved manifest + * key→value maps (additions/removals alone don't count as a "flip" — the + * symptom is specifically about an EXISTING default changing), fail unless + * the PR body contains a `## Breaking Changes` (or `# Breaking Changes`) + * heading. + * + * Needs a PR event payload (`GITHUB_EVENT_PATH`) to read the PR body — this + * is a dedicated-workflow check (like `lint-canary-version-leak.cjs`), not a + * `lint:ci` member, since a local/push run has no PR body to check against. + */ + +const fs = require('node:fs'); +const path = require('node:path'); +const cp = require('node:child_process'); +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); + +const ROOT = path.join(__dirname, '..'); +const MANIFEST_PATH = path.join('gsd-core', 'bin', 'shared', 'config-defaults.manifest.json'); +const BREAKING_CHANGES_RE = /^#{1,6}\s*Breaking Changes\b/im; + +/** + * Pure: flatten a nested plain-object JSON value into dot-path + * `{ "a.b.c": value }` leaves. Arrays and primitives are leaves (compared by + * JSON.stringify equality, never recursed into) so array reordering reads as + * one value change, not N. + * @param {unknown} value + * @param {string} prefix + * @param {Record} out + * @returns {Record} + */ +function flatten(value, prefix = '', out = {}) { + if (value !== null && typeof value === 'object' && !Array.isArray(value)) { + for (const [key, v] of Object.entries(value)) { + flatten(v, prefix ? `${prefix}.${key}` : key, out); + } + } else { + out[prefix] = value; + } + return out; +} + +/** + * Pure: given two resolved (already-flattened) key→value maps, return the + * keys present in BOTH whose value differs. Additions/removals are NOT + * "flips" — a brand-new default has no prior behavior to contradict. + * @param {Record} baseMap + * @param {Record} headMap + * @returns {{ key: string, from: unknown, to: unknown }[]} + */ +function findDefaultValueChanges(baseMap, headMap) { + const changes = []; + for (const key of Object.keys(baseMap)) { + if (!Object.prototype.hasOwnProperty.call(headMap, key)) continue; + if (JSON.stringify(baseMap[key]) !== JSON.stringify(headMap[key])) { + changes.push({ key, from: baseMap[key], to: headMap[key] }); + } + } + return changes; +} + +/** + * Pure verdict: given the detected default-value changes and the PR body, + * decide pass/fail. + * @param {{ key: string, from: unknown, to: unknown }[]} changes + * @param {string} prBody + * @returns {{ ok: boolean, changes: object[] }} + */ +function evaluateDefaultFlipDoc(changes, prBody) { + if (changes.length === 0) return { ok: true, changes: [] }; + if (BREAKING_CHANGES_RE.test(prBody || '')) return { ok: true, changes }; + return { ok: false, changes }; +} + +/** + * Read and JSON.parse the manifest at a given git ref. Returns `{}` when the + * file doesn't exist at that ref (new file, or ref predates it) — that is + * not a "flip", it's an addition, and is silently excluded by + * findDefaultValueChanges's both-sides-present requirement anyway. + * @param {string} root + * @param {string} ref + * @returns {Record} + */ +function readManifestAtRef(root, ref) { + let raw; + try { + raw = cp.execFileSync('git', ['show', `${ref}:${MANIFEST_PATH.split(path.sep).join('/')}`], { + cwd: root, + encoding: 'utf8', + timeout: 15000, + }); + } catch { + return {}; + } + try { + return JSON.parse(raw); + } catch (e) { + throw new ExitError(2, `lint-default-flip-documentation: ${ref}:${MANIFEST_PATH} is not valid JSON: ${e.message}`); + } +} + +function readPrBody() { + const eventPath = process.env.GITHUB_EVENT_PATH; + if (!eventPath || !fs.existsSync(eventPath)) return null; + try { + const event = JSON.parse(fs.readFileSync(eventPath, 'utf8')); + return typeof event.pull_request?.body === 'string' ? event.pull_request.body : ''; + } catch { + return null; + } +} + +function main() { + const prBody = readPrBody(); + if (prBody === null) { + console.log('lint-default-flip-documentation: no PR event payload (not a pull_request run), skipping'); + return; + } + + const baseRef = `origin/${process.env.GITHUB_BASE_REF || 'next'}`; // #2988 + const baseMap = flatten(readManifestAtRef(ROOT, baseRef)); + const headMap = flatten(readManifestAtRef(ROOT, 'HEAD')); + const changes = findDefaultValueChanges(baseMap, headMap); + const verdict = evaluateDefaultFlipDoc(changes, prBody); + + if (!verdict.ok) { + const detail = verdict.changes + .map((c) => ` ${c.key}: ${JSON.stringify(c.from)} → ${JSON.stringify(c.to)}`) + .join('\n'); + throw new ExitError( + 1, + 'lint-default-flip-documentation: this PR changes an existing default value in\n' + + 'config-defaults.manifest.json (DEFECT.DEFAULT-FLIP-DOCUMENTATION) but the PR body has no\n' + + '`## Breaking Changes` section. Add one covering: (a) when the new default takes effect\n' + + '(config-set, fresh project, regenerated config), (b) the opt-back-in command\n' + + '(`gsd config-set `), (c) effect on in-flight artifacts. Changed default(s):\n' + + detail, + ); + } + console.log( + changes.length === 0 + ? 'ok lint-default-flip-documentation: no default value changed' + : `ok lint-default-flip-documentation: ${changes.length} default value change(s), PR body documents Breaking Changes`, + ); +} + +module.exports = { + flatten, + findDefaultValueChanges, + evaluateDefaultFlipDoc, + readManifestAtRef, + MANIFEST_PATH, + BREAKING_CHANGES_RE, +}; + +if (require.main === module) runMain(main); diff --git a/scripts/lint-frontmatter-scalar-broad-grep.cjs b/scripts/lint-frontmatter-scalar-broad-grep.cjs new file mode 100644 index 000000000..fcaec52a3 --- /dev/null +++ b/scripts/lint-frontmatter-scalar-broad-grep.cjs @@ -0,0 +1,237 @@ +#!/usr/bin/env node +'use strict'; + +/** + * lint-frontmatter-scalar-broad-grep.cjs — DEFECT.FRONTMATTER-SCALAR-BROAD-GREP + * (CONTEXT.md). + * + * ## Why + * + * A YAML-frontmatter scalar (e.g. VERIFICATION.md `status:`) read with + * `grep "^key:"` over the WHOLE markdown report instead of the frontmatter + * block returns extra matches whenever a `key:` line also appears in the + * body (a code block, a copied artifact, an example). Piped into + * `cut`/`tr`, those extra matches concatenate into a value that matches no + * expected token, silently misrouting a valid state (#586/PR #650: + * `grep "^status:"` also matched body `status:` lines, yielding + * `passed+gaps_found+human_needed` instead of `passed` and blocking a + * passed phase). + * + * The fix-forward is to scope the grep to the leading frontmatter block and + * take only the first match: + * sed -n '/^---$/,/^---$/p' "$f" | grep -m1 "^:" | cut -d: -f2 | tr -d ' ' + * + * ## What this scans + * + * Every fenced ```bash / ```sh code block in `gsd-core/workflows/*.md`, + * `agents/*.md`, and `commands/**\/*.md`. Within each block, flags a + * `grep "^key:"` / `grep '^key:'` invocation that: + * - is NOT preceded (earlier in the SAME block) by a frontmatter-scoping + * idiom (`sed -n '/^---$/,/^---$/p'`, a JS `/^---\n([\s\S]*?)\n---/` + * extraction, or an equivalent range over the `---` delimiter), AND + * - does NOT carry a `-m1` (or `-m 1`) flag, and is NOT immediately piped + * into `head -1`/`head -n 1` (frontmatter always precedes the body in + * these generated reports, so `head -1` on the whole file is the same + * single-match guarantee as `-m1`), AND + * - is used for exact-token comparison: piped (same line) into + * `cut`/`tr`, or captured into a shell variable that is later compared + * via `==`/`case` elsewhere in the same block. + * + * ## False-positive risk (moderate-to-high, per audit) + * + * Some `grep "^key:"` uses are intentionally whole-body (scanning multiple + * report files at once, not one frontmatter block) and are not a bug. Add + * `# lint-allow: frontmatter-scalar-broad-grep — ` on the same line + * (or the line immediately above) to suppress a specific invocation. + */ + +const fs = require('fs'); +const path = require('path'); +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); + +const ROOT = path.join(__dirname, '..'); +const DEFAULT_ROOTS = ['gsd-core/workflows', 'agents', 'commands']; + +const FENCE_RE = /^```(bash|sh)\s*$/; +const FENCE_END_RE = /^```\s*$/; + +// A `grep "^key:"` / `grep '^key:'` invocation. Captures the key name and the +// full option string preceding the pattern (so callers can check for -m1). +const GREP_KEY_RE = /grep\s+((?:-\S+\s+)*)(["'])\^([A-Za-z_][\w-]*):\2/; + +// A frontmatter-scoping idiom: a delimiter-range extraction anchored on the +// `---` frontmatter fence, opened by `^---` (sed/awk `/^---$/,/^---$/p`, or a +// JS regex like `/^---\n([\s\S]*?)\n---/`) and closed by a second `---` +// within a short window. Matches both idioms without caring which language +// wrote the delimiter. +const FRONTMATTER_SCOPE_RE = /\^---[\s\S]{0,300}?---/; + +const ALLOW_RE = /#\s*lint-allow:\s*frontmatter-scalar-broad-grep/; + +// `| head -1` / `| head -n 1` immediately after the grep is functionally +// equivalent to `-m1` for this check: frontmatter always precedes the body +// in these generated reports, so the first grep match is always the +// frontmatter's, and `head -1` discards every later (body) match exactly +// like `-m1` would. +function hasSingleMatchGuard(line, optionString) { + if (/(^|\s)-m\s*1(\s|$)/.test(optionString) || /(^|\s)--max-count[= ]1(\s|$)/.test(optionString)) return true; + return /\|\s*head\s+(-1|-n\s*1)\b/.test(line); +} + +function isSuppressed(lines, idx) { + if (ALLOW_RE.test(lines[idx])) return true; + if (idx > 0 && ALLOW_RE.test(lines[idx - 1])) return true; + return false; +} + +/** + * Extract fenced ```bash/```sh code blocks from markdown text. + * @param {string} text + * @returns {{ startLine: number, lines: string[] }[]} + */ +function extractBashBlocks(text) { + const allLines = text.split(/\r?\n/); + const blocks = []; + let inBlock = false; + let blockLines = []; + let blockStart = 0; + for (let i = 0; i < allLines.length; i += 1) { + const line = allLines[i]; + if (!inBlock && FENCE_RE.test(line.trim())) { + inBlock = true; + blockLines = []; + blockStart = i + 2; // first line INSIDE the block is 1-indexed i+2 + continue; + } + if (inBlock && FENCE_END_RE.test(line.trim())) { + blocks.push({ startLine: blockStart, lines: blockLines }); + inBlock = false; + continue; + } + if (inBlock) blockLines.push(line); + } + return blocks; +} + +/** + * Pure: find every un-scoped, token-comparison `grep "^key:"` invocation in a + * single fenced bash/sh block's lines. Returns `{ line, key, snippet }[]` + * (line numbers relative to the block's startLine, already offset by caller). + * @param {string[]} lines + * @returns {{ lineIndex: number, key: string, snippet: string }[]} + */ +function findBroadGrepsInBlock(lines) { + const findings = []; + // Variables assigned from a grep-key capture on this block, so a later + // `==`/`case` use of that variable (without an intervening scope/-m1) also + // counts as "used for exact-token comparison". + const capturedVars = new Set(); + let scopeSeenAt = -1; + + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]; + + if (FRONTMATTER_SCOPE_RE.test(line)) { + scopeSeenAt = i; + } + + const m = line.match(GREP_KEY_RE); + if (!m) continue; + const [, options, , key] = m; + if (hasSingleMatchGuard(line, options)) continue; + if (isSuppressed(lines, i)) continue; + // Scoping must appear strictly before this grep line in the same block. + const scoped = scopeSeenAt !== -1 && scopeSeenAt <= i; + if (scoped) continue; + + const pipedToTokenTool = /\|\s*(cut|tr)\b/.test(line); + const assignMatch = line.match(/^\s*(?:export\s+)?([A-Za-z_][\w]*)=\$\(/); + if (assignMatch) capturedVars.add(assignMatch[1]); + + let comparedLater = false; + if (assignMatch) { + const varName = assignMatch[1]; + for (let j = i + 1; j < lines.length; j += 1) { + if ( + new RegExp(`\\$\\{?${varName}\\}?"?\\s*(==|!=)`).test(lines[j]) + || new RegExp(`case\\s+"?\\$\\{?${varName}\\}?"?\\s+in`).test(lines[j]) + ) { + comparedLater = true; + break; + } + } + } + + if (pipedToTokenTool || comparedLater) { + findings.push({ lineIndex: i, key, snippet: line.trim() }); + } + } + return findings; +} + +function walkMarkdown(dir) { + const out = []; + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return out; // a missing root is not an error — some surfaces are optional + } + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) out.push(...walkMarkdown(full)); + else if (entry.isFile() && entry.name.endsWith('.md')) out.push(full); + } + return out; +} + +/** + * Scan the given roots (repo-relative) for un-scoped frontmatter-scalar + * broad-greps. + * @param {string[]} roots + * @returns {{ file: string, line: number, key: string, snippet: string }[]} + */ +function scan(roots = DEFAULT_ROOTS) { + const offenders = []; + for (const rel of roots) { + const abs = path.isAbsolute(rel) ? rel : path.join(ROOT, rel); + for (const file of walkMarkdown(abs)) { + const blocks = extractBashBlocks(fs.readFileSync(file, 'utf8')); + for (const block of blocks) { + for (const finding of findBroadGrepsInBlock(block.lines)) { + offenders.push({ + file: path.relative(ROOT, file), + line: block.startLine + finding.lineIndex, + key: finding.key, + snippet: finding.snippet, + }); + } + } + } + } + return offenders; +} + +function main() { + const rootsEnv = process.env.GSD_LINT_FRONTMATTER_SCALAR_ROOTS; + const roots = rootsEnv ? rootsEnv.split(path.delimiter).filter(Boolean) : DEFAULT_ROOTS; + const offenders = scan(roots); + if (offenders.length > 0) { + const detail = offenders.map((o) => ` ${o.file}:${o.line} ${o.snippet}`).join('\n'); + throw new ExitError( + 1, + 'lint-frontmatter-scalar-broad-grep: `grep "^key:"` over the whole file, compared to an\n' + + 'exact token, with no frontmatter scoping and no -m1 (DEFECT.FRONTMATTER-SCALAR-BROAD-GREP).\n' + + 'A body line beginning `key:` is enough to break this. Scope to the frontmatter block:\n' + + ' sed -n \'/^---$/,/^---$/p\' "$f" | grep -m1 "^:" | cut -d: -f2 | tr -d \' \'\n' + + 'or add `# lint-allow: frontmatter-scalar-broad-grep — ` if this is a genuine\n' + + 'whole-body scan:\n' + + detail, + ); + } + console.log(`ok lint-frontmatter-scalar-broad-grep: no un-scoped frontmatter-scalar greps in ${roots.length} root(s)`); +} + +module.exports = { findBroadGrepsInBlock, extractBashBlocks, scan, DEFAULT_ROOTS }; + +if (require.main === module) runMain(main); diff --git a/scripts/lint-removed-but-needed.cjs b/scripts/lint-removed-but-needed.cjs new file mode 100644 index 000000000..1bd165426 --- /dev/null +++ b/scripts/lint-removed-but-needed.cjs @@ -0,0 +1,208 @@ +#!/usr/bin/env node +'use strict'; + +/** + * lint-removed-but-needed.cjs — DEFECT.REMOVED-BUT-NEEDED (CONTEXT.md). + * + * ## Why + * + * A file/key gets removed because "no longer used" without verifying every + * consumer (workflows, docs, manifests, npm scripts). #3316: root + * `package-lock.json` was deleted while `package.json` still declares deps + * and workflows still use `cache: 'npm'` + `npm ci` (which require a + * lockfile). e3b52c70: docs referenced a removed `/gsd-new-workspace` + * workflow after it was deleted. + * + * ## What this checks + * + * For every file deleted (`git diff --name-status ...HEAD`, status + * `D`), grep the post-diff tree (`.github/workflows/`, `gsd-core/`, `docs/`, + * `package.json`) for the deleted file's basename. Fails if any reference + * survives. `package-lock.json` deletions additionally fail if any workflow + * still uses `npm ci` or `cache: 'npm'`/`cache: "npm"` — those depend on a + * lockfile even though they never spell out its filename. + * + * ## False-positive risk (moderate, per audit) + * + * A common basename (`index.js`, `config.json`) can coincidentally match an + * unrelated file, and this only catches LITERAL string references — not a + * variable holding the filename or a glob that happened to match it. + */ + +const fs = require('node:fs'); +const path = require('node:path'); +const cp = require('node:child_process'); +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); + +const ROOT = path.join(__dirname, '..'); +const SCAN_ROOTS = ['.github/workflows', 'gsd-core', 'docs']; +const EXTRA_FILES = ['package.json']; + +// Skip these when walking SCAN_ROOTS — binary/generated content that can +// never carry a meaningful basename reference, and is often large. +const SKIP_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.ico', '.woff', '.woff2', '.ttf', '.zip']); + +function escapeRegex(s) { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Pure: does `content` contain a literal reference to `basename`, delimited + * by non-identifier/non-path characters on both sides (so "foo.json" doesn't + * match inside "old-foo.json.bak" style names but does match in a normal + * path/prose context)? + * @param {string} content + * @param {string} basename + * @returns {boolean} + */ +function referencesBasename(content, basename) { + const re = new RegExp(`(^|[^\\w.-])${escapeRegex(basename)}($|[^\\w.-])`); + return re.test(content); +} + +/** + * Pure: given the deleted file's basename, does content contain a + * lockfile-dependent idiom (`npm ci`, `cache: 'npm'` / `cache: "npm"`)? + * Only meaningful for package-lock.json deletions. + * @param {string} content + * @returns {boolean} + */ +function referencesNpmLockfileDependency(content) { + return /\bnpm ci\b/.test(content) || /cache:\s*['"]npm['"]/.test(content); +} + +function walk(dir) { + const out = []; + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return out; + } + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) out.push(...walk(full)); + else if (entry.isFile() && !SKIP_EXT.has(path.extname(entry.name))) out.push(full); + } + return out; +} + +/** + * Pure: given a list of deleted basenames and a `{ file, content }[]` corpus + * of the post-diff tree, find every surviving reference. + * @param {string[]} deletedFiles - repo-relative deleted paths + * @param {{ file: string, content: string }[]} corpus + * @returns {{ deletedFile: string, referencedIn: string, reason: string }[]} + */ +function findSurvivingReferences(deletedFiles, corpus) { + const violations = []; + for (const deletedFile of deletedFiles) { + const basename = path.basename(deletedFile); + for (const { file, content } of corpus) { + if (referencesBasename(content, basename)) { + violations.push({ deletedFile, referencedIn: file, reason: `basename '${basename}' still referenced` }); + } + } + if (basename === 'package-lock.json') { + for (const { file, content } of corpus) { + if (file.startsWith('.github/workflows') && referencesNpmLockfileDependency(content)) { + violations.push({ + deletedFile, + referencedIn: file, + reason: '`npm ci` / `cache: \'npm\'` still present — both require a lockfile', + }); + } + } + } + } + return violations; +} + +function getDeletedFiles(root, baseRef) { + // Deliberately let a git failure (unresolvable ref, no merge base, etc.) + // propagate as a plain Error — main() treats ANY scan() failure as "cannot + // resolve this base ref in this environment" and degrades to a skip, + // matching lint-fix-has-regression-test.cjs. There is no failure mode here + // that should hard-exit non-zero; a real drift is only ever reported once + // the diff succeeds and findSurvivingReferences finds a violation. + const out = cp.execFileSync('git', ['diff', '--name-status', `${baseRef}...HEAD`], { + cwd: root, + encoding: 'utf8', + timeout: 15000, + }); + return out + .trim() + .split('\n') + .filter(Boolean) + .filter((line) => line.startsWith('D\t')) + .map((line) => line.slice(2)); +} + +function buildCorpus(root) { + const corpus = []; + for (const rel of SCAN_ROOTS) { + for (const abs of walk(path.join(root, rel))) { + try { + corpus.push({ file: path.relative(root, abs).replace(/\\/g, '/'), content: fs.readFileSync(abs, 'utf8') }); + } catch { + // unreadable (broken symlink, binary that slipped past SKIP_EXT) — skip + } + } + } + for (const rel of EXTRA_FILES) { + const abs = path.join(root, rel); + try { + corpus.push({ file: rel, content: fs.readFileSync(abs, 'utf8') }); + } catch { + // optional file absent — skip + } + } + return corpus; +} + +function scan(root, baseRef) { + const deletedFiles = getDeletedFiles(root, baseRef); + if (deletedFiles.length === 0) return []; + const corpus = buildCorpus(root); + return findSurvivingReferences(deletedFiles, corpus); +} + +function main() { + const baseRef = `origin/${process.env.GSD_REMOVED_BUT_NEEDED_BASE || process.env.GITHUB_BASE_REF || 'next'}`; + let violations; + try { + violations = scan(ROOT, baseRef); + } catch (e) { + // origin/ unreachable in this environment (e.g. a shallow local + // clone with no matching remote-tracking ref) — degrade to a skip rather + // than a false failure, matching lint-fix-has-regression-test.cjs. + console.log(`lint-removed-but-needed: could not resolve ${baseRef}, skipping (${e.message})`); + return; + } + if (violations.length > 0) { + const detail = violations + .map((v) => ` ${v.deletedFile} deleted, but still referenced in ${v.referencedIn}: ${v.reason}`) + .join('\n'); + throw new ExitError( + 1, + 'lint-removed-but-needed: a deleted file is still referenced by a live consumer\n' + + '(DEFECT.REMOVED-BUT-NEEDED). Either restore the file or update every consumer in the\n' + + 'same commit — do not paper over with a workaround that loses reproducibility:\n' + + detail, + ); + } + console.log('ok lint-removed-but-needed: no deleted file has a surviving reference'); +} + +module.exports = { + referencesBasename, + referencesNpmLockfileDependency, + findSurvivingReferences, + getDeletedFiles, + buildCorpus, + scan, + SCAN_ROOTS, + EXTRA_FILES, +}; + +if (require.main === module) runMain(main); diff --git a/src/check-command-router.cts b/src/check-command-router.cts index 0da5acddb..8a339a7be 100644 --- a/src/check-command-router.cts +++ b/src/check-command-router.cts @@ -364,6 +364,7 @@ function recentCommitMessages(projectDir: string): string { encoding: 'utf-8', maxBuffer: 4 * 1024 * 1024, windowsHide: true, + timeout: 15_000, }); } catch { return ''; @@ -663,6 +664,7 @@ function computeUiSafetyGate(projectDir: string, phase: string): { encoding: 'utf-8', maxBuffer: 2 * 1024 * 1024, windowsHide: true, + timeout: 10_000, }); hasUiFiles = changed.split('\n').some((f) => f.trim() && (UI_FILE_EXTENSIONS_RE.test(f) || UI_PATH_PATTERNS_RE.test(f)), @@ -821,7 +823,7 @@ function cmdTddReviewCheckpoint(projectDir: string, args: string[], raw: boolean try { const redCommit = execFileSync( 'git', ['log', '--oneline', `--grep=^test(${planId}):`, '--', '.'], - { cwd: projectDir, encoding: 'utf-8', maxBuffer: 1024 * 1024, windowsHide: true }, + { cwd: projectDir, encoding: 'utf-8', maxBuffer: 1024 * 1024, windowsHide: true, timeout: 10_000 }, ); red = redCommit.trim().length > 0; } catch { /* git unavailable or no match */ } @@ -829,7 +831,7 @@ function cmdTddReviewCheckpoint(projectDir: string, args: string[], raw: boolean try { const greenCommit = execFileSync( 'git', ['log', '--oneline', `--grep=^feat(${planId}):`, '--', '.'], - { cwd: projectDir, encoding: 'utf-8', maxBuffer: 1024 * 1024, windowsHide: true }, + { cwd: projectDir, encoding: 'utf-8', maxBuffer: 1024 * 1024, windowsHide: true, timeout: 10_000 }, ); green = greenCommit.trim().length > 0; } catch { /* git unavailable or no match */ } @@ -837,7 +839,7 @@ function cmdTddReviewCheckpoint(projectDir: string, args: string[], raw: boolean try { const refactorCommit = execFileSync( 'git', ['log', '--oneline', `--grep=^refactor(${planId}):`, '--', '.'], - { cwd: projectDir, encoding: 'utf-8', maxBuffer: 1024 * 1024, windowsHide: true }, + { cwd: projectDir, encoding: 'utf-8', maxBuffer: 1024 * 1024, windowsHide: true, timeout: 10_000 }, ); refactor = refactorCommit.trim().length > 0; } catch { /* git unavailable or no match */ } diff --git a/src/roadmap-upgrade.cts b/src/roadmap-upgrade.cts index 72985fbb1..589653af4 100644 --- a/src/roadmap-upgrade.cts +++ b/src/roadmap-upgrade.cts @@ -490,7 +490,7 @@ function applyMigration(cwd: string, plan: MigrationPlan, options: { dryRun?: bo // ── Real run: verify clean working tree ─────────────────────────────────── let gitStatus: string; try { - gitStatus = execSync('git status --porcelain', { cwd, encoding: 'utf8', windowsHide: true }); + gitStatus = execSync('git status --porcelain', { cwd, encoding: 'utf8', windowsHide: true, timeout: 10_000 }); } catch (err) { throw new Error(`git status failed: ${(err as Error).message}`); } diff --git a/src/shell-command-projection.cts b/src/shell-command-projection.cts index f516daf8a..cf0f8ec6b 100644 --- a/src/shell-command-projection.cts +++ b/src/shell-command-projection.cts @@ -791,6 +791,7 @@ export function probeTty(opts: { platform?: string } = {}): string | null { const ttyPath = childProcess.execFileSync('tty', [], { encoding: 'utf-8', stdio: ['inherit', 'pipe', 'ignore'], + timeout: 5_000, }).trim(); if (!ttyPath || ttyPath === 'not a tty') return null; return ttyPath; diff --git a/src/smart-entry.cts b/src/smart-entry.cts index dd5a54a1a..b4b19c2dc 100644 --- a/src/smart-entry.cts +++ b/src/smart-entry.cts @@ -187,6 +187,7 @@ function readGitSignals(cwd: string): GitSignals { maxBuffer: 4 * 1024 * 1024, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'], + timeout: 10_000, }); } catch { return ''; diff --git a/tests/canary-version-leak-lint.test.cjs b/tests/canary-version-leak-lint.test.cjs new file mode 100644 index 000000000..1c54491b2 --- /dev/null +++ b/tests/canary-version-leak-lint.test.cjs @@ -0,0 +1,109 @@ +'use strict'; +process.env.GSD_TEST_MODE = '1'; + +/** + * Canary-version-leak lint (DEFECT.CANARY-VERSION-LEAK, CONTEXT.md). + * + * scripts/lint-canary-version-leak.cjs fails a run whose package.json + * .version carries a -canary. suffix — that suffix belongs to a dev + * branch only and must never land on main (2026-05-16 audit: origin/main at + * "1.50.0-canary.0", commit 2d32ad82, #3206). Wired into + * .github/workflows/version-gate.yml, gated to PRs targeting main. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const fc = require('fast-check'); + +const ROOT = path.join(__dirname, '..'); +const LINT_SCRIPT = path.join(ROOT, 'scripts', 'lint-canary-version-leak.cjs'); +const { isCanaryVersion, readPackageVersion } = require(LINT_SCRIPT); +const { cleanup } = require('./helpers.cjs'); +const { runNode } = require('./helpers/process-seam.cjs'); + +describe('canary-version-leak lint: isCanaryVersion (pure)', () => { + test('a plain release version is not canary', () => { + assert.equal(isCanaryVersion('1.8.0'), false); + }); + + test('a -canary. version IS flagged', () => { + assert.equal(isCanaryVersion('1.8.0-canary.3'), true); + }); + + test('boundary: -canary.0 (N=0) is flagged', () => { + assert.equal(isCanaryVersion('1.50.0-canary.0'), true); + }); + + test('a non-canary prerelease suffix (e.g. -rc.1) is not flagged', () => { + assert.equal(isCanaryVersion('1.8.0-rc.1'), false); + }); + + test('non-string input is not flagged (fails closed to false, main() handles missing version separately)', () => { + assert.equal(isCanaryVersion(undefined), false); + assert.equal(isCanaryVersion(null), false); + }); + + test('property: appending -canary. to any base string always flags it', () => { + fc.assert( + fc.property( + fc.string().filter((s) => !/-canary\.\d+/.test(s)), + fc.nat(), + (base, n) => { + assert.equal(isCanaryVersion(`${base}-canary.${n}`), true); + }, + ), + ); + }); + + test('property: a version with no "-canary." substring is never flagged', () => { + fc.assert( + fc.property( + fc.string().filter((s) => !s.includes('-canary.')), + (version) => { + assert.equal(isCanaryVersion(version), false); + }, + ), + ); + }); +}); + +describe('canary-version-leak lint: the live repo package.json is clean', () => { + test('readPackageVersion + isCanaryVersion pass against the real package.json', () => { + const version = readPackageVersion(ROOT); + assert.equal(typeof version, 'string'); + assert.equal(isCanaryVersion(version), false, `package.json version '${version}' must not carry -canary.`); + }); +}); + +describe('canary-version-leak lint: main() end-to-end wiring', () => { + function writeFixtureRoot(version) { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-canary-lint-e2e-')); + fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify({ name: 'fixture', version }), 'utf8'); + return tmpDir; + } + + test('exit 0 on a clean version (real package.json, 1.8.0)', () => { + const result = runNode([LINT_SCRIPT], { cwd: ROOT }); + assert.equal(result.exitCode, 0, `expected exit 0, got ${result.exitCode}: ${result.stderr}`); + }); + + test('exit 1 on a fixture package.json carrying a -canary. suffix (never touches the real package.json)', (t) => { + const tmpDir = writeFixtureRoot('1.8.0-canary.3'); + t.after(() => cleanup(tmpDir)); + + // The script resolves its target as `/../package.json`, so + // run a throwaway copy of the script from inside the fixture root + // rather than mutating the real repo's package.json. + const scriptCopyDir = path.join(tmpDir, 'scripts'); + fs.mkdirSync(scriptCopyDir, { recursive: true }); + const scriptCopy = path.join(scriptCopyDir, 'lint-canary-version-leak.cjs'); + fs.copyFileSync(LINT_SCRIPT, scriptCopy); + + const result = runNode([scriptCopy]); + assert.equal(result.exitCode, 1, `expected exit 1, got ${result.exitCode}`); + assert.match(result.stderr, /canary-version-leak/); + }); +}); diff --git a/tests/changeset-lint.test.cjs b/tests/changeset-lint.test.cjs index eca70c164..645d0f9ec 100644 --- a/tests/changeset-lint.test.cjs +++ b/tests/changeset-lint.test.cjs @@ -7,7 +7,7 @@ const path = require('node:path'); const fs = require('node:fs'); const os = require('node:os'); -const { evaluateLint, LINT_REASON, DEFAULT_BASE: CHANGESET_DEFAULT_BASE } = require(path.join(__dirname, '..', 'scripts', 'changeset', 'lint.cjs')); +const { evaluateLint, LINT_REASON, findPrFieldDrift, DEFAULT_BASE: CHANGESET_DEFAULT_BASE } = require(path.join(__dirname, '..', 'scripts', 'changeset', 'lint.cjs')); const { DEFAULT_BASE: DOCS_DEFAULT_BASE } = require(path.join(__dirname, '..', 'scripts', 'lint-docs-required.cjs')); const ROOT = path.join(__dirname, '..'); @@ -87,6 +87,31 @@ function runLint(repoDir) { return { status: result.exitCode, report }; } +/** + * Like runLint, but with a real GITHUB_EVENT_PATH pointing at a synthetic PR + * event payload — needed to exercise DEFECT.CHANGESET-PR-FIELD-DRIFT, which + * compares each fragment's `pr:` against `event.pull_request.number`. + * @param {string} repoDir + * @param {number|undefined} prNumber - omit to simulate a push/non-PR run + * (no `pull_request` key in the payload at all). + */ +function runLintWithPrEvent(repoDir, prNumber) { + const eventPath = path.join(repoDir, 'event.json'); + const payload = prNumber === undefined ? {} : { pull_request: { number: prNumber, labels: [] } }; + fs.writeFileSync(eventPath, JSON.stringify(payload)); + const result = runNode( + [LINT_SCRIPT, '--json'], + { + cwd: repoDir, + env: { ...process.env, GITHUB_BASE_REF: 'main', GITHUB_EVENT_PATH: eventPath }, + timeoutMs: PROBE_TIMEOUT_MS, + }, + ); + let report = {}; + try { report = JSON.parse(result.stdout); } catch { /* leave as empty object */ } + return { status: result.exitCode, report }; +} + // evaluateLint is a pure function over file lists + label list — no fs, no git. // Tests assert on the structured verdict: { ok: bool, reason: LINT_REASON.X }. @@ -94,7 +119,10 @@ describe('changeset lint: pure verdict (#2975)', () => { test('LINT_REASON enum exposes the documented codes', () => { assert.deepEqual( Object.keys(LINT_REASON).sort(), - ['OK_FRAGMENT_PRESENT', 'OK_NO_USER_FACING_CHANGES', 'OK_OPT_OUT_LABEL', 'FAIL_MISSING_FRAGMENT', 'FAIL_INVALID_FRAGMENT'].sort(), + [ + 'OK_FRAGMENT_PRESENT', 'OK_NO_USER_FACING_CHANGES', 'OK_OPT_OUT_LABEL', + 'FAIL_MISSING_FRAGMENT', 'FAIL_INVALID_FRAGMENT', 'FAIL_PR_FIELD_DRIFT', + ].sort(), ); }); @@ -175,6 +203,57 @@ describe('changeset lint: pure verdict (#2975)', () => { assert.equal(verdict.ok, false); assert.equal(verdict.reason, LINT_REASON.FAIL_INVALID_FRAGMENT); }); + + test('FAIL_PR_FIELD_DRIFT when prFieldDrift is non-empty, even with a fragment present', () => { + const verdict = evaluateLint({ + changedFiles: ['.changeset/good.md'], + labels: [], + prFieldDrift: [{ file: '.changeset/good.md', found: 1234, expected: 1240 }], + }); + assert.equal(verdict.ok, false); + assert.equal(verdict.reason, LINT_REASON.FAIL_PR_FIELD_DRIFT); + assert.deepEqual(verdict.drift, [{ file: '.changeset/good.md', found: 1234, expected: 1240 }]); + }); + + test('FAIL_INVALID_FRAGMENT beats FAIL_PR_FIELD_DRIFT (checked first)', () => { + const verdict = evaluateLint({ + changedFiles: ['.changeset/bad.md'], + labels: [], + fragmentFailures: [{ file: '.changeset/bad.md', reason: 'invalid_pr', detail: '0' }], + prFieldDrift: [{ file: '.changeset/bad.md', found: 1, expected: 2 }], + }); + assert.equal(verdict.reason, LINT_REASON.FAIL_INVALID_FRAGMENT); + }); +}); + +// --------------------------------------------------------------------------- +// DEFECT.CHANGESET-PR-FIELD-DRIFT (#3316, #3325): a fragment's pr: field is a +// guess (issue number, stacked-PR leftover) that never got backfilled to the +// real PR number. +// --------------------------------------------------------------------------- +describe('changeset lint: findPrFieldDrift (pure)', () => { + test('a fragment whose pr matches the real PR number is not drift', () => { + assert.deepEqual(findPrFieldDrift([{ file: '.changeset/good.md', pr: 1234 }], 1234), []); + }); + + test('a fragment whose pr disagrees with the real PR number IS flagged, naming the file', () => { + const drift = findPrFieldDrift([{ file: '.changeset/stale.md', pr: 1234 }], 1240); + assert.deepEqual(drift, [{ file: '.changeset/stale.md', found: 1234, expected: 1240 }]); + }); + + test('realPrNumber === null (no PR event payload) always yields no drift — push/non-PR runs never fail', () => { + assert.deepEqual(findPrFieldDrift([{ file: '.changeset/anything.md', pr: 999 }], null), []); + }); + + test('a pr: 0 placeholder entry is silent even when it disagrees with a real, non-zero PR number', () => { + // CONTRIBUTING.md documents pr:0 as the deliberate placeholder used + // "during initial commit" before the real PR number is backfilled — it + // is unbackfilled, not drifted, so it must never be flagged. (In the + // real main() wiring, parseFragment already rejects pr:0 upstream as + // invalid_pr before a fragment reaches this function at all — this test + // locks in the pure function's own contract independent of that.) + assert.deepEqual(findPrFieldDrift([{ file: '.changeset/placeholder.md', pr: 0 }], 1234), []); + }); }); // --------------------------------------------------------------------------- @@ -315,3 +394,76 @@ describe('#2988: changeset + docs lints resolve the same local base fallback', ( `the two lints must not diverge on base resolution: changeset='${CHANGESET_DEFAULT_BASE}' docs='${DOCS_DEFAULT_BASE}'`); }); }); + +// --------------------------------------------------------------------------- +// DEFECT.CHANGESET-PR-FIELD-DRIFT end-to-end: real main() wiring reading +// GITHUB_EVENT_PATH's pull_request.number and comparing it against each +// changed fragment's pr: field. +// --------------------------------------------------------------------------- +describe('changeset lint: PR-field-drift end-to-end wiring', () => { + test('correct pr: (matches the real PR number) passes the gate', (t) => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-lint-e2e-')); + t.after(() => cleanup(tmpDir)); + + buildTempRepo(tmpDir, [ + { file: 'bin/thing.js', content: '// placeholder\n' }, + { file: '.changeset/good.md', content: '---\ntype: Fixed\npr: 4242\n---\n**Good** fix. (#4242)\n' }, + ]); + + const { status, report } = runLintWithPrEvent(tmpDir, 4242); + + assert.equal(status, 0, `expected exit 0, got ${status}: ${JSON.stringify(report)}`); + assert.equal(report.reason, LINT_REASON.OK_FRAGMENT_PRESENT); + }); + + test('wrong pr: (stale/guessed number) fails the gate, naming the offending file', (t) => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-lint-e2e-')); + t.after(() => cleanup(tmpDir)); + + buildTempRepo(tmpDir, [ + { file: 'bin/thing.js', content: '// placeholder\n' }, + { file: '.changeset/stale.md', content: '---\ntype: Fixed\npr: 3312\n---\n**Stale pr field** fix. (#3316)\n' }, + ]); + + const { status, report } = runLintWithPrEvent(tmpDir, 3316); + + assert.equal(status, 1, `expected exit 1, got ${status}`); + assert.equal(report.reason, LINT_REASON.FAIL_PR_FIELD_DRIFT); + assert.ok(Array.isArray(report.drift), 'drift must be an array'); + const entry = report.drift.find((d) => d.file.endsWith('.changeset/stale.md')); + assert.ok(entry, `drift must name the offending file, got: ${JSON.stringify(report.drift)}`); + assert.equal(entry.found, 3312); + assert.equal(entry.expected, 3316); + }); + + test('no PR event payload (push / non-PR run) skips the drift check entirely, even with a stale pr:', (t) => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-lint-e2e-')); + t.after(() => cleanup(tmpDir)); + + buildTempRepo(tmpDir, [ + { file: 'bin/thing.js', content: '// placeholder\n' }, + { file: '.changeset/anything.md', content: '---\ntype: Fixed\npr: 999\n---\n**Anything** fix. (#999)\n' }, + ]); + + // runLint() (no override) sets GITHUB_EVENT_PATH: '' — no payload at all. + const { status, report } = runLint(tmpDir); + + assert.equal(status, 0, `expected exit 0, got ${status}: ${JSON.stringify(report)}`); + assert.equal(report.reason, LINT_REASON.OK_FRAGMENT_PRESENT); + }); + + test('event payload present but with no pull_request key (also push-shaped) skips the drift check', (t) => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-lint-e2e-')); + t.after(() => cleanup(tmpDir)); + + buildTempRepo(tmpDir, [ + { file: 'bin/thing.js', content: '// placeholder\n' }, + { file: '.changeset/anything.md', content: '---\ntype: Fixed\npr: 999\n---\n**Anything** fix. (#999)\n' }, + ]); + + const { status, report } = runLintWithPrEvent(tmpDir, undefined); + + assert.equal(status, 0, `expected exit 0, got ${status}: ${JSON.stringify(report)}`); + assert.equal(report.reason, LINT_REASON.OK_FRAGMENT_PRESENT); + }); +}); diff --git a/tests/default-flip-documentation-lint.test.cjs b/tests/default-flip-documentation-lint.test.cjs new file mode 100644 index 000000000..76ddb328f --- /dev/null +++ b/tests/default-flip-documentation-lint.test.cjs @@ -0,0 +1,197 @@ +'use strict'; +process.env.GSD_TEST_MODE = '1'; + +/** + * Default-flip-documentation lint (DEFECT.DEFAULT-FLIP-DOCUMENTATION, + * CONTEXT.md). + * + * scripts/lint-default-flip-documentation.cjs fails a PR that changes an + * EXISTING default value in gsd-core/bin/shared/config-defaults.manifest.json + * (the single source `CONFIG_DEFAULTS` loads at runtime) without a + * `## Breaking Changes` PR-body section covering the migration semantics + * (#3309: the v2 default flip from mid-flight to end-of-phase). + * + * Scope note: this check is deliberately narrower than the full DEFECT text + * — it does NOT cover `buildNewProjectConfig`'s hardcoded object literal in + * src/config.cts, because that literal mixes env-derived branches with + * CONFIG_DEFAULTS spreads and cannot be reduced to a resolved value map from + * source text alone without executing the compiled module at both refs. A + * line/text diff of that literal would inherit the exact false-positive + * risk (a harmless refactor reading as a "flip") this check exists to + * avoid, so it is left out rather than shipped noisy. See the script's own + * header comment for the full rationale. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const ROOT = path.join(__dirname, '..'); +const LINT_SCRIPT = path.join(ROOT, 'scripts', 'lint-default-flip-documentation.cjs'); +const { flatten, findDefaultValueChanges, evaluateDefaultFlipDoc, MANIFEST_PATH } = require(LINT_SCRIPT); +const { cleanup } = require('./helpers.cjs'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { gitOrThrow } = require('./helpers/git-fixture.cjs'); + +describe('default-flip-documentation lint: flatten (pure)', () => { + test('flattens a nested object into dot-path leaves', () => { + assert.deepEqual( + flatten({ workflow: { human_verify_mode: 'end-of-phase' }, model_profile: 'balanced' }), + { 'workflow.human_verify_mode': 'end-of-phase', model_profile: 'balanced' }, + ); + }); + + test('an array is a leaf, not recursed into (reordering reads as one change, not N)', () => { + assert.deepEqual(flatten({ tags: ['a', 'b'] }), { tags: ['a', 'b'] }); + }); +}); + +describe('default-flip-documentation lint: findDefaultValueChanges (pure)', () => { + test('the real #3309 defect shape IS a change: an existing key value differs', () => { + const changes = findDefaultValueChanges( + { 'workflow.human_verify_mode': 'mid-flight' }, + { 'workflow.human_verify_mode': 'end-of-phase' }, + ); + assert.deepEqual(changes, [{ key: 'workflow.human_verify_mode', from: 'mid-flight', to: 'end-of-phase' }]); + }); + + test('LOOKALIKE: a brand-new key (addition, not a flip) is NOT a change', () => { + const changes = findDefaultValueChanges({ a: 1 }, { a: 1, b: 2 }); + assert.deepEqual(changes, []); + }); + + test('LOOKALIKE: a removed key (not a flip either) is NOT a change', () => { + const changes = findDefaultValueChanges({ a: 1, b: 2 }, { a: 1 }); + assert.deepEqual(changes, []); + }); + + test('LOOKALIKE: the whole object reordered/restructured with identical resolved values is NOT a change (the false-positive the audit called out)', () => { + const base = { workflow: { a: 1, b: 2 }, git: { create_tag: true } }; + const head = { git: { create_tag: true }, workflow: { b: 2, a: 1 } }; + assert.deepEqual(findDefaultValueChanges(flatten(base), flatten(head)), []); + }); + + test('an unchanged value is not reported', () => { + assert.deepEqual(findDefaultValueChanges({ a: 1 }, { a: 1 }), []); + }); +}); + +describe('default-flip-documentation lint: evaluateDefaultFlipDoc (pure)', () => { + test('no changes: always ok regardless of PR body', () => { + assert.equal(evaluateDefaultFlipDoc([], '').ok, true); + }); + + test('a real flip with no Breaking Changes section in the PR body fails', () => { + const verdict = evaluateDefaultFlipDoc([{ key: 'x', from: 1, to: 2 }], 'just a normal PR description'); + assert.equal(verdict.ok, false); + }); + + test('a real flip WITH a "## Breaking Changes" heading in the PR body passes', () => { + const verdict = evaluateDefaultFlipDoc( + [{ key: 'x', from: 1, to: 2 }], + 'Summary\n\n## Breaking Changes\n\nNew default takes effect on config-set.', + ); + assert.equal(verdict.ok, true); + }); + + test('the heading match is case-insensitive and tolerates heading level', () => { + const verdict = evaluateDefaultFlipDoc([{ key: 'x', from: 1, to: 2 }], '# breaking changes\ndetails'); + assert.equal(verdict.ok, true); + }); +}); + +describe('default-flip-documentation lint: main() end-to-end wiring', () => { + const git = (dir, ...args) => gitOrThrow(args, { cwd: dir }); + + function buildRepo(tmpDir, baseManifest, headManifest) { + git(tmpDir, 'init', '-q', '-b', 'main'); + git(tmpDir, 'config', 'user.email', 'test@example.com'); + git(tmpDir, 'config', 'user.name', 'Test'); + const manifestAbs = path.join(tmpDir, MANIFEST_PATH); + fs.mkdirSync(path.dirname(manifestAbs), { recursive: true }); + fs.writeFileSync(manifestAbs, JSON.stringify(baseManifest)); + git(tmpDir, 'add', '-A'); + git(tmpDir, 'commit', '-q', '-m', 'base'); + git(tmpDir, 'update-ref', 'refs/remotes/origin/main', 'HEAD'); + + git(tmpDir, 'checkout', '-q', '-b', 'pr'); + fs.writeFileSync(manifestAbs, JSON.stringify(headManifest)); + git(tmpDir, 'add', '-A'); + git(tmpDir, 'commit', '-q', '-m', 'pr'); + + const scriptsDir = path.join(tmpDir, 'scripts'); + const libDir = path.join(scriptsDir, 'lib'); + fs.mkdirSync(libDir, { recursive: true }); + const scriptCopy = path.join(scriptsDir, 'lint-default-flip-documentation.cjs'); + fs.copyFileSync(LINT_SCRIPT, scriptCopy); + fs.copyFileSync(path.join(ROOT, 'scripts', 'lib', 'cli-exit.cjs'), path.join(libDir, 'cli-exit.cjs')); + return scriptCopy; + } + + function runWithPrBody(tmpDir, scriptCopy, prBody) { + const eventPath = path.join(tmpDir, 'event.json'); + fs.writeFileSync(eventPath, JSON.stringify({ pull_request: { body: prBody } })); + return runNode( + [scriptCopy], + { + cwd: tmpDir, + env: { ...process.env, GITHUB_BASE_REF: 'main', GITHUB_EVENT_PATH: eventPath }, + }, + ); + } + + test('exit 1: a flipped default with no Breaking Changes section in the PR body', (t) => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-default-flip-e2e-')); + t.after(() => cleanup(tmpDir)); + const scriptCopy = buildRepo( + tmpDir, + { workflow: { human_verify_mode: 'mid-flight' } }, + { workflow: { human_verify_mode: 'end-of-phase' } }, + ); + const result = runWithPrBody(tmpDir, scriptCopy, 'Flips the default. No migration notes.'); + assert.equal(result.exitCode, 1, `expected exit 1, got ${result.exitCode}: ${result.stderr}`); + assert.match(result.stderr, /DEFAULT-FLIP-DOCUMENTATION/); + }); + + test('exit 0: a flipped default WITH a Breaking Changes section', (t) => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-default-flip-e2e-doc-')); + t.after(() => cleanup(tmpDir)); + const scriptCopy = buildRepo( + tmpDir, + { workflow: { human_verify_mode: 'mid-flight' } }, + { workflow: { human_verify_mode: 'end-of-phase' } }, + ); + const result = runWithPrBody( + tmpDir, + scriptCopy, + '## Breaking Changes\n\nNew default takes effect when config.json is regenerated; opt back in with `gsd config-set workflow.human_verify_mode mid-flight`.', + ); + assert.equal(result.exitCode, 0, `expected exit 0, got ${result.exitCode}: ${result.stderr}`); + }); + + test('LOOKALIKE: manifest restructured/reformatted with identical resolved values does NOT fail, even with no Breaking Changes section', (t) => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-default-flip-e2e-reformat-')); + t.after(() => cleanup(tmpDir)); + const scriptCopy = buildRepo( + tmpDir, + { a: 1, workflow: { x: true, y: false } }, + { workflow: { y: false, x: true }, a: 1 }, + ); + const result = runWithPrBody(tmpDir, scriptCopy, 'Pure reformat, no PR body sections at all.'); + assert.equal(result.exitCode, 0, `expected exit 0 (no real value change), got ${result.exitCode}: ${result.stderr}`); + }); + + test('exit 0 and skip when there is no PR event payload (push / local run)', (t) => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-default-flip-e2e-noevent-')); + t.after(() => cleanup(tmpDir)); + const scriptCopy = buildRepo(tmpDir, { a: 1 }, { a: 2 }); + const result = runNode( + [scriptCopy], + { cwd: tmpDir, env: { ...process.env, GITHUB_BASE_REF: 'main', GITHUB_EVENT_PATH: '' } }, + ); + assert.equal(result.exitCode, 0, `expected exit 0, got ${result.exitCode}: ${result.stderr}`); + assert.match(result.stdout, /skipping/); + }); +}); diff --git a/tests/eslint-rules.test.cjs b/tests/eslint-rules.test.cjs index c2d551b20..8a024bcd3 100644 --- a/tests/eslint-rules.test.cjs +++ b/tests/eslint-rules.test.cjs @@ -9,6 +9,7 @@ * - local/no-elapsed-assertion * - local/no-raw-rmsync-in-tests * - local/no-adhoc-markdown-parsing + * - local/require-subprocess-timeout */ const { test, describe } = require('node:test'); @@ -24,6 +25,7 @@ const noRawRmsyncInTests = require('../eslint-rules/no-raw-rmsync-in-tests.cjs') const noTautologicalAssert = require('../eslint-rules/no-tautological-assert.cjs'); const noAdhocMarkdownParsing = require('../eslint-rules/no-adhoc-markdown-parsing.cjs'); const noDuplicateFoldMarker = require('../eslint-rules/no-duplicate-fold-marker.cjs'); +const requireSubprocessTimeout = require('../eslint-rules/require-subprocess-timeout.cjs'); const ruleTester = new RuleTester({ languageOptions: { @@ -1592,7 +1594,7 @@ describe('no-adhoc-markdown-parsing rule', () => { }); }); -// ─── no-duplicate-fold-marker ──────────────────────────────────────────────── +// ─── no-duplicate-fold-marker ──────────────────────────────────────── describe('no-duplicate-fold-marker rule', () => { const REPO_ROOT = path.join(__dirname, '..'); @@ -1962,3 +1964,145 @@ describe('no-duplicate-fold-marker rule', () => { ); }); }); + +// ─── require-subprocess-timeout ──────────────────────────────────── + +describe('require-subprocess-timeout rule', () => { + test('rule module exports a create function', () => { + assert.strictEqual(typeof requireSubprocessTimeout.create, 'function'); + }); + + // ── INVALID cases (must error) ──────────────────────────────────────────── + + test('invalid: execFileSync("git", args, { cwd }) — object-literal options with no timeout key', () => { + ruleTester.run('require-subprocess-timeout', requireSubprocessTimeout, { + valid: [], + invalid: [ + { + code: ` + const { execFileSync } = require('node:child_process'); + const args = ['status']; + const cwd = '/repo'; + execFileSync('git', args, { cwd }); + `, + filename: 'src/some-module.cts', + errors: [{ messageId: 'requireSubprocessTimeout' }], + }, + ], + }); + }); + + test('invalid: execSync("npm ci", { encoding: "utf8" }) — object-literal options with no timeout key', () => { + ruleTester.run('require-subprocess-timeout', requireSubprocessTimeout, { + valid: [], + invalid: [ + { + code: ` + const { execSync } = require('node:child_process'); + execSync('npm ci', { encoding: 'utf8' }); + `, + filename: 'src/some-module.cts', + errors: [{ messageId: 'requireSubprocessTimeout' }], + }, + ], + }); + }); + + test('invalid: spawnSync with a dotted childProcess.spawnSync callee and no timeout', () => { + ruleTester.run('require-subprocess-timeout', requireSubprocessTimeout, { + valid: [], + invalid: [ + { + code: ` + const childProcess = require('node:child_process'); + childProcess.spawnSync('git', ['log'], { cwd: '/repo', encoding: 'utf-8' }); + `, + filename: 'src/some-module.cts', + errors: [{ messageId: 'requireSubprocessTimeout' }], + }, + ], + }); + }); + + test('invalid: execFileSync with NO options argument at all — categorically no timeout', () => { + ruleTester.run('require-subprocess-timeout', requireSubprocessTimeout, { + valid: [], + invalid: [ + { + code: ` + const { execFileSync } = require('node:child_process'); + execFileSync('git', ['status']); + `, + filename: 'src/some-module.cts', + errors: [{ messageId: 'requireSubprocessTimeout' }], + }, + ], + }); + }); + + // ── VALID cases (must NOT error) ────────────────────────────────────────── + + test('valid: execFileSync("git", args, { cwd, timeout: 30000 }) — timeout key present', () => { + ruleTester.run('require-subprocess-timeout', requireSubprocessTimeout, { + valid: [ + { + code: ` + const { execFileSync } = require('node:child_process'); + const args = ['status']; + const cwd = '/repo'; + execFileSync('git', args, { cwd, timeout: 30000 }); + `, + filename: 'src/some-module.cts', + }, + ], + invalid: [], + }); + }); + + test('valid: options as a pre-built identifier — execFileSync("git", args, opts) is not traced', () => { + ruleTester.run('require-subprocess-timeout', requireSubprocessTimeout, { + valid: [ + { + code: ` + const { execFileSync } = require('node:child_process'); + const args = ['status']; + const opts = { cwd: '/repo', timeout: 30000 }; + execFileSync('git', args, opts); + `, + filename: 'src/some-module.cts', + }, + ], + invalid: [], + }); + }); + + test('valid: same unbounded call under a tests/** filename — rule is inert outside src/*.cts', () => { + ruleTester.run('require-subprocess-timeout', requireSubprocessTimeout, { + valid: [ + { + code: ` + const { execFileSync } = require('node:child_process'); + execFileSync('git', ['status'], { cwd: '/repo' }); + `, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('valid: allow-unbounded-subprocess suppression comment on the call line', () => { + ruleTester.run('require-subprocess-timeout', requireSubprocessTimeout, { + valid: [ + { + code: ` + const { execFileSync } = require('node:child_process'); + execFileSync('git', ['status'], { cwd: '/repo' }); // allow-unbounded-subprocess: bounded by caller's own watchdog + `, + filename: 'src/some-module.cts', + }, + ], + invalid: [], + }); + }); +}); diff --git a/tests/lint-frontmatter-scalar-broad-grep.test.cjs b/tests/lint-frontmatter-scalar-broad-grep.test.cjs new file mode 100644 index 000000000..c6ad44eb9 --- /dev/null +++ b/tests/lint-frontmatter-scalar-broad-grep.test.cjs @@ -0,0 +1,181 @@ +'use strict'; +process.env.GSD_TEST_MODE = '1'; + +/** + * Frontmatter-scalar-broad-grep lint (DEFECT.FRONTMATTER-SCALAR-BROAD-GREP, + * CONTEXT.md). + * + * scripts/lint-frontmatter-scalar-broad-grep.cjs flags a `grep "^key:"` over + * a whole markdown report (not scoped to the frontmatter block, no -m1/ + * `head -1` single-match guard) whose result feeds an exact-token comparison + * — the #586/#651 bug class where a body line beginning `key:` concatenates + * onto the intended frontmatter value and misroutes a valid state. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const ROOT = path.join(__dirname, '..'); +const LINT_SCRIPT = path.join(ROOT, 'scripts', 'lint-frontmatter-scalar-broad-grep.cjs'); +const { findBroadGrepsInBlock, extractBashBlocks, scan } = require(LINT_SCRIPT); +const { cleanup } = require('./helpers.cjs'); +const { runNode } = require('./helpers/process-seam.cjs'); + +describe('frontmatter-scalar-broad-grep lint: findBroadGrepsInBlock (pure)', () => { + test('the real #586/#651 defect shape IS flagged: whole-file grep, no scope, no -m1, piped to cut|tr', () => { + const lines = [ + 'grep "^status:" "${QUICK_DIR}/${quick_id}-VERIFICATION.md" | cut -d: -f2 | tr -d \' \'', + ]; + const findings = findBroadGrepsInBlock(lines); + assert.equal(findings.length, 1); + assert.equal(findings[0].key, 'status'); + }); + + test('a variable captured from a broad grep and later compared with == is also flagged', () => { + const lines = [ + 'STATUS=$(grep "^status:" "$FILE")', + 'if [ "$STATUS" == "passed" ]; then echo ok; fi', + ]; + const findings = findBroadGrepsInBlock(lines); + assert.equal(findings.length, 1); + }); + + test('LOOKALIKE: sed-scoped to the frontmatter block is NOT flagged', () => { + const lines = [ + 'sed -n \'/^---$/,/^---$/p\' "$f" | grep -m1 "^status:" | cut -d: -f2 | tr -d \' \'', + ]; + assert.deepEqual(findBroadGrepsInBlock(lines), []); + }); + + test('LOOKALIKE: -m1 on the grep itself is NOT flagged even without a preceding scope', () => { + const lines = [ + 'grep -m1 "^status:" "$FILE" | cut -d: -f2 | tr -d \' \'', + ]; + assert.deepEqual(findBroadGrepsInBlock(lines), []); + }); + + test('LOOKALIKE: piped to `head -1` immediately after grep is NOT flagged (frontmatter is always first)', () => { + const lines = [ + 'AUDIT_STATUS=$(grep "^status:" "${AUDIT_FILE}" 2>/dev/null | head -1 | cut -d: -f2 | tr -d \' \')', + ]; + assert.deepEqual(findBroadGrepsInBlock(lines), []); + }); + + test('LOOKALIKE: a frontmatter block already extracted into a variable (JS regex idiom), then multiple keys parsed from it', () => { + const lines = [ + 'FRONTMATTER=$(node -e "', + ' const m = content.match(/^---\\n([\\s\\S]*?)\\n---/);', + ' if (m) process.stdout.write(m[1]);', + '")', + 'STATUS=$(echo "$FRONTMATTER" | grep "^status:" | cut -d: -f2 | xargs)', + 'FILES_REVIEWED=$(echo "$FRONTMATTER" | grep "^files_reviewed:" | cut -d: -f2 | xargs)', + ]; + assert.deepEqual(findBroadGrepsInBlock(lines), []); + }); + + test('LOOKALIKE: an explicit `# lint-allow:` suppression comment silences the finding', () => { + const lines = [ + '# lint-allow: frontmatter-scalar-broad-grep — intentional multi-file scan, not a single report', + 'grep "^status:" reports/*.md | cut -d: -f2 | tr -d \' \'', + ]; + assert.deepEqual(findBroadGrepsInBlock(lines), []); + }); + + test('a grep not piped to cut/tr and never compared is NOT flagged (not a token-comparison use)', () => { + const lines = ['grep -c "^status:" "$FILE"']; + assert.deepEqual(findBroadGrepsInBlock(lines), []); + }); +}); + +describe('frontmatter-scalar-broad-grep lint: extractBashBlocks (pure)', () => { + test('extracts a fenced ```bash block and reports its 1-indexed start line', () => { + const text = [ + 'intro', + '```bash', + 'echo hi', + '```', + 'outro', + ].join('\n'); + const blocks = extractBashBlocks(text); + assert.equal(blocks.length, 1); + assert.equal(blocks[0].startLine, 3); + assert.deepEqual(blocks[0].lines, ['echo hi']); + }); + + test('a non-bash fenced block (e.g. ```json) is ignored', () => { + const text = ['```json', '{"a":1}', '```'].join('\n'); + assert.deepEqual(extractBashBlocks(text), []); + }); +}); + +describe('frontmatter-scalar-broad-grep lint: the live repo is clean', () => { + test('scan() finds zero offenders in the real workflow/agent/command markdown', () => { + const offenders = scan(); + assert.deepEqual( + offenders, + [], + 'un-scoped frontmatter-scalar grep(s) found:\n' + offenders.map((o) => ` ${o.file}:${o.line} ${o.snippet}`).join('\n'), + ); + }); +}); + +describe('frontmatter-scalar-broad-grep lint: main() end-to-end wiring', () => { + test('exit 0 on the real repo tree', () => { + const result = runNode([LINT_SCRIPT], { cwd: ROOT }); + assert.equal(result.exitCode, 0, `expected exit 0, got ${result.exitCode}: ${result.stderr}`); + }); + + test('exit 1 on a fixture reproducing the real defect shape', (t) => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-frontmatter-grep-lint-e2e-')); + t.after(() => cleanup(tmpDir)); + const workflowsDir = path.join(tmpDir, 'gsd-core', 'workflows'); + fs.mkdirSync(workflowsDir, { recursive: true }); + fs.writeFileSync( + path.join(workflowsDir, 'quick.md'), + [ + '# Quick', + '```bash', + 'grep "^status:" "${QUICK_DIR}/${quick_id}-VERIFICATION.md" | cut -d: -f2 | tr -d \' \'', + '```', + ].join('\n'), + ); + const scriptCopyDir = path.join(tmpDir, 'scripts'); + fs.mkdirSync(scriptCopyDir, { recursive: true }); + const scriptCopy = path.join(scriptCopyDir, 'lint-frontmatter-scalar-broad-grep.cjs'); + fs.copyFileSync(LINT_SCRIPT, scriptCopy); + fs.mkdirSync(path.join(scriptCopyDir, 'lib'), { recursive: true }); + fs.copyFileSync(path.join(ROOT, 'scripts', 'lib', 'cli-exit.cjs'), path.join(scriptCopyDir, 'lib', 'cli-exit.cjs')); + + const result = runNode([scriptCopy]); + assert.equal(result.exitCode, 1, `expected exit 1, got ${result.exitCode}`); + assert.match(result.stderr, /FRONTMATTER-SCALAR-BROAD-GREP/); + }); + + test('exit 0 on a fixture that is properly scoped (no false positive)', (t) => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-frontmatter-grep-lint-e2e-clean-')); + t.after(() => cleanup(tmpDir)); + const workflowsDir = path.join(tmpDir, 'gsd-core', 'workflows'); + fs.mkdirSync(workflowsDir, { recursive: true }); + fs.writeFileSync( + path.join(workflowsDir, 'quick.md'), + [ + '# Quick', + '```bash', + 'sed -n \'/^---$/,/^---$/p\' "$f" | grep -m1 "^status:" | cut -d: -f2 | tr -d \' \'', + '```', + ].join('\n'), + ); + const scriptCopyDir = path.join(tmpDir, 'scripts'); + fs.mkdirSync(scriptCopyDir, { recursive: true }); + const scriptCopy = path.join(scriptCopyDir, 'lint-frontmatter-scalar-broad-grep.cjs'); + fs.copyFileSync(LINT_SCRIPT, scriptCopy); + fs.mkdirSync(path.join(scriptCopyDir, 'lib'), { recursive: true }); + fs.copyFileSync(path.join(ROOT, 'scripts', 'lib', 'cli-exit.cjs'), path.join(scriptCopyDir, 'lib', 'cli-exit.cjs')); + + const result = runNode([scriptCopy]); + assert.equal(result.exitCode, 0, `expected exit 0, got ${result.exitCode}: ${result.stderr}`); + }); +}); diff --git a/tests/removed-but-needed-lint.test.cjs b/tests/removed-but-needed-lint.test.cjs new file mode 100644 index 000000000..04e74ae8d --- /dev/null +++ b/tests/removed-but-needed-lint.test.cjs @@ -0,0 +1,229 @@ +'use strict'; +process.env.GSD_TEST_MODE = '1'; + +/** + * Removed-but-needed lint (DEFECT.REMOVED-BUT-NEEDED, CONTEXT.md). + * + * scripts/lint-removed-but-needed.cjs fails a PR that deletes a file while a + * live consumer (a workflow, docs, or package.json) still references it — + * #3316 (root package-lock.json deleted while workflows still used + * `cache: 'npm'` + `npm ci`), e3b52c70 (docs referenced a removed + * `/gsd-new-workspace` workflow). + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const ROOT = path.join(__dirname, '..'); +const LINT_SCRIPT = path.join(ROOT, 'scripts', 'lint-removed-but-needed.cjs'); +const { referencesBasename, referencesNpmLockfileDependency, findSurvivingReferences, scan } = require(LINT_SCRIPT); +const { cleanup } = require('./helpers.cjs'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { gitOrThrow } = require('./helpers/git-fixture.cjs'); + +describe('removed-but-needed lint: referencesBasename (pure)', () => { + test('a plain filename reference in prose is found', () => { + assert.equal(referencesBasename('see docs/gsd-new-workspace.md for details', 'gsd-new-workspace.md'), true); + }); + + test('no reference at all is not found', () => { + assert.equal(referencesBasename('nothing to see here', 'gsd-new-workspace.md'), false); + }); + + test('a coincidental substring inside a different filename is NOT a false match (word-boundary guard)', () => { + assert.equal(referencesBasename('old-config.json.bak lives here', 'config.json'), false); + }); + + test('a path-embedded reference (with separators) IS found', () => { + assert.equal(referencesBasename('run: node scripts/gsd-new-workspace.cjs', 'gsd-new-workspace.cjs'), true); + }); +}); + +describe('removed-but-needed lint: referencesNpmLockfileDependency (pure)', () => { + test('`npm ci` is flagged', () => { + assert.equal(referencesNpmLockfileDependency(' run: npm ci'), true); + }); + + test('`cache: \'npm\'` is flagged', () => { + assert.equal(referencesNpmLockfileDependency(" cache: 'npm'"), true); + }); + + test('an unrelated workflow step is not flagged', () => { + assert.equal(referencesNpmLockfileDependency(' run: npm run build'), false); + }); +}); + +describe('removed-but-needed lint: findSurvivingReferences (pure)', () => { + test('the real #3316 defect shape IS flagged: package-lock.json deleted, workflow still runs npm ci', () => { + const violations = findSurvivingReferences( + ['package-lock.json'], + [{ file: '.github/workflows/ci.yml', content: 'jobs:\n test:\n steps:\n - run: npm ci\n' }], + ); + assert.ok(violations.some((v) => v.deletedFile === 'package-lock.json')); + }); + + test('a deleted workflow still referenced in docs IS flagged (e3b52c70 shape)', () => { + const violations = findSurvivingReferences( + ['gsd-core/workflows/new-workspace.md'], + [{ file: 'docs/getting-started.md', content: 'run /gsd:new-workspace.md to start' }], + ); + assert.equal(violations.length, 1); + assert.equal(violations[0].referencedIn, 'docs/getting-started.md'); + }); + + test('LOOKALIKE: a deleted file with zero surviving references is clean', () => { + const violations = findSurvivingReferences( + ['gsd-core/workflows/retired.md'], + [{ file: 'docs/getting-started.md', content: 'nothing relevant here' }], + ); + assert.deepEqual(violations, []); + }); + + test('LOOKALIKE: a coincidental basename collision with an unrelated live file is not silently skipped, but the word-boundary guard avoids substring noise', () => { + const violations = findSurvivingReferences( + ['old/config.json'], + [{ file: 'docs/setup.md', content: 'we removed archived-config.json.old, unrelated' }], + ); + assert.deepEqual(violations, []); + }); +}); + +describe('removed-but-needed lint: the live repo (against origin/next) is clean', () => { + test('scan() finds zero surviving references for anything deleted since origin/next', () => { + let violations; + try { + violations = scan(ROOT, 'origin/next'); + } catch { + // origin/next unreachable in this environment — nothing to assert. + return; + } + assert.deepEqual( + violations, + [], + 'deleted file(s) still referenced by a live consumer:\n' + + violations.map((v) => ` ${v.deletedFile} -> ${v.referencedIn}: ${v.reason}`).join('\n'), + ); + }); +}); + +/** + * Build a minimal temp git repo shaped like a PR branch, mirroring + * tests/changeset-lint.test.cjs's fixture builder: origin/main = base + * commit, pr = PR branch with caller-supplied file mutations on top. + * @param {string} tmpDir + * @param {Array<{file: string, content: string|null}>} baseFiles + * @param {Array<{file: string, content: string|null}>} prFiles - null content deletes + */ +function buildTempRepo(tmpDir, baseFiles, prFiles) { + const git = (...args) => gitOrThrow(args, { cwd: tmpDir }); + git('init', '-q', '-b', 'main'); + git('config', 'user.email', 'test@example.com'); + git('config', 'user.name', 'Test'); + + for (const { file, content } of baseFiles) { + const abs = path.join(tmpDir, file); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, content); + } + git('add', '-A'); + git('commit', '-q', '-m', 'base'); + git('update-ref', 'refs/remotes/origin/main', 'HEAD'); + + git('checkout', '-q', '-b', 'pr'); + for (const { file, content } of prFiles) { + const abs = path.join(tmpDir, file); + if (content === null) { + try { fs.unlinkSync(abs); } catch { /* already absent */ } + } else { + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, content); + } + } + git('add', '-A'); + git('commit', '-q', '-m', 'pr changes'); + return tmpDir; +} + +/** + * The lint script resolves its scan root from `path.join(__dirname, '..')` + * (matching every other standalone lint script in this repo, e.g. + * lint-canary-version-leak.cjs) — it does NOT use `process.cwd()`. So an + * end-to-end fixture must run a COPY of the script placed inside the fixture + * repo, not the real repo's script, or it would scan the real repo instead + * of the fixture tree. + * @param {string} tmpDir + * @returns {string} path to the copied script inside tmpDir/scripts/ + */ +function copyScriptInto(tmpDir) { + const scriptsDir = path.join(tmpDir, 'scripts'); + const libDir = path.join(scriptsDir, 'lib'); + fs.mkdirSync(libDir, { recursive: true }); + const scriptCopy = path.join(scriptsDir, 'lint-removed-but-needed.cjs'); + fs.copyFileSync(LINT_SCRIPT, scriptCopy); + fs.copyFileSync(path.join(ROOT, 'scripts', 'lib', 'cli-exit.cjs'), path.join(libDir, 'cli-exit.cjs')); + return scriptCopy; +} + +describe('removed-but-needed lint: main() end-to-end wiring', () => { + test('exit 1 in a fixture repo reproducing the real defect shape (package-lock.json deleted, workflow still npm ci)', (t) => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-removed-but-needed-e2e-')); + t.after(() => cleanup(tmpDir)); + buildTempRepo( + tmpDir, + [ + { file: 'package-lock.json', content: '{}' }, + { file: '.github/workflows/ci.yml', content: 'jobs:\n test:\n steps:\n - run: npm ci\n' }, + ], + [{ file: 'package-lock.json', content: null }], + ); + const scriptCopy = copyScriptInto(tmpDir); + const result = runNode( + [scriptCopy], + { cwd: tmpDir, env: { ...process.env, GSD_REMOVED_BUT_NEEDED_BASE: 'main' } }, + ); + assert.equal(result.exitCode, 1, `expected exit 1, got ${result.exitCode}: ${result.stderr}`); + assert.match(result.stderr, /REMOVED-BUT-NEEDED/); + }); + + test('exit 0 in a fixture repo where the deletion is clean (no surviving reference, workflow updated too)', (t) => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-removed-but-needed-e2e-clean-')); + t.after(() => cleanup(tmpDir)); + buildTempRepo( + tmpDir, + [ + { file: 'package-lock.json', content: '{}' }, + { file: '.github/workflows/ci.yml', content: 'jobs:\n test:\n steps:\n - run: npm install\n' }, + ], + [{ file: 'package-lock.json', content: null }], + ); + const scriptCopy = copyScriptInto(tmpDir); + const result = runNode( + [scriptCopy], + { cwd: tmpDir, env: { ...process.env, GSD_REMOVED_BUT_NEEDED_BASE: 'main' } }, + ); + assert.equal(result.exitCode, 0, `expected exit 0, got ${result.exitCode}: ${result.stderr}`); + }); + + test('gracefully skips (exit 0) when the base ref cannot be resolved', (t) => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-removed-but-needed-e2e-noref-')); + t.after(() => cleanup(tmpDir)); + const git = (...args) => gitOrThrow(args, { cwd: tmpDir }); + git('init', '-q', '-b', 'main'); + git('config', 'user.email', 'test@example.com'); + git('config', 'user.name', 'Test'); + fs.writeFileSync(path.join(tmpDir, 'README.md'), '# x\n'); + git('add', '-A'); + git('commit', '-q', '-m', 'only commit'); + const scriptCopy = copyScriptInto(tmpDir); + + const result = runNode( + [scriptCopy], + { cwd: tmpDir, env: { ...process.env, GSD_REMOVED_BUT_NEEDED_BASE: 'nonexistent-branch' } }, + ); + assert.equal(result.exitCode, 0, `expected graceful skip (exit 0), got ${result.exitCode}: ${result.stderr}`); + assert.match(result.stdout, /skipping/); + }); +});