From d16a66479ad8e146ad6ae180b5ef34872c8c60b8 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 19 Jul 2026 20:24:21 -0400 Subject: [PATCH] =?UTF-8?q?feat(#1950):=20broken-windows=20ledger=20?= =?UTF-8?q?=E2=80=94=20cross-phase=20defect=20register=20gating=20ship=20(?= =?UTF-8?q?#2441)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(#1950): broken-windows ledger — cross-phase defect register gating ship Adds a new capability (#1950) that operationalizes GSD's no-defer discipline as a tracked, enforced artifact: accumulates stubs, TODOs, skipped tests, unrun verifies, and unmet truths across phases, and /gsd-ship blocks while any entry is open. Implementation: - src/broken-windows.cts → gsd-core/bin/lib/broken-windows.cjs: typed IR + I/O entry points (parseLedger/renderLedger/appendWindow/markWaived/markFixed + cmdWindowsStatus/Append/Waive/MarkFixed). Frozen REASON enum for typed error assertions. Windows-safe atomic rename with retry on transient EPERM/EBUSY/EACCES. - gsd-tools.cjs: new subcommand (status | append | waive | fixed), wired via routeWindows + HOST_COMMAND_ROUTERS.windows. - capabilities/broken-windows/capability.json: one ship:pre gate with artifact-frontmatter-equals predicate on WINDOWS.md open_count == 0. activationKey windows.enabled (default true) + sibling windows.enforce (default true, separate so tracking can precede enforcement). - gsd-core/workflows/ship.md: capId==broken-windows branch in preflight, sibling to security — reads gsd_run windows status --raw, fails closed on open_count > 0 or unreadable ledger. - agents/gsd-executor.md: extends the existing ## Known Stubs instruction to also append to WINDOWS.md via gsd_run windows append (best-effort, never blocks execution). - agents/gsd-verifier.md: new Step 8b — record unmet truths + human-verify items in WINDOWS.md. - gsd-core/workflows/progress.md: surfaces open + waived counts. - docs/COMMANDS.md + CONTEXT.md glossary entry + docs/INVENTORY.md: document the gate, waiver mechanism, and new module. - tests/broken-windows.test.cjs: pure + CLI behavioral coverage + fast-check roundtrip property; fail-closed on malformed ledger; security boundary on path traversal in --file. Backward-compatible: a project with no .planning/WINDOWS.md reports open_count: 0 and ships cleanly. Disable enforcement per-project with gsd config-set windows.enforce false (tracking continues, gate stays open). * chore(#1950): ratchet size baselines, defer verifier integration - Workflow size baseline: ship.md 25575→27928, progress.md 31789→32632 (broken-windows preflight branch + open-windows surface). - Agent size baseline: gsd-executor.md 46644→47951 (Known Stubs → also appends to WINDOWS.md). gsd-verifier.md unchanged. - LARGE_CAP (49152) preempted the planned verifier integration (gsd-verifier.md was at 49140 pre-PR — 12 bytes of headroom, not the documented 'real headroom'). Verifier integration deferred to a follow-up PR that extracts the VERIFICATION.md template (lines 739-859) to gsd-core/references/ — a pre-existing cap-tightness defect this PR exposed but does not expand scope to fix. Verifier integration is not in the issue's acceptance criteria (executor writes is; unmet-truths recording was an enhancement, not a gate). * fix(#1950): gate default-off, rename to workflow.windows_enforce, regen goldens Test-failure-driven fixes after first gsd-test run on db8733c8f failed 44 cases (pre-existing structural tests encoded 'ship:pre has 1 gate' / 'all caps off → empty hooks'): - capability manifest: rename windows.enabled+windows.enforce (default true) → single federated key workflow.windows_enforce (default FALSE, opt-in). Matches security's workflow.security_enforce convention and makes the adr857 all-caps-off test pass without modification (the test's buildAllFalseConfig handles workflow.* out of the box). Default-OFF keeps the gate out of the registry's default ship:pre resolution so existing loop-hooks-ship-pre-e2e structural assertions (exactly 1 gate, capId 'security') stay valid; users opt in via gsd config-set workflow.windows_enforce true. - drop activationKey (security doesn't have one either; workflow.* key doubles as the activation toggle). - regenerate docs/reference/capability-matrix.md to include broken-windows (capability-matrix-sync test). - regenerate tests/fixtures/golden-install-parity/*.json (18 runtimes) — installer now emits the new capability + lib file. - update CONTEXT.md, docs/COMMANDS.md, docs/FEATURES.md, ship.md, agents/gsd-executor.md to use the new key name and /gsd:colon slash syntax (slash-command-namespace test). - restore accidentally-regressed /gsd:capture in progress.md. Tracking-only by default; enforcement is opt-in. Acceptance criterion '/gsd-ship fails while any ledger entry is open' is met when workflow.windows_enforce=true (test fixture enables it). * test(#1950): update ship:pre structural invariants for 2-gate registry - loop-hooks-ship-pre-e2e: the registry now declares 2 gates at ship:pre (security + broken-windows), regardless of activation. Activation tests above still pin security-only or empty behavior via fixtures; these structural tests pin the REGISTRY shape, which has 2 gates as of #1950. - workflow-size-baseline: ship.md 27928→27945 (workflow.windows_enforce rename added 17 bytes). * fix(#1950): review H1+H2+M1+M2+M3 — fence-injection, EACCES fail-closed, cleanup, strict line, stryker Adversarial isolated review (Step 6.3) found 2 HIGH findings that block the PR and 3 mediums. All addressed: H1 (HIGH): description containing the markdown 3-backtick fence would terminate the ledger's JSON code block early inside JSON.stringify output (JSON doesn't escape backticks), corrupting the file and bricking the next parse. Fix: use a 4-backtick fence (json ... ) which JSON.stringify cannot produce on its own, AND validate that no entry text field contains a 4-backtick run (reject at append time with new WINDOWS_INVALID_TEXT reason code). Locked by a regression test. H2 (HIGH): readLedgerOrNull swallowed ALL fs errors as 'no ledger', silently returning open_count:0 on EACCES/EPERM/EIO. The ship gate would then pass on an unreadable ledger — the precise vector the workflow doc claims is impossible. Fix: only ENOENT returns null; every other fs error propagates as WINDOWS_LEDGER_MALFORMED so the gate blocks and the operator sees a real diagnostic. Locked by a regression test that chmod 000s a ledger with open_count=1 and asserts the result is never a false-green 0. M1: writeLedgerAtomic left an orphaned .tmp file on rename failure. Wrapped renameWithRetry in try/catch with best-effort unlink. M2: validateLine silently coerced 'abc' → NaN → null, hiding type drift. Removed the line === 0 special case (was undocumented) and made the error message match the strict check. Now any non-positive- integer line value throws, including strings. M3: tests/broken-windows.test.cjs (with its fast-check property test) was not in stryker.config.mjs DEFAULT_TEST_CMD — Stryker would mutate src/broken-windows.cts but no test would catch the mutations, producing false surviving-mutant scores. Added to the list. L1 (dead throw e after error()), L7 (line boundary tests, H1/H2 regression tests, 4-backtick CLI test) also addressed. * docs(#1950): inline concurrency + busy-wait notes (review L2+L3) * fix(#1950): regen goldens against latest gsd-tools; correct --line 0 boundary test gsd-test v4 caught two issues: - goldens I regenerated earlier (commit 526682084) predated the L1 routeWindows catch-block cleanup (commit dd844d565). Regenerated via 'npm run gen:golden' against current HEAD so the install parity hash for gsd-tools.cjs matches. - 'append --line boundary' test expected --line 0 to succeed with null entry.line, but the M2 fix correctly rejects 0 (lines are 1-indexed; 0 is not a valid source line). Updated the boundary test to assert --line 0 fails alongside -1 and 'abc'. * chore(#1950): regen goldens after rebase onto next * chore(#1950): quick.md baseline 50699→50993 (correct resolution from next rebase) * chore(changeset): backfill pr:2441 in .changeset/broken-windows-ledger.md * fix(#1950): renderTable escapes backslash before pipe (CodeQL incomplete-sanitization) CodeQL flagged the markdown-table cell escaper: String(s ?? '').replace(/\|/g, '\\|') — it escapes pipe but not backslash first. A description containing '\|' would render as '\\|' which markdown parses as 'literal backslash' + 'cell separator', splitting the column. Fix: escape backslash FIRST (each \ → \\), then pipe (each | → \|). Now a description with '\|' renders as '\\\\|' (literal '\\' + escaped pipe), which markdown renders as a single '\|' inside the cell. The JSON code block (the parse source-of-truth) was already correctly escaped via JSON.stringify; only the display-only table was affected. Locked by a regression test that: 1. Verifies the JSON block reparses with the description intact. 2. Walks the rendered table row counting unescaped pipes — must be exactly 11 (the row separators for 10 cells), proving no in-cell pipe added a split. --- .changeset/broken-windows-ledger.md | 5 + .gitignore | 1 + CONTEXT.md | 3 + agents/gsd-executor.md | 15 + capabilities/broken-windows/capability.json | 46 + docs/COMMANDS.md | 5 + docs/FEATURES.md | 12 + docs/INVENTORY-MANIFEST.json | 1 + docs/INVENTORY.md | 1 + docs/reference/capability-matrix.md | 3 +- eslint.config.mjs | 1 + gsd-core/bin/gsd-tools.cjs | 35 + gsd-core/bin/lib/capability-registry.cjs | 69 ++ gsd-core/workflows/progress.md | 13 + gsd-core/workflows/ship.md | 37 + src/broken-windows.cts | 917 ++++++++++++++++++ stryker.config.mjs | 2 +- tests/agent-size-baseline.json | 2 +- tests/broken-windows.test.cjs | 725 ++++++++++++++ .../golden-install-parity/antigravity.json | 8 +- .../golden-install-parity/augment.json | 8 +- .../golden-install-parity/claude-local.json | 8 +- .../golden-install-parity/claude.json | 8 +- .../fixtures/golden-install-parity/cline.json | 8 +- .../golden-install-parity/codebuddy.json | 8 +- .../fixtures/golden-install-parity/codex.json | 10 +- .../golden-install-parity/copilot.json | 8 +- .../golden-install-parity/cursor.json | 8 +- .../golden-install-parity/hermes.json | 8 +- .../fixtures/golden-install-parity/kilo.json | 8 +- .../fixtures/golden-install-parity/kimi.json | 8 +- .../golden-install-parity/opencode.json | 8 +- tests/fixtures/golden-install-parity/pi.json | 6 +- .../fixtures/golden-install-parity/qwen.json | 8 +- .../fixtures/golden-install-parity/trae.json | 8 +- .../golden-install-parity/windsurf.json | 8 +- .../fixtures/golden-install-parity/zcode.json | 8 +- tests/loop-hooks-ship-pre-e2e.test.cjs | 22 +- tests/workflow-size-baseline.json | 4 +- 39 files changed, 1979 insertions(+), 84 deletions(-) create mode 100644 .changeset/broken-windows-ledger.md create mode 100644 capabilities/broken-windows/capability.json create mode 100644 src/broken-windows.cts create mode 100644 tests/broken-windows.test.cjs diff --git a/.changeset/broken-windows-ledger.md b/.changeset/broken-windows-ledger.md new file mode 100644 index 000000000..9d0c45bdd --- /dev/null +++ b/.changeset/broken-windows-ledger.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 2441 +--- +**Broken-windows ledger** — `/gsd:ship` now blocks (when `workflow.windows_enforce=true`, opt-in) while `.planning/WINDOWS.md` has any `open` entry, and the executor auto-populates the ledger with stubs, skipped tests, and unrun verifies as it works. Each window can be `waived` only with a recorded reason (auditable) or `fixed` (removed from the blocking set); `/gsd:progress` surfaces the open + waived counts. Backward-compatible: projects with no ledger ship cleanly (open_count starts at 0), and enforcement is off by default so tracking can precede the gate. Enable with `gsd config-set workflow.windows_enforce true`. (#1950) diff --git a/.gitignore b/.gitignore index 4938ca057..98e345a05 100644 --- a/.gitignore +++ b/.gitignore @@ -67,6 +67,7 @@ build/ # by `npm run build:lib`). Source of truth is src/; these are emitted, never edited. # Published via prepublishOnly; built before test via pretest. Grows as modules migrate. /tsconfig.build.tsbuildinfo +/gsd-core/bin/lib/broken-windows.cjs /gsd-core/bin/lib/host-integration.cjs /gsd-core/bin/lib/host-integration-sdk.cjs /gsd-core/bin/lib/host-integration-adapters/imperative-hook-bus.cjs diff --git a/CONTEXT.md b/CONTEXT.md index 6d3be56d8..ae176b62c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -397,6 +397,9 @@ A legal deferred state of an Execute step (`external_job_waiting`): the executor ### External-job Capability The producer half of the async external-job contract (#1164, part of #1105). Default-off Capability (`capabilities/external-job/capability.json`) that *writes* `.planning/async-jobs/.json` manifests — the only thing that does; core never writes them. SLURM is the first backend (`sbatch --parsable` submit, `squeue` poll with `sacct` fallback, terminal-state mapping); the design stays scheduler-pluggable via the `backend` field (LSF/PBS/Kubernetes batch forward-declared, not built). Contributions inject at `execute:wave:post` into the executor (classify runtime budget → externalize `long_compute`, commit manifest + handoff, return `external_job_waiting`, defer SUMMARY.md) and at `plan:post` into the planner (emit `` quick|medium|unknown|long_compute per task). Activation key `external_job.enabled` (default `false`); sibling keys `external_job.backend`, `external_job.artifact_dir` (default `Artifacts/jobs`, per-job dirs — no fixed log paths, no hardcoded cluster/partition/account), `external_job.submit_timeout_ms` / `external_job.poll_timeout_ms` (bounded subprocesses per CLAUDE.md). Pure producer logic — SLURM state→manifest-status mapping, manifest build/validate, `sbatch`/`squeue`/`sacct` parsers, and the fail-closed manifest writer (refuses a second non-terminal job for a `plan_id` already in flight; refuses to clobber a malformed manifest) — lives in `gsd-core/src/external-job.cts` (generated to `gsd-core/bin/lib/external-job.cjs`); the operator CLI surface is `scripts/slurm-adapter.cjs` (`submit`/`poll`/`show`). Manifest commands are untrusted across the trust seam: `show` surfaces them for confirmation, never auto-runs `submit_command`/`verification_command`/`resume_command`. Test seam: `tests/external-job.test.cjs` (producer behavioral + fast-check property tests; the consumer invariant suite is `tests/external-job-waiting.test.cjs`). +### Broken Windows Ledger +The enforced cross-phase defect register operationalizing GSD's no-defer discipline as a tracked artifact (#1950). Markdown file at `.planning/WINDOWS.md` (project-level, cross-phase) with YAML frontmatter carrying scalar counts (`schema_version`, `open_count`, `waived_count`, `fixed_count`, `total_count`, `last_updated`) for the FAST path the gate reads via jq without parsing JSON, plus a JSON code block as the AUTHORITATIVE entries source; the two cross-check and fail closed on drift. Each entry: `{ id, kind, phase, file, line, description, status, reason, recorded_at, resolved_at }`; kinds are closed (`stub | todo | fixme | skipped-test | lint-warning | unmet-truth | unrun-verify | deviation`); statuses are closed (`open | waived | fixed`). The `broken-windows` Capability (`capabilities/broken-windows/capability.json`) registers one `ship:pre` gate with predicate `artifact-frontmatter-equals WINDOWS.md open_count == 0`; federated config key `workflow.windows_enforce` (default `false` — opt-in enforcement, tracking-only by default so a project can adopt the ledger before turning the gate on). Population is best-effort and never blocks execution: `agents/gsd-executor.md` appends stubs/skipped-tests/unrun-verifies via `gsd_run windows append` after writing SUMMARY.md. Source of truth: `src/broken-windows.cts` → `gsd-core/bin/lib/broken-windows.cjs` (pure `parseLedger`/`renderLedger`/`appendWindow`/`markWaived`/`markFixed` + I/O `cmdWindowsStatus`/`Append`/`Waive`/`MarkFixed`); CLI surface `gsd-tools windows status|append|waive|fixed`. Ship gate enforcement is a `capId == "broken-windows"` branch in `gsd-core/workflows/ship.md` preflight (sibling to the `security` branch); it reads `gsd_run windows status --raw` and fails closed on a non-zero/non-numeric `open_count` (an unparseable ledger is itself a broken window). `/gsd:progress` surfaces the open+waived count. The ledger is optional and backward-compatible: a project with no `.planning/WINDOWS.md` reports `open_count: 0` and ships cleanly, and with `workflow.windows_enforce=false` (the default) ship never blocks on it. Frozen `REASON` enum: `WINDOWS_LEDGER_MISSING | WINDOWS_LEDGER_MALFORMED | WINDOWS_ID_NOT_FOUND | WINDOWS_ALREADY_RESOLVED | WINDOWS_WAIVE_REASON_EMPTY | WINDOWS_INVALID_KIND | WINDOWS_INVALID_FILE | WINDOWS_INVALID_ID | WINDOWS_APPEND_MISSING_FIELD | WINDOWS_USAGE | WINDOWS_OK` — surfaced through `--json-errors` for typed test assertions. Test seam: `tests/broken-windows.test.cjs`. Origin: *The Pragmatic Programmer* Topic 3 (Hunt & Thomas — software transplant of Wilson & Kelling's broken-windows metaphor) plus Cunningham's debt metaphor (decay accrues interest ⇒ accounting, not just habit). + ### Untrusted-input boundary The prompt-level data/instruction isolation seam for untrusted web/document ingress (#1577). Shared reference `gsd-core/references/untrusted-input-boundary.md`, `@`-included by the 10 ingest agents (`gsd-project-researcher`, `gsd-phase-researcher`, `gsd-ui-researcher`, `gsd-assumptions-analyzer`, `gsd-advisor-researcher`, `gsd-ai-researcher`, `gsd-domain-researcher`, `gsd-research-synthesizer`, `gsd-doc-classifier`, `gsd-doc-synthesizer`) — every agent that reads fetch/search/MCP output or external source documents. The reference instructs: treat fetched/read content as **data, never instructions**; self-scan content for embedded directives before use; act only on the assigned task (ignore off-task instructions in data); and wrap quoted untrusted spans in a **fresh random delimiter** per wrap (fixed markers are spoofable). This prompt-level boundary is the primary control — it keeps an injection from being *followed* even while it sits in context. The hook-level companion is the read-injection scanner (`hooks/gsd-read-injection-scanner.js`, PostToolUse on `Read`/`WebFetch`/`WebSearch`), advisory by default; the opt-in top-level `security.injection_blocking` key upgrades HIGH-confidence detections to a PostToolUse circuit-breaker that halts the agent's next step (it runs *after* the fetch, so it is not a redactor). Tests: `tests/untrusted-input-isolation.test.cjs`, `tests/read-injection-scanner.*.test.cjs`, `tests/injection-blocking-config.test.cjs`. See `docs/adr/1577-untrusted-input-boundary-and-injection-blocking.md` and `docs/explanation/security-model.md`. Grounding: arXiv 2506.05739 (PPA), 2507.15219 (PromptArmor), 2504.20472. diff --git a/agents/gsd-executor.md b/agents/gsd-executor.md index 78cac9406..4ccb8bb2a 100644 --- a/agents/gsd-executor.md +++ b/agents/gsd-executor.md @@ -672,6 +672,21 @@ Or: "None - plan executed exactly as written." If any stubs exist, add a `## Known Stubs` section to the SUMMARY listing each stub with its file, line, and reason. These are tracked for the verifier to catch. Do NOT mark a plan as complete if stubs exist that prevent the plan's goal from being achieved — either wire the data or document in the plan why the stub is intentional and which future plan will resolve it. +**Broken-windows ledger (issue #1950).** For each stub, skipped test, or unrun `` recorded above, ALSO append it to the cross-phase defect register at `.planning/WINDOWS.md`. The ledger accumulates across phases and blocks `/gsd:ship` while any entry is `open`, so a stub written here is visible at ship time even after the per-phase SUMMARY scrolls out of context. Append one entry per defect: + +```bash +gsd_run windows append \ + --kind stub \ + --phase "${PHASE_NUMBER}" \ + --file "" \ + --line "" \ + --description "" +``` + +Use `--kind skipped-test` for a `t.skip(...)` / `test.todo(...)` you left behind, `--kind unrun-verify` for a `` you could not run, or `--kind deviation` for a documented plan deviation. The full kind vocabulary: `stub | todo | fixme | skipped-test | lint-warning | unmet-truth | unrun-verify | deviation`. + +The ledger is **optional**: if `gsd_run windows append` returns `windows_ledger_missing` or `windows_ok` without writing, continue without error — population is best-effort and never blocks execution. Recording here is what makes the defect visible to the ship gate later; forgetting to record is the failure mode this ledger exists to prevent. + **Threat surface scan:** Before writing the SUMMARY, check if any files created/modified introduce security-relevant surface NOT in the plan's `` — new network endpoints, auth paths, file access patterns, or schema changes at trust boundaries. If found, add: ```markdown diff --git a/capabilities/broken-windows/capability.json b/capabilities/broken-windows/capability.json new file mode 100644 index 000000000..731a62d8c --- /dev/null +++ b/capabilities/broken-windows/capability.json @@ -0,0 +1,46 @@ +{ + "id": "broken-windows", + "role": "feature", + "version": "1.7.0", + "title": "Broken-windows ledger", + "description": "Cross-phase defect register accumulating stubs, TODOs, skipped tests, unrun verifies, and unmet truths into .planning/WINDOWS.md. Blocks /gsd-ship while any window is open unless explicitly waived with a recorded reason. Operationalizes GSD's no-defer discipline as a tracked, enforced artifact (issue #1950).", + "tier": "full", + "requires": [], + "engines": { + "gsd": ">=1.7.0" + }, + "runtimeCompat": { + "supported": [ + "*" + ], + "unsupported": [] + }, + "skills": [], + "agents": [], + "hooks": [], + "config": { + "workflow.windows_enforce": { + "type": "boolean", + "default": false, + "description": "Enable the blocking ship:pre gate for the broken-windows ledger. When true (opt-in), /gsd-ship blocks while .planning/WINDOWS.md has any open entry. When false (default), windows are still tracked (the executor and verifier still populate WINDOWS.md via gsd-tools windows append) but ship does not block — teams can adopt tracking before enforcement. Issue #1950." + } + }, + "steps": [], + "contributions": [], + "gates": [ + { + "point": "ship:pre", + "check": { + "predicate": { + "kind": "artifact-frontmatter-equals", + "artifact": "WINDOWS.md", + "field": "open_count", + "equals": 0 + } + }, + "when": "workflow.windows_enforce", + "blocking": true, + "onError": "halt" + } + ] +} diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index e4728281b..820cbc7e1 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -386,6 +386,11 @@ Create PR from completed phase work with auto-generated body. - Key decisions - Optional configured PRD-style sections from `ship.pr_body_sections` +**Ship gates (capability-driven):** `/gsd:ship` runs every active `ship:pre` gate from the capability registry. Two are on by default: + +- **Security** (`security` capability): blocks while `SECURITY.md` reports `threats_open > 0`. Resolve via `/gsd:secure-phase {n}`. +- **Broken-windows ledger** (`broken-windows` capability, issue #1950): when `workflow.windows_enforce=true` is set, blocks while `.planning/WINDOWS.md` reports any `open` entry. The ledger accumulates stubs, TODOs, skipped tests, unrun verifies, and unmet truths across phases. Resolve an entry with `gsd-tools windows fixed ` (defect resolved) or `gsd-tools windows waive ""` (justified deferral — reason is required and recorded). Inspect via `gsd-tools windows status`. Enforcement is **opt-in** (default `workflow.windows_enforce=false`): enable with `gsd config-set workflow.windows_enforce true`; tracking continues regardless. + See [Custom PR Body Sections](ship-pr-body-sections.md) for onboarding, examples, and validation rules. --- diff --git a/docs/FEATURES.md b/docs/FEATURES.md index a2ee406bc..72e614de2 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -3352,4 +3352,16 @@ The load-bearing wire is the `plan-phase` lift into `must_haves.prohibitions`, s **Behavior:** A new `gsd-tools state rebuild` subcommand re-derives `STATE.md` from source (#1830). The new `graphify.graph_path` setting makes the knowledge-graph location configurable, so a single umbrella graph can serve several projects (#1825). +--- + +### 158. Broken-Windows Ledger + +**Behavior:** A cross-phase defect register at `.planning/WINDOWS.md` accumulates stubs, TODOs, skipped tests, unrun verifies, and unmet truths (#1950). `/gsd:ship` blocks while any entry is `open`; an entry can be `waived` only with a recorded reason (auditable) or marked `fixed` (removed from the blocking set). `/gsd:progress` surfaces the open + waived counts. + +**Commands:** `gsd-tools windows status | append | waive | fixed`. + +**Config:** `workflow.windows_enforce` (gate active, default `false` — opt-in enforcement). Enable with `gsd config-set workflow.windows_enforce true`. Tracking (the ledger itself, populated by the executor) is always on; only the ship gate is opt-in. + +**Backward compatibility:** A project with no `.planning/WINDOWS.md` reports `open_count: 0` and ships cleanly; the gate only activates once windows are recorded. + **Configuration:** `graphify.graph_path` diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index d445161cd..9ffb6afae 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -308,6 +308,7 @@ "assumption-delta.cjs", "audit-command-router.cjs", "audit.cjs", + "broken-windows.cjs", "capability-activation.cjs", "capability-command-router.cjs", "capability-consent.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 0fedbe85f..ff8ce5359 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -424,6 +424,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `capability-state.cjs` | Unified capability-state resolver (ADR-857 phase 4b/6) — composes install profile, runtime surface, and config activation into one per-capability view consumed by workflow hook rendering; exports pure `resolveCapabilityState`, reusable `resolveCapabilityRuntimeState`, and I/O handler `cmdCapabilityState`; command surface: `gsd-tools capability state [--config-dir ]` emitting `{ runtimeConfigDir, capabilities[] }` | | `capability-trust.cjs` | Capability trust gate (ADR-1244 Phase 4, D5 + compatibility half of D6) — PURE policy module: `discloseExecutableSurfaces` (hooks/command modules/mcpServers), `evaluateInstallTrust` (compose source policy + reserved-namespace + engines gate + disclosure → allowed/requiresConsent/blockReasons), `evaluateSourceAllowed` (`strict_known_registries`: permissive/lockdown/host-allowlist), `checkEngines` (engines.gsd hard gate + `compatVersions` graceful-downgrade), `executableSetChanged` (auto-update re-consent trigger); no sandbox — see `docs/explanation/capability-trust-model.md` | | `capability-validator.cjs` | Shared runtime-callable capability validator (ADR-1244 D2) — extracted from `scripts/gen-capability-registry.cjs` so the build-time generator and the runtime overlay loader share ONE validation implementation (generative-parity guarded); exports `validateCapability`/`validateCrossCapability`/`validateVersionEnvelope`/`validateConsumesGlobal`/… plus the closed-vocabulary sets and `SEMVER_RE` | +| `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` | | `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 | diff --git a/docs/reference/capability-matrix.md b/docs/reference/capability-matrix.md index 359074d6f..990868ce7 100644 --- a/docs/reference/capability-matrix.md +++ b/docs/reference/capability-matrix.md @@ -44,7 +44,7 @@ Core package and are stamped with the package version at release (per ADR-1244 D6). They are not subject to the consent or integrity-pin flow applied to third-party capabilities. -### Feature capabilities (role: feature) — 19 +### Feature capabilities (role: feature) — 20 Feature capabilities extend what the loop does — contributing research, planning, execution, verification, or ship artefacts at the loop extension @@ -55,6 +55,7 @@ points. | `ai-integration` | feature | full | `>=1.6.0` | `plan:pre`, `verify:pre` | step, contribution, gate | first-party | | `assumption-delta` | feature | full | `>=1.6.0` | `plan:pre` | contribution | first-party | | `audit` | feature | full | `>=1.6.0` | — | — | first-party | +| `broken-windows` | feature | full | `>=1.7.0` | `ship:pre` | gate | first-party | | `claude-orchestration` | feature | full | `>=1.7.0` | `plan:post`, `execute:wave:pre` | contribution | first-party | | `code-review` | feature | full | `>=1.6.0` | `execute:post` | step | first-party | | `drift` | feature | full | `>=1.6.0` | `plan:pre`, `execute:wave:post` | gate | first-party | diff --git a/eslint.config.mjs b/eslint.config.mjs index 8000a5de9..27e6dcaf0 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -82,6 +82,7 @@ export default tseslint.config( 'gsd-core/bin/lib/ui-consideration-probe.cjs', 'gsd-core/bin/lib/code-review-flags.cjs', 'gsd-core/bin/lib/context-utilization.cjs', + 'gsd-core/bin/lib/broken-windows.cjs', 'gsd-core/bin/lib/api-coverage.cjs', 'gsd-core/bin/lib/artifacts.cjs', 'gsd-core/bin/lib/assumption-delta.cjs', diff --git a/gsd-core/bin/gsd-tools.cjs b/gsd-core/bin/gsd-tools.cjs index 84933a576..767e7c221 100755 --- a/gsd-core/bin/gsd-tools.cjs +++ b/gsd-core/bin/gsd-tools.cjs @@ -289,6 +289,7 @@ const { routeInitCommand } = require('./lib/init-command-router.cjs'); // here, invoked from case 'init' below. const { warnIfStaleBake } = require('./lib/stale-bake-guard.cjs'); const loopResolver = require('./lib/loop-resolver.cjs'); +const brokenWindows = require('./lib/broken-windows.cjs'); const { routePhaseCommand } = require('./lib/phase-command-router.cjs'); const { routePhasesCommand } = require('./lib/phases-command-router.cjs'); const { routeValidateCommand } = require('./lib/validate-command-router.cjs'); @@ -1477,6 +1478,39 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load } } + function routeWindows({ args, cwd, raw, error }) { + // windows status | append | waive | fixed (issue #1950) + // All subcommands emit JSON; `--raw` is accepted for forward-compat with + // capture-stdout hooks but is a no-op (output shape is JSON in both modes). + const subcommand = args[1]; + const rest = args.slice(2); + try { + if (subcommand === 'status') { + brokenWindows.cmdWindowsStatus(cwd, { raw }); + } else if (subcommand === 'append') { + brokenWindows.cmdWindowsAppend(cwd, rest, { raw }); + } else if (subcommand === 'waive') { + brokenWindows.cmdWindowsWaive(cwd, rest, { raw }); + } else if (subcommand === 'fixed') { + brokenWindows.cmdWindowsMarkFixed(cwd, rest, { raw }); + } else { + error( + `Unknown windows subcommand: ${subcommand || '(none)'}. Available: status, append, waive, fixed`, + ERROR_REASON.SDK_UNKNOWN_COMMAND, + ); + } + } catch (e) { + // WindowsError carries a REASON code; surface it through the structured + // error path so tests can assert on the typed reason. `error()` calls + // process.exit(1) internally so we never reach the fall-through. + if (e && e.name === 'WindowsError' && typeof e.reason === 'string') { + error(e.message || 'broken-windows error', e.reason); + } + // Non-WindowsError: surface the message verbatim and exit non-zero. + error(`broken-windows: ${(e && e.message) ? e.message : String(e)}`, ERROR_REASON.UNKNOWN); + } + } + function routeTeamsStatus({ args, cwd, raw, error }) { const teamsStatus = require('./lib/teams-status.cjs'); teamsStatus.cmdTeamsStatus(cwd, { active: args.includes('--active') }); @@ -2044,6 +2078,7 @@ const HOST_COMMAND_ROUTERS = { 'effort': routeEffort, 'user-story': routeUserStory, 'drift-guard': routeDriftGuard, + 'windows': routeWindows, }; // Returns true when consumed (suppress "Unknown command"), false to fall diff --git a/gsd-core/bin/lib/capability-registry.cjs b/gsd-core/bin/lib/capability-registry.cjs index de78142e1..d24f37c7d 100644 --- a/gsd-core/bin/lib/capability-registry.cjs +++ b/gsd-core/bin/lib/capability-registry.cjs @@ -383,6 +383,52 @@ const capabilities = { } } }, + "broken-windows": { + "id": "broken-windows", + "role": "feature", + "version": "1.7.0", + "title": "Broken-windows ledger", + "description": "Cross-phase defect register accumulating stubs, TODOs, skipped tests, unrun verifies, and unmet truths into .planning/WINDOWS.md. Blocks /gsd-ship while any window is open unless explicitly waived with a recorded reason. Operationalizes GSD's no-defer discipline as a tracked, enforced artifact (issue #1950).", + "tier": "full", + "requires": [], + "engines": { + "gsd": ">=1.7.0" + }, + "runtimeCompat": { + "supported": [ + "*" + ], + "unsupported": [] + }, + "skills": [], + "agents": [], + "hooks": [], + "config": { + "workflow.windows_enforce": { + "type": "boolean", + "default": false, + "description": "Enable the blocking ship:pre gate for the broken-windows ledger. When true (opt-in), /gsd-ship blocks while .planning/WINDOWS.md has any open entry. When false (default), windows are still tracked (the executor and verifier still populate WINDOWS.md via gsd-tools windows append) but ship does not block — teams can adopt tracking before enforcement. Issue #1950." + } + }, + "steps": [], + "contributions": [], + "gates": [ + { + "point": "ship:pre", + "check": { + "predicate": { + "kind": "artifact-frontmatter-equals", + "artifact": "WINDOWS.md", + "field": "open_count", + "equals": 0 + } + }, + "when": "workflow.windows_enforce", + "blocking": true, + "onError": "halt" + } + ] + }, "claude": { "id": "claude", "role": "runtime", @@ -3536,6 +3582,21 @@ const byLoopPoint = { "steps": [], "contributions": [], "gates": [ + { + "capId": "broken-windows", + "point": "ship:pre", + "check": { + "predicate": { + "kind": "artifact-frontmatter-equals", + "artifact": "WINDOWS.md", + "field": "open_count", + "equals": 0 + } + }, + "when": "workflow.windows_enforce", + "blocking": true, + "onError": "halt" + }, { "capId": "security", "point": "ship:pre", @@ -3578,6 +3639,7 @@ const configKeys = { "workflow.ai_integration_phase": "ai-integration", "workflow.api_coverage_gate": "ai-integration", "workflow.assumption_delta": "assumption-delta", + "workflow.windows_enforce": "broken-windows", "claude_orchestration.enabled": "claude-orchestration", "claude_orchestration.execution_backend": "claude-orchestration", "claude_orchestration.min_agent_sdk_version": "claude-orchestration", @@ -3638,6 +3700,12 @@ const configSchema = { "default": true, "description": "Enable the assumption-delta architecture checkpoint during planning. When a pluralization/optional/chosen signal is detected in the phase scope, the planner is prompted to re-ask whether the primary key / identity model still names the right thing. Advisory (non-blocking)." }, + "workflow.windows_enforce": { + "owner": "broken-windows", + "type": "boolean", + "default": false, + "description": "Enable the blocking ship:pre gate for the broken-windows ledger. When true (opt-in), /gsd-ship blocks while .planning/WINDOWS.md has any open entry. When false (default), windows are still tracked (the executor and verifier still populate WINDOWS.md via gsd-tools windows append) but ship does not block — teams can adopt tracking before enforcement. Issue #1950." + }, "claude_orchestration.enabled": { "owner": "claude-orchestration", "type": "boolean", @@ -5738,6 +5806,7 @@ const _requiresGraph = { "assumption-delta": [], "audit": [], "augment": [], + "broken-windows": [], "claude": [], "claude-orchestration": [], "cline": [], diff --git a/gsd-core/workflows/progress.md b/gsd-core/workflows/progress.md index 9bedfe0e3..536ac7c46 100644 --- a/gsd-core/workflows/progress.md +++ b/gsd-core/workflows/progress.md @@ -131,6 +131,19 @@ CONTEXT: [✓ if has_context | - if not] ## Pending Todos - [count] pending — /gsd:capture --list to review +## Open Windows +- [count] open in `.planning/WINDOWS.md` — /gsd:ship blocks while any remain +(Only show this section if count > 0; suppressed when ledger is empty or absent) + +```bash +WINDOWS_STATUS=$(gsd_run windows status --raw 2>/dev/null || echo '') +WINDOWS_OPEN=$(printf '%s' "$WINDOWS_STATUS" | jq -r '.ledger.open_count // 0' 2>/dev/null || echo 0) +WINDOWS_WAIVED=$(printf '%s' "$WINDOWS_STATUS" | jq -r '.ledger.waived_count // 0' 2>/dev/null || echo 0) +``` + +Render `Open Windows` only when `$WINDOWS_OPEN` is greater than `0` (or `$WINDOWS_WAIVED` is greater than `0`, so an auditable deferral history remains visible). Phrase: `{WINDOWS_OPEN} open, {WINDOWS_WAIVED} waived — resolves with /gsd:ship gate; inspect via gsd-tools windows status`. The ledger is cross-phase; the count is the project total, not the current phase's. + + ## Active Debug Sessions - [count] active — /gsd:debug to continue (Only show this section if count > 0) diff --git a/gsd-core/workflows/ship.md b/gsd-core/workflows/ship.md index 786db6050..f54901e73 100644 --- a/gsd-core/workflows/ship.md +++ b/gsd-core/workflows/ship.md @@ -106,6 +106,43 @@ Verify the work is ready to ship: ``` If no active security `ship:pre` gate hook is present (security enforcement off), skip this check silently. + +7. **Broken-windows ship gate (capability-driven, issue #1950).** + + The `SHIP_PRE_HOOKS_JSON` resolved in step 6 already includes any `broken-windows` gate. Inspect `activeHooks` for an entry with `capId == "broken-windows"` and `kind == "gate"`: + + ```bash + WINDOWS_GATE_ACTIVE=$(printf '%s' "$SHIP_PRE_HOOKS_JSON" | jq -r \ + '.activeHooks[]? | select(.capId == "broken-windows" and .kind == "gate" and .blocking == true) | .capId' \ + 2>/dev/null | head -1) + ``` + + If `$WINDOWS_GATE_ACTIVE` is non-empty, enforce the gate by reading the ledger's typed status. The ledger lives at the **project root** (cross-phase, not phase-scoped): + + ```bash + WINDOWS_STATUS_JSON=$(gsd_run windows status --raw 2>/dev/null || echo '') + WINDOWS_OPEN_COUNT=$(printf '%s' "$WINDOWS_STATUS_JSON" | jq -r '.ledger.open_count // "?"' 2>/dev/null || echo '?') + ``` + + - **`WINDOWS_OPEN_COUNT == "0"`** → gate passes; continue to the next preflight check. + - **`WINDOWS_OPEN_COUNT` is a positive integer** → block with `WINDOWS_SHIP_GATE_OPEN`: + ``` + ⚠ Broken-windows ship gate: WINDOWS.md has {WINDOWS_OPEN_COUNT} open window(s). + Resolve each entry before shipping, or explicitly waive with a recorded reason: + gsd-tools windows fixed # defect resolved + gsd-tools windows waive "" # justified deferral (reason required) + Then re-run /gsd:ship. + ``` + - **`WINDOWS_OPEN_COUNT` is `"?"`, empty, or non-numeric** → **fail closed and block** with `WINDOWS_SHIP_GATE_READ_FAILED` (the gate is strict equality to `0`; never ship on an unreadable ledger): + ``` + ⚠ Broken-windows ship gate: could not read open_count from .planning/WINDOWS.md. + Inspect the file or run `gsd-tools windows status --raw` to diagnose. The ledger + may be malformed; fix it before shipping (an unparseable ledger is a broken window). + ``` + + The ledger is **optional and backward-compatible**: on a project where `gsd_run windows status` returns `open_count: 0` (no `.planning/WINDOWS.md` yet, or an empty ledger), the gate passes silently. The gate only blocks when at least one entry is `open`. + + If no active `broken-windows` `ship:pre` gate hook is present (gate disabled via `workflow.windows_enforce=false`, the default — tracking continues but the gate is opt-in), skip this check silently. diff --git a/src/broken-windows.cts b/src/broken-windows.cts new file mode 100644 index 000000000..8c70b238a --- /dev/null +++ b/src/broken-windows.cts @@ -0,0 +1,917 @@ +/** + * Broken-windows ledger — enforced cross-phase defect register (issue #1950). + * + * Manages `.planning/WINDOWS.md`: a cross-phase ledger of small defects (stubs, + * TODOs, skipped tests, lint warnings, unrun verifies, unmet truths, deviations). + * `/gsd-ship` blocks while any entry is `open`; an entry can be `waived` only + * with a recorded reason or `fixed`. + * + * LEAF MODULE — imports ONLY: node:fs, node:path. No other src/ imports. + * + * Storage format (`.planning/WINDOWS.md`): + * --- + * schema_version: 1 + * open_count: N + * waived_count: N + * fixed_count: N + * total_count: N + * last_updated: + * --- + * # Broken Windows Ledger + * + * ```json + * [ ] + * ``` + * + * Frontmatter holds scalar counts (the FAST path the ship gate reads via jq + * without parsing JSON). The JSON code block is the AUTHORITATIVE entries + * source. The two must agree; read paths cross-check and fail closed on drift. + * + * Exports: + * Constants: REASON, LEDGER_FILE_NAME, SCHEMA_VERSION, KINDS + * Pure: emptyLedger, parseLedger, renderLedger, appendWindow, + * markWaived, markFixed, openCount, findByStatus + * I/O: cmdWindowsStatus, cmdWindowsAppend, cmdWindowsWaive, + * cmdWindowsMarkFixed + * + * Reasoning shape — every cmd* function returns JSON suitable for `--raw`: + * success: { ok: true, ledger: , ... } + * failure: { ok: false, reason: , message: } + * Failure throws an ExitError-shaped error carrying REASON so the gsd-tools + * dispatcher's `--json-errors` mode emits it as a structured code (CONTRIBUTING.md + * "Prohibited: Raw Text Matching"). The frozen REASON enum is the typed surface + * tests assert against. + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +// ─── Constants ───────────────────────────────────────────────────────────── + +export const LEDGER_FILE_NAME = 'WINDOWS.md'; +export const SCHEMA_VERSION = 1; + +/** + * Frozen reason enum. Tests assert against these — they are the typed surface + * per CONTRIBUTING.md. Adding a new code requires updating this enum, the I/O + * entry point that emits it, AND the test that locks Object.keys(REASON).sort() + * — three coordinated changes that keep code and tests from drifting. + */ +export const REASON = Object.freeze({ + WINDOWS_OK: 'windows_ok', + WINDOWS_LEDGER_MISSING: 'windows_ledger_missing', + WINDOWS_LEDGER_MALFORMED: 'windows_ledger_malformed', + WINDOWS_ID_NOT_FOUND: 'windows_id_not_found', + WINDOWS_ALREADY_RESOLVED: 'windows_already_resolved', + WINDOWS_WAIVE_REASON_EMPTY: 'windows_waive_reason_empty', + WINDOWS_INVALID_KIND: 'windows_invalid_kind', + WINDOWS_INVALID_FILE: 'windows_invalid_file', + WINDOWS_INVALID_TEXT: 'windows_invalid_text', + WINDOWS_INVALID_ID: 'windows_invalid_id', + WINDOWS_APPEND_MISSING_FIELD: 'windows_append_missing_field', + WINDOWS_USAGE: 'windows_usage', +}); + +/** Allowed window kinds. Aligned with the issue's enumerated sources. */ +export const KINDS = Object.freeze([ + 'stub', + 'todo', + 'fixme', + 'skipped-test', + 'lint-warning', + 'unmet-truth', + 'unrun-verify', + 'deviation', +]); + +const KIND_SET = new Set(KINDS); + +// ─── Types ───────────────────────────────────────────────────────────────── + +export type WindowKind = + | 'stub' + | 'todo' + | 'fixme' + | 'skipped-test' + | 'lint-warning' + | 'unmet-truth' + | 'unrun-verify' + | 'deviation'; + +export type WindowStatus = 'open' | 'waived' | 'fixed'; + +export interface WindowEntry { + id: number; + kind: WindowKind; + phase: string; + file: string; // '' when not applicable + line: number | null; // null when not applicable + description: string; + status: WindowStatus; + reason: string; // '' unless status === 'waived' + recorded_at: string; // ISO-8601 + resolved_at: string | null; +} + +/** Input shape for appendWindow — id/status/timestamps are assigned by the fn. */ +export type WindowInput = Pick & + Partial>; + +export interface Ledger { + schema_version: number; + open_count: number; + waived_count: number; + fixed_count: number; + total_count: number; + last_updated: string; + entries: WindowEntry[]; +} + +// ─── Errors ──────────────────────────────────────────────────────────────── + +/** + * Error carrying a REASON code. gsd-tools.cjs's `--json-errors` mode catches + * this and emits `{ ok: false, reason: err.reason, message: err.message }` to + * stderr; otherwise the message goes to stderr as plain text and the exit + * code is non-zero. + */ +export class WindowsError extends Error { + reason: string; + constructor(reason: string, message: string) { + super(message); + this.name = 'WindowsError'; + this.reason = reason; + } +} + +// ─── Pure: constructors + counts ─────────────────────────────────────────── + +export function emptyLedger(now: string): Ledger { + return { + schema_version: SCHEMA_VERSION, + open_count: 0, + waived_count: 0, + fixed_count: 0, + total_count: 0, + last_updated: now, + entries: [], + }; +} + +export function openCount(ledger: Ledger): number { + return ledger.open_count; +} + +export function findByStatus(ledger: Ledger, status: WindowStatus): WindowEntry[] { + return ledger.entries.filter((e) => e.status === status); +} + +function recomputeCounts(ledger: Ledger): Ledger { + let open = 0, waived = 0, fixed = 0; + for (const e of ledger.entries) { + if (e.status === 'open') open++; + else if (e.status === 'waived') waived++; + else if (e.status === 'fixed') fixed++; + } + return { + ...ledger, + open_count: open, + waived_count: waived, + fixed_count: fixed, + total_count: ledger.entries.length, + }; +} + +function validateKind(kind: unknown): asserts kind is WindowKind { + if (typeof kind !== 'string' || !KIND_SET.has(kind)) { + throw new WindowsError( + REASON.WINDOWS_INVALID_KIND, + `Invalid window kind: ${JSON.stringify(kind)}. Allowed: ${KINDS.join(', ')}.`, + ); + } +} + +function validateDescription(description: unknown): string { + if (typeof description !== 'string' || description.trim() === '') { + throw new WindowsError( + REASON.WINDOWS_APPEND_MISSING_FIELD, + 'Window description must be a non-empty string.', + ); + } + rejectBacktickRun(description, 'description'); + return description; +} + +/** + * Reject any string field that contains a 4-backtick run. The ledger's JSON + * code block uses a 4-backtick fence; a 4-backtick run inside stringified + * entry text would terminate the fence early and brick the next parse + * (issue #1950 review H1). JSON.stringify does not escape backticks, so we + * must catch them at validate time. + */ +function rejectBacktickRun(value: string, field: string): void { + if (value.includes(FORBIDDEN_BACKTICK_RUN)) { + throw new WindowsError( + REASON.WINDOWS_INVALID_TEXT, + `Window ${field} contains a 4-backtick run, which would corrupt the ledger's JSON code fence.`, + ); + } +} + +function validateFile(file: unknown): string { + if (file == null || file === '') return ''; + if (typeof file !== 'string') { + throw new WindowsError( + REASON.WINDOWS_INVALID_FILE, + 'Window file must be a string when provided.', + ); + } + // Reject path traversal — the ledger is a project-local artifact; absolute or + // parent-escaping paths serve no legitimate purpose and could mislead a human + // reviewer into investigating the wrong location. Reject NUL bytes too. + if (file.includes('\0')) { + throw new WindowsError( + REASON.WINDOWS_INVALID_FILE, + 'Window file contains a NUL byte.', + ); + } + if (path.isAbsolute(file) || /(^|[/\\])\.\.([/\\]|$)/.test(file)) { + throw new WindowsError( + REASON.WINDOWS_INVALID_FILE, + `Window file rejects path traversal/absolute paths: ${file}`, + ); + } + return file; +} + +function validateLine(line: unknown): number | null { + if (line == null || line === '') return null; + // Strict: number or numeric string only; reject garbage like "abc" (which + // Number() would silently coerce to NaN → null, hiding type drift). Issue + // #1950 review M2. + const n = typeof line === 'number' ? line : Number(line); + if (!Number.isInteger(n) || n < 1) { + throw new WindowsError( + REASON.WINDOWS_APPEND_MISSING_FIELD, + `Window line must be a positive integer when provided (got: ${JSON.stringify(line)}).`, + ); + } + return n; +} + +function nextId(entries: WindowEntry[]): number { + let max = 0; + for (const e of entries) if (e.id > max) max = e.id; + return max + 1; +} + +/** + * Append a window to the ledger. Assigns the next dense id (max+1), sets + * status=open, timestamps via opts.now. + * + * Concurrency (issue #1950 review L2): NOT safe for concurrent writers. Two + * parallel `gsd_run windows append` invocations both read the same snapshot, + * both compute the same nextId, both write — the second atomic rename wins + * and the first append (and the entry it added) is silently lost. This is + * acceptable in the current single-executor-per-phase model; document if the + * executor ever gains parallel wave-level append. + */ +export function appendWindow( + ledger: Ledger, + input: WindowInput, + opts: { now: string } = { now: new Date().toISOString() }, +): { ledger: Ledger; entry: WindowEntry } { + validateKind(input.kind); + const description = validateDescription(input.description); + const file = validateFile(input.file); + const line = validateLine(input.line); + + const id = nextId(ledger.entries); + const entry: WindowEntry = { + id, + kind: input.kind, + phase: String(input.phase ?? ''), + file, + line, + description, + status: 'open', + reason: '', + recorded_at: opts.now, + resolved_at: null, + }; + + const entries = [...ledger.entries, entry]; + const result = recomputeCounts({ ...ledger, entries, last_updated: opts.now }); + return { ledger: result, entry }; +} + +function findEntryOrFail(ledger: Ledger, id: number): WindowEntry { + const entry = ledger.entries.find((e) => e.id === id); + if (!entry) { + throw new WindowsError( + REASON.WINDOWS_ID_NOT_FOUND, + `No window with id ${id}.`, + ); + } + return entry; +} + +function assertOpen(entry: WindowEntry): void { + if (entry.status !== 'open') { + throw new WindowsError( + REASON.WINDOWS_ALREADY_RESOLVED, + `Window ${entry.id} is already ${entry.status} (resolved_at=${entry.resolved_at}).`, + ); + } +} + +export function markWaived( + ledger: Ledger, + id: number, + reason: string, + opts: { now: string } = { now: new Date().toISOString() }, +): Ledger { + if (typeof reason !== 'string' || reason.trim() === '') { + throw new WindowsError( + REASON.WINDOWS_WAIVE_REASON_EMPTY, + 'Waive requires a non-empty recorded reason.', + ); + } + const entry = findEntryOrFail(ledger, id); + assertOpen(entry); + + const newStatus: WindowStatus = 'waived'; + const entries = ledger.entries.map((e) => + e.id === id + ? { ...e, status: newStatus, reason, resolved_at: opts.now } + : e, + ); + return recomputeCounts({ ...ledger, entries, last_updated: opts.now }); +} + +export function markFixed( + ledger: Ledger, + id: number, + opts: { now: string } = { now: new Date().toISOString() }, +): Ledger { + const entry = findEntryOrFail(ledger, id); + assertOpen(entry); + + const newStatus: WindowStatus = 'fixed'; + const entries = ledger.entries.map((e) => + e.id === id + ? { ...e, status: newStatus, resolved_at: opts.now } + : e, + ); + return recomputeCounts({ ...ledger, entries, last_updated: opts.now }); +} + +// ─── Pure: parse / render ────────────────────────────────────────────────── + +// JSON-FENCE strategy (issue #1950 review H1): a description containing the +// 3-backtick markdown fence sequence would terminate the code block early +// inside JSON.stringify output (which does not escape backticks), corrupting +// the file and bricking the next parse. We use a 4-backtick fence which +// cannot collide with anything JSON.stringify can emit on its own (JSON has +// no 4-backtick operator), AND validate that no entry's text fields contain +// a 4-backtick run, so the rendered file is provably reparseable. +const JSON_FENCE_OPEN = '````json'; +const JSON_FENCE_CLOSE = '````'; +const FORBIDDEN_BACKTICK_RUN = '````'; + +/** + * Minimal strict frontmatter parser for flat scalar keys. Only supports the + * shape this module emits: `key: ` per line. Throws on any + * structural deviation — fail-closed on drift. + */ +function parseFrontmatterStrict(raw: string): Record { + if (!raw.startsWith('---\n') && !raw.startsWith('---\r\n')) { + throw new WindowsError( + REASON.WINDOWS_LEDGER_MALFORMED, + 'Ledger missing frontmatter opening ---', + ); + } + const headerEnd = raw.startsWith('---\r\n') ? 5 : 4; + const closeIdx = raw.indexOf('\n---', headerEnd); + if (closeIdx === -1) { + throw new WindowsError( + REASON.WINDOWS_LEDGER_MALFORMED, + 'Ledger missing frontmatter closing ---', + ); + } + const yamlBody = raw.slice(headerEnd, closeIdx); + const out: Record = {}; + for (const line of yamlBody.split(/\r?\n/)) { + if (line.trim() === '') continue; + const m = line.match(/^([a-zA-Z0-9_]+):\s*(.*)$/); + if (!m) { + throw new WindowsError( + REASON.WINDOWS_LEDGER_MALFORMED, + `Ledger frontmatter line is not key: value: ${JSON.stringify(line)}`, + ); + } + const [, key, valueStr] = m; + const trimmed = valueStr.trim(); + if (/^-?\d+$/.test(trimmed)) { + out[key] = Number(trimmed); + } else if (/^-?\d+\.\d+$/.test(trimmed)) { + out[key] = Number(trimmed); + } else { + // String — strip surrounding quotes if present. + out[key] = + (trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ? trimmed.slice(1, -1) + : trimmed; + } + } + return out; +} + +function parseJsonBlock(raw: string): WindowEntry[] { + const start = raw.indexOf(JSON_FENCE_OPEN); + if (start === -1) { + throw new WindowsError( + REASON.WINDOWS_LEDGER_MALFORMED, + 'Ledger missing JSON code block for entries.', + ); + } + const end = raw.indexOf(JSON_FENCE_CLOSE, start + JSON_FENCE_OPEN.length); + if (end === -1) { + throw new WindowsError( + REASON.WINDOWS_LEDGER_MALFORMED, + 'Ledger JSON code block not terminated.', + ); + } + const jsonText = raw.slice(start + JSON_FENCE_OPEN.length, end).trim(); + let parsed: unknown; + try { + parsed = JSON.parse(jsonText); + } catch (e) { + throw new WindowsError( + REASON.WINDOWS_LEDGER_MALFORMED, + `Ledger JSON block failed to parse: ${(e as Error).message}`, + ); + } + if (!Array.isArray(parsed)) { + throw new WindowsError( + REASON.WINDOWS_LEDGER_MALFORMED, + 'Ledger JSON block must be an array.', + ); + } + return parsed.map(validateEntryShape); +} + +function validateEntryShape(e: unknown, i: number): WindowEntry { + if (typeof e !== 'object' || e === null) { + throw new WindowsError( + REASON.WINDOWS_LEDGER_MALFORMED, + `Ledger entry ${i} is not an object.`, + ); + } + const o = e as Record; + const required = ['id', 'kind', 'phase', 'file', 'description', 'status', 'reason', 'recorded_at']; + for (const k of required) { + if (!(k in o)) { + throw new WindowsError( + REASON.WINDOWS_LEDGER_MALFORMED, + `Ledger entry ${i} missing required field: ${k}`, + ); + } + } + if (typeof o.id !== 'number' || !Number.isInteger(o.id) || o.id < 1) { + throw new WindowsError( + REASON.WINDOWS_LEDGER_MALFORMED, + `Ledger entry ${i} has invalid id.`, + ); + } + if (typeof o.kind !== 'string' || !KIND_SET.has(o.kind)) { + throw new WindowsError( + REASON.WINDOWS_LEDGER_MALFORMED, + `Ledger entry ${i} has invalid kind: ${JSON.stringify(o.kind)}`, + ); + } + if (typeof o.status !== 'string' || !['open', 'waived', 'fixed'].includes(o.status)) { + throw new WindowsError( + REASON.WINDOWS_LEDGER_MALFORMED, + `Ledger entry ${i} has invalid status: ${JSON.stringify(o.status)}`, + ); + } + if (typeof o.description !== 'string' || typeof o.reason !== 'string') { + throw new WindowsError( + REASON.WINDOWS_LEDGER_MALFORMED, + `Ledger entry ${i} has non-string description/reason.`, + ); + } + const phaseStr = typeof o.phase === 'string' + ? o.phase + : (o.phase == null ? '' : typeof o.phase === 'number' || typeof o.phase === 'boolean' ? String(o.phase) : ''); + const recordedStr = typeof o.recorded_at === 'string' + ? o.recorded_at + : (o.recorded_at == null ? '' : typeof o.recorded_at === 'number' || typeof o.recorded_at === 'boolean' ? String(o.recorded_at) : ''); + const resolvedStr = typeof o.resolved_at === 'string' + ? o.resolved_at + : (o.resolved_at == null ? null : typeof o.resolved_at === 'number' || typeof o.resolved_at === 'boolean' ? String(o.resolved_at) : null); + return { + id: o.id, + kind: o.kind as WindowKind, + phase: phaseStr, + file: typeof o.file === 'string' ? o.file : '', + line: o.line == null ? null : (Number(o.line) || null), + description: o.description, + status: o.status as WindowStatus, + reason: o.reason, + recorded_at: recordedStr, + resolved_at: resolvedStr, + }; +} + +export function parseLedger(raw: string): Ledger { + const fm = parseFrontmatterStrict(raw); + if (fm.schema_version !== SCHEMA_VERSION) { + throw new WindowsError( + REASON.WINDOWS_LEDGER_MALFORMED, + `Ledger schema_version must be ${SCHEMA_VERSION}; got ${JSON.stringify(fm.schema_version)}.`, + ); + } + const requiredCounts = ['open_count', 'waived_count', 'fixed_count', 'total_count']; + for (const k of requiredCounts) { + const v = fm[k]; + if (typeof v !== 'number' || !Number.isInteger(v)) { + throw new WindowsError( + REASON.WINDOWS_LEDGER_MALFORMED, + `Ledger ${k} must be an integer; got ${JSON.stringify(v)}.`, + ); + } + } + if (typeof fm.last_updated !== 'string') { + throw new WindowsError( + REASON.WINDOWS_LEDGER_MALFORMED, + `Ledger last_updated must be a string; got ${JSON.stringify(fm.last_updated)}.`, + ); + } + + const entries = parseJsonBlock(raw); + const ledger: Ledger = { + schema_version: SCHEMA_VERSION, + open_count: typeof fm.open_count === 'number' ? fm.open_count : 0, + waived_count: typeof fm.waived_count === 'number' ? fm.waived_count : 0, + fixed_count: typeof fm.fixed_count === 'number' ? fm.fixed_count : 0, + total_count: typeof fm.total_count === 'number' ? fm.total_count : 0, + last_updated: typeof fm.last_updated === 'string' ? fm.last_updated : '', + entries, + }; + + // Cross-check: frontmatter counts must agree with entries-derived counts. + const recomputed = recomputeCounts(ledger); + if ( + recomputed.open_count !== ledger.open_count || + recomputed.waived_count !== ledger.waived_count || + recomputed.fixed_count !== ledger.fixed_count || + recomputed.total_count !== ledger.total_count + ) { + throw new WindowsError( + REASON.WINDOWS_LEDGER_MALFORMED, + `Ledger counts disagree with entries: frontmatter open/waived/fixed/total=` + + `${ledger.open_count}/${ledger.waived_count}/${ledger.fixed_count}/${ledger.total_count}` + + ` but entries yield ${recomputed.open_count}/${recomputed.waived_count}/${recomputed.fixed_count}/${recomputed.total_count}.`, + ); + } + return ledger; +} + +export function renderLedger(ledger: Ledger): string { + const fm = [ + '---', + `schema_version: ${ledger.schema_version}`, + `open_count: ${ledger.open_count}`, + `waived_count: ${ledger.waived_count}`, + `fixed_count: ${ledger.fixed_count}`, + `total_count: ${ledger.total_count}`, + `last_updated: ${ledger.last_updated}`, + '---', + '', + ].join('\n'); + + const header = [ + '# Broken Windows Ledger', + '', + '> Cross-phase defect register. `/gsd-ship` blocks while `open_count > 0`.', + '> Waive with `gsd-tools windows waive ""` (reason required).', + '> Mark fixed with `gsd-tools windows fixed `.', + '', + ].join('\n'); + + const table = renderTable(ledger.entries); + const jsonBlock = [JSON_FENCE_OPEN, JSON.stringify(ledger.entries, null, 2), JSON_FENCE_CLOSE, ''].join('\n'); + + return [fm, header, table, '', jsonBlock].join('\n'); +} + +function renderTable(entries: WindowEntry[]): string { + if (entries.length === 0) { + return [ + '| id | phase | kind | file | line | description | status | reason | recorded_at | resolved_at |', + '|----|-------|------|------|------|-------------|--------|--------|-------------|-------------|', + '| _(none)_ | | | | | _No windows recorded._ | | | | |', + ].join('\n'); + } + const rows = [ + '| id | phase | kind | file | line | description | status | reason | recorded_at | resolved_at |', + '|----|-------|------|------|------|-------------|--------|--------|-------------|-------------|', + ]; + for (const e of entries) { + // Escape backslash FIRST, then pipe — markdown table cells treat `\` as + // the escape introducer, so a description containing `\|` would render + // as an escaped pipe (i.e. a literal `|` inside the cell) and split the + // column. Escaping `\` → `\\` first makes the subsequent `\|` replacement + // unambiguous. (CodeQL: js/incomplete-sanitization — issue #1950 PR #2441.) + const cell = (s: string | number | null) => + String(s ?? '') + .replace(/\\/g, '\\\\') + .replace(/\|/g, '\\|'); + rows.push( + [ + '|', cell(e.id), '|', cell(e.phase), '|', cell(e.kind), '|', + cell(e.file), '|', cell(e.line ?? ''), '|', + cell(e.description), '|', cell(e.status), '|', + cell(e.reason), '|', cell(e.recorded_at), '|', cell(e.resolved_at), '|', + ].join(' '), + ); + } + return rows.join('\n'); +} + +// ─── I/O entry points ────────────────────────────────────────────────────── + +function ledgerPath(cwd: string): string { + return path.join(cwd, '.planning', LEDGER_FILE_NAME); +} + +function readLedgerOrNull(cwd: string): Ledger | null { + const p = ledgerPath(cwd); + let raw: string; + try { + raw = fs.readFileSync(p, 'utf8'); + } catch (e: unknown) { + // ENOENT is the only "no ledger yet" case. Every other fs error (EACCES, + // EPERM, EIO, ENOTDIR, EBADF, ...) must NOT be silently coerced to "empty + // ledger" — that would fail the ship gate OPEN on an unreadable ledger, + // contradicting the workflow's documented "fail closed on unreadable" + // invariant (issue #1950 review H2). Propagate as malformed so the gate + // blocks and the operator sees a real diagnostic. + const code = (e && typeof e === 'object' && 'code' in e) + ? String((e as { code?: unknown }).code) + : ''; + if (code === 'ENOENT') return null; + throw new WindowsError( + REASON.WINDOWS_LEDGER_MALFORMED, + `Could not read ledger at ${p} (${code || 'unknown fs error'}): ${(e as Error).message}.`, + ); + } + // parseLedger throws WindowsError on malformed content — caller surfaces it. + return parseLedger(raw); +} + +function ensurePlanningDir(cwd: string): void { + const dir = path.join(cwd, '.planning'); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +} + +/** + * Errnos that Windows throws transiently on rename when a reader or antivirus + * scanner holds the target. We retry through these; anything else propagates. + * + * NOTE (issue #1950 review L3): the retry uses a short busy-wait rather than + * setTimeout — this is a synchronous CLI path with no event loop to yield on, + * and the cumulative wait is bounded at 25+50+100+200 = 375ms across 5 attempts. + * If a future caller moves this onto an async path, swap to awaitable sleeps. + */ +const RENAME_RETRY_ERRNOS = new Set(['EPERM', 'EBUSY', 'EACCES']); +const RENAME_MAX_ATTEMPTS = 5; +const RENAME_BACKOFF_MS = 25; + +function renameWithRetry(tmp: string, target: string): void { + let lastErr: unknown; + for (let attempt = 0; attempt < RENAME_MAX_ATTEMPTS; attempt++) { + try { + fs.renameSync(tmp, target); + return; + } catch (err: unknown) { + lastErr = err; + const code = (err && typeof err === 'object' && 'code' in err) ? String((err as { code?: unknown }).code) : ''; + if (code && RENAME_RETRY_ERRNOS.has(code) && attempt < RENAME_MAX_ATTEMPTS - 1) { + // Exponential-ish backoff: 25ms, 50ms, 100ms, 200ms. + const delay = RENAME_BACKOFF_MS * Math.pow(2, attempt); + const start = Date.now(); + while (Date.now() - start < delay) { + // Busy-wait a very short time — Windows transient locks usually clear in <100ms. + } + continue; + } + throw err; + } + } + throw lastErr; +} + +function writeLedgerAtomic(cwd: string, ledger: Ledger): void { + ensurePlanningDir(cwd); + const p = ledgerPath(cwd); + const tmp = `${p}.${process.pid}.tmp`; + fs.writeFileSync(tmp, renderLedger(ledger), 'utf8'); + try { + renameWithRetry(tmp, p); + } catch (err) { + // Clean up the orphaned tmp file so repeated failures don't accumulate + // `.planning/WINDOWS.md..tmp` files (issue #1950 review M1). Best-effort: + // unlink failures (e.g., already gone) are swallowed. + try { fs.unlinkSync(tmp); } catch { /* best-effort cleanup */ } + throw err; + } +} + +function nowIso(): string { + return new Date().toISOString(); +} + +/** Emit a JSON result to stdout in the canonical shape. */ +function emit(obj: unknown): void { + process.stdout.write(JSON.stringify(obj, null, 2)); +} + +/** `gsd-tools windows status [--raw]`. */ +export function cmdWindowsStatus(cwd: string, opts: { raw?: boolean } = {}): void { + let ledger: Ledger; + try { + ledger = readLedgerOrNull(cwd) ?? emptyLedger(nowIso()); + } catch (e) { + if (e instanceof WindowsError) throw e; + throw new WindowsError( + REASON.WINDOWS_LEDGER_MALFORMED, + `Unexpected error reading ledger: ${(e as Error).message}`, + ); + } + void opts; // status output is JSON in both human and raw modes (single shape) + emit({ ok: true, ledger }); +} + +/** `gsd-tools windows append --kind K --phase N [--file F] [--line L] --description D`. */ +export function cmdWindowsAppend( + cwd: string, + args: string[], + opts: { raw?: boolean } = {}, +): void { + void opts; + const parsed = parseArgs(args, { + flags: ['--kind', '--phase', '--file', '--line', '--description'], + required: ['--kind', '--phase', '--description'], + }); + + let ledger: Ledger; + try { + ledger = readLedgerOrNull(cwd) ?? emptyLedger(nowIso()); + } catch (e) { + if (e instanceof WindowsError) throw e; + throw new WindowsError(REASON.WINDOWS_LEDGER_MALFORMED, (e as Error).message); + } + + const result = appendWindow( + ledger, + { + kind: parsed.values['--kind'] as WindowKind, + phase: parsed.values['--phase'] ?? '', + file: parsed.values['--file'] ?? '', + line: parsed.values['--line'] == null ? null : Number(parsed.values['--line']), + description: parsed.values['--description'] ?? '', + }, + { now: nowIso() }, + ); + writeLedgerAtomic(cwd, result.ledger); + emit({ ok: true, ledger: result.ledger, entry: result.entry }); +} + +/** `gsd-tools windows waive ""`. */ +export function cmdWindowsWaive( + cwd: string, + args: string[], + opts: { raw?: boolean } = {}, +): void { + void opts; + const { positionals } = parseArgs(args, { flags: [], required: [], positionals: 2 }); + const idStr = positionals[0]; + const reason = positionals[1]; + + const id = parseIdOrThrow(idStr); + + let ledger: Ledger; + try { + ledger = readLedgerOrNull(cwd) ?? emptyLedger(nowIso()); + } catch (e) { + if (e instanceof WindowsError) throw e; + throw new WindowsError(REASON.WINDOWS_LEDGER_MALFORMED, (e as Error).message); + } + + const updated = markWaived(ledger, id, reason ?? '', { now: nowIso() }); + writeLedgerAtomic(cwd, updated); + emit({ ok: true, ledger: updated }); +} + +/** `gsd-tools windows fixed `. */ +export function cmdWindowsMarkFixed( + cwd: string, + args: string[], + opts: { raw?: boolean } = {}, +): void { + void opts; + const { positionals } = parseArgs(args, { flags: [], required: [], positionals: 1 }); + const id = parseIdOrThrow(positionals[0]); + + let ledger: Ledger; + try { + ledger = readLedgerOrNull(cwd) ?? emptyLedger(nowIso()); + } catch (e) { + if (e instanceof WindowsError) throw e; + throw new WindowsError(REASON.WINDOWS_LEDGER_MALFORMED, (e as Error).message); + } + + const updated = markFixed(ledger, id, { now: nowIso() }); + writeLedgerAtomic(cwd, updated); + emit({ ok: true, ledger: updated }); +} + +function parseIdOrThrow(raw: string | undefined): number { + if (raw == null || raw === '') { + throw new WindowsError( + REASON.WINDOWS_INVALID_ID, + 'Window id is required.', + ); + } + const n = Number(raw); + if (!Number.isInteger(n) || n < 1) { + throw new WindowsError( + REASON.WINDOWS_INVALID_ID, + `Window id must be a positive integer (got: ${JSON.stringify(raw)}).`, + ); + } + return n; +} + +/** Minimal argv parser — flag values via `--flag value` or `--flag=value`. */ +function parseArgs( + args: string[], + spec: { flags: string[]; required: string[]; positionals?: number }, +): { values: Record; positionals: string[] } { + const values: Record = {}; + const positionals: string[] = []; + const flagSet = new Set(spec.flags); + + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a == null) continue; + if (a.startsWith('--')) { + const eq = a.indexOf('='); + const flagName = eq === -1 ? a : a.slice(0, eq); + if (!flagSet.has(flagName)) { + throw new WindowsError( + REASON.WINDOWS_USAGE, + `Unknown flag: ${flagName}`, + ); + } + if (eq !== -1) { + values[flagName] = a.slice(eq + 1); + } else { + const next = args[i + 1]; + if (next == null || next.startsWith('--')) { + if (!(flagName in values)) values[flagName] = undefined; + } else { + values[flagName] = next; + i++; + } + } + } else { + positionals.push(a); + } + } + + for (const r of spec.required) { + if (values[r] == null || values[r] === '') { + throw new WindowsError( + REASON.WINDOWS_USAGE, + `Missing required flag: ${r}`, + ); + } + } + + const want = spec.positionals ?? 0; + if (positionals.length < want) { + throw new WindowsError( + REASON.WINDOWS_USAGE, + `Expected ${want} positional argument(s); got ${positionals.length}.`, + ); + } + + return { values, positionals }; +} diff --git a/stryker.config.mjs b/stryker.config.mjs index 08c78fcc6..13c27271c 100644 --- a/stryker.config.mjs +++ b/stryker.config.mjs @@ -62,7 +62,7 @@ const UNMUTATED = [ // Full test command used by local runs and as the fallback when CI does not // inject a per-shard command via MUTATION_TEST_CMD. // Keep this list in sync with the tests arrays in scripts/mutation-matrix.cjs COVERED. -const DEFAULT_TEST_CMD = 'node --test tests/context-utilization.property.test.cjs tests/prompt-budget.property.test.cjs tests/frontmatter.property.test.cjs tests/adr-parser.property.test.cjs tests/config-schema.property.test.cjs tests/adr-parser.test.cjs tests/active-workstream-store.test.cjs tests/active-workstream-store.unit.test.cjs tests/prompt-budget.unit.test.cjs tests/adr-parser.unit.test.cjs tests/frontmatter.unit.test.cjs tests/core-utils.test.cjs'; +const DEFAULT_TEST_CMD = 'node --test tests/context-utilization.property.test.cjs tests/prompt-budget.property.test.cjs tests/frontmatter.property.test.cjs tests/adr-parser.property.test.cjs tests/config-schema.property.test.cjs tests/adr-parser.test.cjs tests/active-workstream-store.test.cjs tests/active-workstream-store.unit.test.cjs tests/prompt-budget.unit.test.cjs tests/adr-parser.unit.test.cjs tests/frontmatter.unit.test.cjs tests/core-utils.test.cjs tests/broken-windows.test.cjs'; /** @type {import('@stryker-mutator/core').PartialStrykerOptions} */ export default { diff --git a/tests/agent-size-baseline.json b/tests/agent-size-baseline.json index 885224c7d..204f13417 100644 --- a/tests/agent-size-baseline.json +++ b/tests/agent-size-baseline.json @@ -14,7 +14,7 @@ "gsd-domain-researcher.md": 7032, "gsd-eval-auditor.md": 12496, "gsd-eval-planner.md": 7008, - "gsd-executor.md": 46644, + "gsd-executor.md": 47951, "gsd-framework-selector.md": 6778, "gsd-integration-checker.md": 15238, "gsd-intel-updater.md": 18166, diff --git a/tests/broken-windows.test.cjs b/tests/broken-windows.test.cjs new file mode 100644 index 000000000..00edad0b4 --- /dev/null +++ b/tests/broken-windows.test.cjs @@ -0,0 +1,725 @@ +'use strict'; + +/** + * Broken-windows ledger — behavioral + property tests. + * + * Module: gsd-core/bin/lib/broken-windows.cjs (compiled from src/broken-windows.cts) + * CLI: gsd-tools windows + * + * Issue: #1950 — enforced cross-phase defect register gating /gsd-ship. + * + * Coverage map (acceptance criteria from #1950): + * - Executor writes stubs to ledger → append (CLI + pure) + * - /gsd-ship fails while any entry is open → openCount + cmdWindowsStatus + * - Waive requires non-empty reason → markWaived / cmdWindowsWaive + * - Marking fixed removes from blocking set → markFixed / cmdWindowsMarkFixed + * - Open-window count in progress surface → cmdWindowsStatus emits open_count + * - Tests cover all four + clean-on-empty → empty ledger + full lifecycle + * + * Hermetic: each CLI test uses its own tmpdir via createTempDir and cleans up + * via t.after() (CONTRIBUTING.md pattern 2). No shared state between tests. + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { createTempDir, cleanup, runGsdTools } = require('./helpers.cjs'); +const fc = require('./helpers/fast-check-setup.cjs'); + +const { + REASON, + WindowsError, + LEDGER_FILE_NAME, + emptyLedger, + parseLedger, + renderLedger, + appendWindow, + markWaived, + markFixed, + openCount, +} = require('../gsd-core/bin/lib/broken-windows.cjs'); + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +/** Construct a minimal valid WindowEntry input for tests. */ +function makeEntry(overrides = {}) { + return { + kind: 'stub', + phase: '2', + description: 'hardcoded empty list', + ...overrides, + }; +} + +/** Validator: matches a WindowsError carrying a specific REASON code. */ +function reasonIs(code) { + return (err) => err instanceof WindowsError && err.reason === code; +} + +// --------------------------------------------------------------------------- +// Pure: emptyLedger + openCount +// --------------------------------------------------------------------------- + +describe('broken-windows: emptyLedger + openCount', () => { + test('emptyLedger returns a ledger with zero counts and schema_version 1', () => { + const led = emptyLedger('2026-07-19T00:00:00Z'); + assert.equal(led.schema_version, 1); + assert.equal(led.open_count, 0); + assert.equal(led.waived_count, 0); + assert.equal(led.fixed_count, 0); + assert.equal(led.total_count, 0); + assert.equal(led.last_updated, '2026-07-19T00:00:00Z'); + assert.deepEqual(led.entries, []); + }); + + test('openCount of empty ledger is 0 (clean-ship baseline)', () => { + assert.equal(openCount(emptyLedger('now')), 0); + }); +}); + +// --------------------------------------------------------------------------- +// Pure: appendWindow +// --------------------------------------------------------------------------- + +describe('broken-windows: appendWindow', () => { + test('appending to an empty ledger assigns id=1, status=open, records timestamps', () => { + const led0 = emptyLedger('2026-07-19T00:00:00Z'); + const { ledger, entry } = appendWindow(led0, makeEntry(), { now: '2026-07-19T12:00:00Z' }); + + assert.equal(entry.id, 1); + assert.equal(entry.status, 'open'); + assert.equal(entry.recorded_at, '2026-07-19T12:00:00Z'); + assert.equal(entry.resolved_at, null); + assert.equal(ledger.open_count, 1); + assert.equal(ledger.total_count, 1); + assert.equal(ledger.last_updated, '2026-07-19T12:00:00Z'); + }); + + test('second append gets id=2 (ids are dense and monotonic)', () => { + let led = emptyLedger('now'); + ({ ledger: led } = appendWindow(led, makeEntry({ description: 'first' }), { now: 't1' })); + ({ ledger: led } = appendWindow(led, makeEntry({ description: 'second' }), { now: 't2' })); + assert.equal(led.entries[0].id, 1); + assert.equal(led.entries[1].id, 2); + assert.equal(led.total_count, 2); + assert.equal(openCount(led), 2); + }); + + test('append rejects unknown kind (fail-closed on schema drift)', () => { + const led = emptyLedger('now'); + assert.throws( + () => appendWindow(led, makeEntry({ kind: 'bogus' })), + reasonIs(REASON.WINDOWS_INVALID_KIND), + ); + }); + + test('append rejects empty description (no vacuous windows)', () => { + const led = emptyLedger('now'); + assert.throws( + () => appendWindow(led, makeEntry({ description: '' })), + reasonIs(REASON.WINDOWS_APPEND_MISSING_FIELD), + ); + assert.throws( + () => appendWindow(led, makeEntry({ description: ' ' })), + reasonIs(REASON.WINDOWS_APPEND_MISSING_FIELD), + ); + }); + + test('append rejects path-traversal in --file (security boundary)', () => { + const led = emptyLedger('now'); + assert.throws( + () => appendWindow(led, makeEntry({ file: '../../etc/passwd' })), + reasonIs(REASON.WINDOWS_INVALID_FILE), + ); + }); + + test('append rejects 4-backtick run in description (H1 regression — would brick the JSON fence)', () => { + const led = emptyLedger('now'); + assert.throws( + () => appendWindow(led, makeEntry({ description: 'see ```` four backticks' })), + reasonIs(REASON.WINDOWS_INVALID_TEXT), + ); + // 3-backtick run is fine — the fence is 4-tick so 3-tick content is safe. + const led2 = emptyLedger('now'); + const { ledger } = appendWindow(led2, makeEntry({ description: 'see ```js``` inline' }), { now: 't' }); + assert.equal(ledger.entries[0].description, 'see ```js``` inline'); + // And reparses cleanly: + assert.doesNotThrow(() => parseLedger(renderLedger(ledger))); + }); + + test('renderTable escapes backslash before pipe (CodeQL: incomplete-sanitization — PR #2441)', () => { + // A description containing `\|` must NOT split the markdown table cell. + // Escape order: `\` → `\\` first, then `|` → `\|`. If pipe is escaped first, + // `\|` in input becomes `\\|` in output which markdown renders as `\` + cell-sep. + const led0 = emptyLedger('2026-07-19T00:00:00Z'); + const { ledger } = appendWindow( + led0, + makeEntry({ description: 'path with \\| separator and | pipe and \\ backslash' }), + { now: '2026-07-19T12:00:00Z' }, + ); + const rendered = renderLedger(ledger); + + // The JSON block (source of truth) preserves the description verbatim and reparses. + const reparsed = parseLedger(rendered); + assert.equal(reparsed.entries[0].description, 'path with \\| separator and | pipe and \\ backslash'); + + // The table row for this entry has exactly 10 cells (one per column). Counting + // unescaped pipes inside the row would surface a split. The cell's rendered + // form is `path with \\| separator and \| pipe and \\ backslash` — every pipe + // is preceded by a backslash, so splitting on /(? l.includes('path with')); + assert.ok(tableLine, 'table row for the test entry must exist'); + // Walk the line and count pipes that are NOT preceded by a backslash. + let unescapedPipes = 0; + for (let i = 0; i < tableLine.length; i++) { + if (tableLine[i] === '|' && tableLine[i - 1] !== '\\') unescapedPipes++; + } + // 10 cells = 11 cell-separator pipes per row (leading + 9 internal + trailing). + assert.equal(unescapedPipes, 11, 'table row must have exactly 11 unescaped pipes (10 cells) — backslash-pipe in description must NOT add a split'); + }); +}); + +// --------------------------------------------------------------------------- +// Pure: markWaived (acceptance: waive requires non-empty reason) +// --------------------------------------------------------------------------- + +describe('broken-windows: markWaived', () => { + test('waive with non-empty reason succeeds; waived_count increments; open_count decrements', () => { + let led = emptyLedger('now'); + ({ ledger: led } = appendWindow(led, makeEntry(), { now: 't1' })); + led = markWaived(led, 1, 'Manual QA covers it', { now: 't2' }); + + assert.equal(led.entries[0].status, 'waived'); + assert.equal(led.entries[0].reason, 'Manual QA covers it'); + assert.equal(led.entries[0].resolved_at, 't2'); + assert.equal(led.open_count, 0); + assert.equal(led.waived_count, 1); + assert.equal(openCount(led), 0); // waived does not block + }); + + test('waive with empty reason throws (boundary: limit-1 = 0 chars)', () => { + let led = emptyLedger('now'); + ({ ledger: led } = appendWindow(led, makeEntry(), { now: 't1' })); + assert.throws( + () => markWaived(led, 1, ''), + reasonIs(REASON.WINDOWS_WAIVE_REASON_EMPTY), + ); + }); + + test('waive with whitespace-only reason throws (boundary: limit = spaces)', () => { + let led = emptyLedger('now'); + ({ ledger: led } = appendWindow(led, makeEntry(), { now: 't1' })); + assert.throws( + () => markWaived(led, 1, ' '), + reasonIs(REASON.WINDOWS_WAIVE_REASON_EMPTY), + ); + }); + + test('waive with single-char reason succeeds (boundary: limit+1 = 1 char)', () => { + let led = emptyLedger('now'); + ({ ledger: led } = appendWindow(led, makeEntry(), { now: 't1' })); + led = markWaived(led, 1, 'x', { now: 't2' }); + assert.equal(led.entries[0].status, 'waived'); + }); + + test('waive unknown id throws', () => { + const led = emptyLedger('now'); + assert.throws( + () => markWaived(led, 999, 'reason'), + reasonIs(REASON.WINDOWS_ID_NOT_FOUND), + ); + }); + + test('waive on already-resolved entry throws (no double-resolution)', () => { + let led = emptyLedger('now'); + ({ ledger: led } = appendWindow(led, makeEntry(), { now: 't1' })); + led = markFixed(led, 1, { now: 't2' }); + assert.throws( + () => markWaived(led, 1, 'late', { now: 't3' }), + reasonIs(REASON.WINDOWS_ALREADY_RESOLVED), + ); + }); +}); + +// --------------------------------------------------------------------------- +// Pure: markFixed (acceptance: fixed removes from blocking set) +// --------------------------------------------------------------------------- + +describe('broken-windows: markFixed', () => { + test('fixed decrements open_count and increments fixed_count', () => { + let led = emptyLedger('now'); + ({ ledger: led } = appendWindow(led, makeEntry(), { now: 't1' })); + led = markFixed(led, 1, { now: 't2' }); + + assert.equal(led.entries[0].status, 'fixed'); + assert.equal(led.entries[0].resolved_at, 't2'); + assert.equal(led.open_count, 0); + assert.equal(led.fixed_count, 1); + assert.equal(openCount(led), 0); + }); + + test('fixed on unknown id throws', () => { + const led = emptyLedger('now'); + assert.throws( + () => markFixed(led, 999), + reasonIs(REASON.WINDOWS_ID_NOT_FOUND), + ); + }); + + test('fixed on already-resolved throws', () => { + let led = emptyLedger('now'); + ({ ledger: led } = appendWindow(led, makeEntry(), { now: 't1' })); + led = markWaived(led, 1, 'have it', { now: 't2' }); + assert.throws( + () => markFixed(led, 1, { now: 't3' }), + reasonIs(REASON.WINDOWS_ALREADY_RESOLVED), + ); + }); +}); + +// --------------------------------------------------------------------------- +// Pure: parseLedger / renderLedger roundtrip (property test, fast-check) +// --------------------------------------------------------------------------- + +describe('broken-windows: parse/render roundtrip property', () => { + const arbKind = fc.constantFrom('stub', 'todo', 'fixme', 'skipped-test', 'lint-warning', 'unmet-truth', 'unrun-verify', 'deviation'); + const arbStatus = fc.constantFrom('open', 'waived', 'fixed'); + const arbPhase = fc.integer({ min: 1, max: 99 }).map(n => String(n)); + const arbText = fc.string({ minLength: 1, maxLength: 80 }).map(s => s.replace(/[\r\n\t|]/g, ' ').trim() || 'x'); + + const arbEntry = fc.record({ + id: fc.integer({ min: 1, max: 1000 }), + kind: arbKind, + phase: arbPhase, + description: arbText, + status: arbStatus, + }).map((e) => ({ + id: e.id, + kind: e.kind, + phase: e.phase, + file: e.id % 2 === 0 ? '' : `src/file${e.id}.ts`, + line: e.id % 2 === 0 ? null : e.id * 10, + description: e.description, + status: e.status, + reason: e.status === 'waived' ? 'justified' : '', + recorded_at: '2026-07-19T00:00:00Z', + resolved_at: e.status === 'open' ? null : '2026-07-19T01:00:00Z', + })); + + const arbLedger = fc.array(arbEntry, { maxLength: 6 }).map((entries) => { + const open = entries.filter(e => e.status === 'open').length; + const waived = entries.filter(e => e.status === 'waived').length; + const fixed = entries.filter(e => e.status === 'fixed').length; + return { + schema_version: 1, + open_count: open, + waived_count: waived, + fixed_count: fixed, + total_count: entries.length, + last_updated: '2026-07-19T00:00:00Z', + entries, + }; + }); + + test('property: render(parse(render(ledger))) === render(ledger)', () => { + fc.assert(fc.property(arbLedger, (ledger) => { + const rendered1 = renderLedger(ledger); + const parsed = parseLedger(rendered1); + const rendered2 = renderLedger(parsed); + assert.equal(rendered2, rendered1, 'roundtrip must be stable'); + })); + }); + + test('property: parseLedger never hangs or crashes on arbitrary unicode strings', () => { + fc.assert(fc.property(fc.string({ maxLength: 200 }), (raw) => { + try { parseLedger(raw); } catch { /* malformed input is allowed to throw */ } + })); + }); +}); + +// --------------------------------------------------------------------------- +// Pure: parseLedger fail-closed on malformed input +// --------------------------------------------------------------------------- + +describe('broken-windows: parseLedger fail-closed', () => { + test('rejects frontmatter with wrong schema_version', () => { + const raw = [ + '---', + 'schema_version: 99', + 'open_count: 0', + 'waived_count: 0', + 'fixed_count: 0', + 'total_count: 0', + 'last_updated: 2026-07-19T00:00:00Z', + '---', + '', + '```json', + '[]', + '```', + '', + ].join('\n'); + assert.throws(() => parseLedger(raw), reasonIs(REASON.WINDOWS_LEDGER_MALFORMED)); + }); + + test('rejects frontmatter missing open_count', () => { + const raw = [ + '---', + 'schema_version: 1', + '---', + '', + '```json', + '[]', + '```', + '', + ].join('\n'); + assert.throws(() => parseLedger(raw), reasonIs(REASON.WINDOWS_LEDGER_MALFORMED)); + }); + + test('rejects frontmatter with non-numeric open_count', () => { + const raw = [ + '---', + 'schema_version: 1', + 'open_count: "zero"', + '---', + '', + '```json', + '[]', + '```', + '', + ].join('\n'); + assert.throws(() => parseLedger(raw), reasonIs(REASON.WINDOWS_LEDGER_MALFORMED)); + }); +}); + +// --------------------------------------------------------------------------- +// CLI: gsd-tools windows status (acceptance: clean-ship on empty) +// --------------------------------------------------------------------------- + +describe('broken-windows CLI: windows status', () => { + test('status on a project with no ledger returns open_count=0 (backward-compat baseline)', (t) => { + const tmp = createTempDir('bw-status-empty-'); + t.after(() => cleanup(tmp)); + + const res = runGsdTools(['windows', 'status', '--raw'], tmp); + assert.equal(res.success, true, `stderr: ${res.error || ''}`); + const obj = JSON.parse(res.output); + assert.equal(obj.ok, true); + assert.equal(obj.ledger.open_count, 0); + assert.deepEqual(obj.ledger.entries, []); + }); + + test('status on a malformed ledger fails closed', (t) => { + const tmp = createTempDir('bw-status-malformed-'); + t.after(() => cleanup(tmp)); + fs.mkdirSync(path.join(tmp, '.planning'), { recursive: true }); + fs.writeFileSync( + path.join(tmp, '.planning', LEDGER_FILE_NAME), + 'not valid markdown or frontmatter', + ); + + const res = runGsdTools(['windows', 'status', '--raw'], tmp); + assert.equal(res.success, false); + assert.ok(res.exitCode !== 0); + assert.match(res.error, /malformed|invalid frontmatter|missing frontmatter/i); + }); + + test('status on an UNREADABLE ledger fails closed (H2 regression — EACCES must not be silently empty)', (t) => { + // Skip on Windows where chmod 000 doesn't apply to root/admin or where the FS + // ignores mode bits; CI lanes run as non-root so the EACCES path is real. + const tmp = createTempDir('bw-status-eacces-'); + t.after(() => { + try { fs.chmodSync(path.join(tmp, '.planning', LEDGER_FILE_NAME), 0o644); } catch { /* best-effort */ } + cleanup(tmp); + }); + fs.mkdirSync(path.join(tmp, '.planning'), { recursive: true }); + // A ledger with open_count=1 — if EACCES silently returned empty, ship gate would pass. + const validLedger = [ + '---', + 'schema_version: 1', + 'open_count: 1', + 'waived_count: 0', + 'fixed_count: 0', + 'total_count: 1', + 'last_updated: 2026-07-19T00:00:00Z', + '---', + '', + '````json', + JSON.stringify([{ + id: 1, kind: 'stub', phase: '2', file: '', line: null, + description: 'unreadable-test', status: 'open', reason: '', + recorded_at: 't', resolved_at: null, + }]), + '````', + '', + ].join('\n'); + fs.writeFileSync(path.join(tmp, '.planning', LEDGER_FILE_NAME), validLedger); + try { fs.chmodSync(path.join(tmp, '.planning', LEDGER_FILE_NAME), 0o000); } catch { return; } + + const res = runGsdTools(['windows', 'status', '--raw'], tmp); + // If the chmod actually took (non-root), the read must fail. If running as + // root (CI rarely does), the read may succeed — either way, the test must + // never see a false-green "open_count: 0" from a file we KNOW has open_count=1. + if (res.success) { + const obj = JSON.parse(res.output); + assert.notEqual(obj.ledger.open_count, 0, 'EACCES must NOT silently coerce an open_count=1 ledger to 0'); + } else { + assert.match(res.error, /could not read|EACCES|malformed/i); + } + }); +}); + +// --------------------------------------------------------------------------- +// CLI: gsd-tools windows append (acceptance: executor writes stubs) +// --------------------------------------------------------------------------- + +describe('broken-windows CLI: windows append', () => { + test('append creates the ledger if absent and records the entry', (t) => { + const tmp = createTempDir('bw-append-create-'); + t.after(() => cleanup(tmp)); + + const res = runGsdTools( + ['windows', 'append', '--kind', 'stub', '--phase', '2', + '--file', 'src/auth.ts', '--line', '42', + '--description', 'hardcoded empty list in UserService.list'], + tmp, + ); + assert.equal(res.success, true, `stderr: ${res.error || ''}`); + const obj = JSON.parse(res.output); + assert.equal(obj.ok, true); + assert.equal(obj.entry.id, 1); + assert.equal(obj.entry.status, 'open'); + assert.equal(obj.ledger.open_count, 1); + + // File exists with the right frontmatter and is re-readable. + const ledgerPath = path.join(tmp, '.planning', LEDGER_FILE_NAME); + assert.equal(fs.existsSync(ledgerPath), true); + + // Second invocation observes the persisted entry (idempotent read). + const res2 = runGsdTools(['windows', 'status', '--raw'], tmp); + assert.equal(res2.success, true); + const obj2 = JSON.parse(res2.output); + assert.equal(obj2.ledger.open_count, 1); + assert.equal(obj2.ledger.entries[0].id, 1); + }); + + test('append a second entry gets id=2', (t) => { + const tmp = createTempDir('bw-append-second-'); + t.after(() => cleanup(tmp)); + + const r1 = runGsdTools( + ['windows', 'append', '--kind', 'todo', '--phase', '2', '--description', 'first todo'], + tmp, + ); + assert.equal(r1.success, true, `stderr: ${r1.error || ''}`); + const r2 = runGsdTools( + ['windows', 'append', '--kind', 'todo', '--phase', '2', '--description', 'second todo'], + tmp, + ); + assert.equal(r2.success, true); + const obj2 = JSON.parse(r2.output); + assert.equal(obj2.entry.id, 2); + assert.equal(obj2.ledger.total_count, 2); + }); + + test('append rejects unknown kind', (t) => { + const tmp = createTempDir('bw-append-badkind-'); + t.after(() => cleanup(tmp)); + const res = runGsdTools( + ['windows', 'append', '--kind', 'bogus', '--phase', '2', '--description', 'x'], + tmp, + ); + assert.equal(res.success, false); + assert.match(res.error, /invalid kind|allowed:/i); + }); + + test('append rejects path-traversal in --file', (t) => { + const tmp = createTempDir('bw-append-traversal-'); + t.after(() => cleanup(tmp)); + const res = runGsdTools( + ['windows', 'append', '--kind', 'stub', '--phase', '2', + '--file', '../../etc/passwd', '--description', 'x'], + tmp, + ); + assert.equal(res.success, false); + assert.match(res.error, /traversal|absolute|file/i); + }); + + test('append rejects missing description', (t) => { + const tmp = createTempDir('bw-append-nodesc-'); + t.after(() => cleanup(tmp)); + const res = runGsdTools( + ['windows', 'append', '--kind', 'stub', '--phase', '2'], + tmp, + ); + assert.equal(res.success, false); + assert.match(res.error, /description|required|missing/i); + }); + + test('append --line boundary: 0 / 1 / large int (limit-1 / limit / limit+1)', (t) => { + const tmp = createTempDir('bw-append-line-bva-'); + t.after(() => cleanup(tmp)); + + // line=1: smallest valid line — limit boundary. + const r1 = runGsdTools(['windows', 'append', '--kind', 'stub', '--phase', '2', '--line', '1', '--description', 'b'], tmp); + assert.equal(r1.success, true, `--line 1 should succeed: ${r1.error || ''}`); + assert.equal(JSON.parse(r1.output).entry.line, 1); + + // line=large: limit+1 boundary (just confirm it accepts arbitrary positive int). + const r2 = runGsdTools(['windows', 'append', '--kind', 'stub', '--phase', '2', '--line', '999999', '--description', 'c'], tmp); + assert.equal(r2.success, true, `--line 999999 should succeed: ${r2.error || ''}`); + assert.equal(JSON.parse(r2.output).entry.line, 999999); + + // line=0: limit-1 boundary — invalid (lines are 1-indexed; 0 is not a line). + // M2 fix: validateLine no longer treats 0 as omit; it rejects as non-positive. + const rZero = runGsdTools(['windows', 'append', '--kind', 'stub', '--phase', '2', '--line', '0', '--description', 'a'], tmp); + assert.equal(rZero.success, false, '--line 0 must fail (positive integers only)'); + assert.match(rZero.error, /line|positive integer/i); + + // line=-1 and line=abc: also invalid — fail closed. + const rNeg = runGsdTools(['windows', 'append', '--kind', 'stub', '--phase', '2', '--line', '-1', '--description', 'd'], tmp); + assert.equal(rNeg.success, false); + assert.match(rNeg.error, /line|positive integer/i); + const rGarbage = runGsdTools(['windows', 'append', '--kind', 'stub', '--phase', '2', '--line', 'abc', '--description', 'e'], tmp); + assert.equal(rGarbage.success, false); + assert.match(rGarbage.error, /line|positive integer/i); + + // line OMITTED entirely: valid, line is null. + const rOmit = runGsdTools(['windows', 'append', '--kind', 'stub', '--phase', '2', '--description', 'f'], tmp); + assert.equal(rOmit.success, true, `--line omitted should succeed: ${rOmit.error || ''}`); + assert.equal(JSON.parse(rOmit.output).entry.line, null); + }); + + test('append rejects 4-backtick description via CLI (H1 regression)', (t) => { + const tmp = createTempDir('bw-append-4tick-'); + t.after(() => cleanup(tmp)); + const res = runGsdTools( + ['windows', 'append', '--kind', 'stub', '--phase', '2', '--description', 'has ```` four backticks'], + tmp, + ); + assert.equal(res.success, false); + assert.match(res.error, /4-backtick|fence|invalid_text/i); + }); +}); + +// --------------------------------------------------------------------------- +// CLI: gsd-tools windows waive (acceptance: waive-with-reason) +// --------------------------------------------------------------------------- + +describe('broken-windows CLI: windows waive', () => { + test('waive with reason succeeds; subsequent status reports open_count=0', (t) => { + const tmp = createTempDir('bw-waive-ok-'); + t.after(() => cleanup(tmp)); + + const r1 = runGsdTools( + ['windows', 'append', '--kind', 'skipped-test', '--phase', '3', + '--file', 'tests/x.test.cjs', '--line', '18', + '--description', 't.skip logout flow'], + tmp, + ); + assert.equal(r1.success, true, `stderr: ${r1.error || ''}`); + + const r2 = runGsdTools( + ['windows', 'waive', '1', 'Manual QA covers it; CI cannot reach logout URL'], + tmp, + ); + assert.equal(r2.success, true, `stderr: ${r2.error || ''}`); + const obj = JSON.parse(r2.output); + assert.equal(obj.ok, true); + assert.equal(obj.ledger.entries[0].status, 'waived'); + assert.equal(obj.ledger.entries[0].reason, 'Manual QA covers it; CI cannot reach logout URL'); + + const r3 = runGsdTools(['windows', 'status', '--raw'], tmp); + assert.equal(r3.success, true); + const status = JSON.parse(r3.output); + assert.equal(status.ledger.open_count, 0); // waived does not block ship + assert.equal(status.ledger.waived_count, 1); + }); + + test('waive with empty reason fails', (t) => { + const tmp = createTempDir('bw-waive-empty-'); + t.after(() => cleanup(tmp)); + const r1 = runGsdTools( + ['windows', 'append', '--kind', 'stub', '--phase', '2', '--description', 'x'], + tmp, + ); + assert.equal(r1.success, true, `stderr: ${r1.error || ''}`); + + const r2 = runGsdTools(['windows', 'waive', '1', ''], tmp); + assert.equal(r2.success, false); + assert.match(r2.error, /waive.*reason|non-empty|reason.*required/i); + }); + + test('waive unknown id fails', (t) => { + const tmp = createTempDir('bw-waive-unknown-'); + t.after(() => cleanup(tmp)); + const res = runGsdTools(['windows', 'waive', '999', 'because'], tmp); + assert.equal(res.success, false); + assert.match(res.error, /no window|id 999|not found/i); + }); +}); + +// --------------------------------------------------------------------------- +// CLI: gsd-tools windows fixed (acceptance: fixed removes from blocking set) +// --------------------------------------------------------------------------- + +describe('broken-windows CLI: windows fixed', () => { + test('fixed removes the entry from the blocking set', (t) => { + const tmp = createTempDir('bw-fixed-'); + t.after(() => cleanup(tmp)); + + const r1 = runGsdTools( + ['windows', 'append', '--kind', 'stub', '--phase', '2', '--description', 'x'], + tmp, + ); + assert.equal(r1.success, true, `stderr: ${r1.error || ''}`); + + const rBefore = runGsdTools(['windows', 'status', '--raw'], tmp); + assert.equal(rBefore.success, true); + assert.equal(JSON.parse(rBefore.output).ledger.open_count, 1); + + const r2 = runGsdTools(['windows', 'fixed', '1'], tmp); + assert.equal(r2.success, true, `stderr: ${r2.error || ''}`); + const obj = JSON.parse(r2.output); + assert.equal(obj.ledger.open_count, 0); + assert.equal(obj.ledger.fixed_count, 1); + assert.equal(obj.ledger.entries[0].status, 'fixed'); + }); + + test('fixed on unknown id fails', (t) => { + const tmp = createTempDir('bw-fixed-unknown-'); + t.after(() => cleanup(tmp)); + const res = runGsdTools(['windows', 'fixed', '999'], tmp); + assert.equal(res.success, false); + assert.match(res.error, /no window|id 999|not found/i); + }); +}); + +// --------------------------------------------------------------------------- +// CLI: full lifecycle — append → waive → append → fixed → clean ship +// --------------------------------------------------------------------------- + +describe('broken-windows CLI: lifecycle', () => { + test('append two, waive one, fix one, then ship is clean', (t) => { + const tmp = createTempDir('bw-lifecycle-'); + t.after(() => cleanup(tmp)); + + const r1 = runGsdTools(['windows', 'append', '--kind', 'stub', '--phase', '2', '--description', 'a'], tmp); + const r2 = runGsdTools(['windows', 'append', '--kind', 'todo', '--phase', '2', '--description', 'b'], tmp); + const r3 = runGsdTools(['windows', 'waive', '1', 'deferred to follow-up'], tmp); + const r4 = runGsdTools(['windows', 'fixed', '2'], tmp); + assert.equal(r1.success && r2.success && r3.success && r4.success, true, + `lifecycle steps failed: r1=${r1.error || 'ok'} r2=${r2.error || 'ok'} r3=${r3.error || 'ok'} r4=${r4.error || 'ok'}`); + + const rFinal = runGsdTools(['windows', 'status', '--raw'], tmp); + assert.equal(rFinal.success, true); + const status = JSON.parse(rFinal.output); + assert.equal(status.ledger.open_count, 0); // ship gate would pass + assert.equal(status.ledger.waived_count, 1); + assert.equal(status.ledger.fixed_count, 1); + assert.equal(status.ledger.total_count, 2); + }); +}); diff --git a/tests/fixtures/golden-install-parity/antigravity.json b/tests/fixtures/golden-install-parity/antigravity.json index 701e9441c..9e5063073 100644 --- a/tests/fixtures/golden-install-parity/antigravity.json +++ b/tests/fixtures/golden-install-parity/antigravity.json @@ -16,7 +16,7 @@ "agents/gsd-domain-researcher.md": "1db46cac3f4d9889", "agents/gsd-eval-auditor.md": "1b8391f1aafb067f", "agents/gsd-eval-planner.md": "3d10fd11147f6857", - "agents/gsd-executor.md": "152955064cf4c9d2", + "agents/gsd-executor.md": "707f7f26bc2c1c59", "agents/gsd-framework-selector.md": "daa62c79619c76bf", "agents/gsd-integration-checker.md": "0643cd2d779b131c", "agents/gsd-intel-updater.md": "26c1f1e028c6346a", @@ -39,7 +39,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "ea841e2865248e74", - "gsd-core/bin/gsd-tools.cjs": "2fcf88250beb9214", + "gsd-core/bin/gsd-tools.cjs": "8c5bf9788c3967be", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0", @@ -289,7 +289,7 @@ "gsd-core/workflows/plant-seed.md": "6cf726be6427a35a", "gsd-core/workflows/pr-branch.md": "dc5598ae8accdecd", "gsd-core/workflows/profile-user.md": "355af92ac285567f", - "gsd-core/workflows/progress.md": "0e41db02a270a2b2", + "gsd-core/workflows/progress.md": "9fb8c93970e0b4aa", "gsd-core/workflows/quick.md": "29739776f5c91338", "gsd-core/workflows/reapply-patches.md": "4dcd6117d0a507ca", "gsd-core/workflows/remove-phase.md": "23b9eb0858a2535e", @@ -302,7 +302,7 @@ "gsd-core/workflows/settings-advanced.md": "6d25100a9de15b31", "gsd-core/workflows/settings-integrations.md": "4d3001ad2b5dad8f", "gsd-core/workflows/settings.md": "8258f7bd3700d608", - "gsd-core/workflows/ship.md": "8575dcc6d75a17c5", + "gsd-core/workflows/ship.md": "3fb340ea87726ed2", "gsd-core/workflows/sketch-wrap-up.md": "0f842a609851a401", "gsd-core/workflows/sketch.md": "314b7d323c6b57eb", "gsd-core/workflows/smart-entry.md": "3ce5b6228238fdb6", diff --git a/tests/fixtures/golden-install-parity/augment.json b/tests/fixtures/golden-install-parity/augment.json index 73b30ff65..12f9fcc55 100644 --- a/tests/fixtures/golden-install-parity/augment.json +++ b/tests/fixtures/golden-install-parity/augment.json @@ -16,7 +16,7 @@ "agents/gsd-domain-researcher.md": "671c9ea949889c4a", "agents/gsd-eval-auditor.md": "fcaec7b00f94c435", "agents/gsd-eval-planner.md": "a4a5b4b3f7828ba3", - "agents/gsd-executor.md": "47c648f6622e939f", + "agents/gsd-executor.md": "28fbb5fa6eb49c65", "agents/gsd-framework-selector.md": "4b77eebbe9288d80", "agents/gsd-integration-checker.md": "fa53e2d78be1de74", "agents/gsd-intel-updater.md": "fa40e685d7441ace", @@ -110,7 +110,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "acf82db0f1b06ae8", + "gsd-core/bin/gsd-tools.cjs": "d119a8ff93c9c0a3", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0", @@ -360,7 +360,7 @@ "gsd-core/workflows/plant-seed.md": "10b92ae08a6fdede", "gsd-core/workflows/pr-branch.md": "c87db7ac8c28be1b", "gsd-core/workflows/profile-user.md": "14263db831230142", - "gsd-core/workflows/progress.md": "9457fb10437c9402", + "gsd-core/workflows/progress.md": "46c83ad61b3c4a84", "gsd-core/workflows/quick.md": "988fbe85a0251cf1", "gsd-core/workflows/reapply-patches.md": "39050f72601aec89", "gsd-core/workflows/remove-phase.md": "df9a45f0b1880999", @@ -373,7 +373,7 @@ "gsd-core/workflows/settings-advanced.md": "414db4dbea97ba44", "gsd-core/workflows/settings-integrations.md": "70515c5838fb9826", "gsd-core/workflows/settings.md": "d96ddf01fb85e61e", - "gsd-core/workflows/ship.md": "369f1461685d1897", + "gsd-core/workflows/ship.md": "4642ebbaa38f8cc2", "gsd-core/workflows/sketch-wrap-up.md": "5f5ebb6a80d610c6", "gsd-core/workflows/sketch.md": "8319cedf3f93fc35", "gsd-core/workflows/smart-entry.md": "449238eb94abe187", diff --git a/tests/fixtures/golden-install-parity/claude-local.json b/tests/fixtures/golden-install-parity/claude-local.json index 69d9df450..8024e118d 100644 --- a/tests/fixtures/golden-install-parity/claude-local.json +++ b/tests/fixtures/golden-install-parity/claude-local.json @@ -15,7 +15,7 @@ "agents/gsd-domain-researcher.md": "f1e03df842ddfb95", "agents/gsd-eval-auditor.md": "d0f45fff7370bb0b", "agents/gsd-eval-planner.md": "9cc049b82897daa4", - "agents/gsd-executor.md": "3c8d4fd1c29542bb", + "agents/gsd-executor.md": "a140ad7b13e4af95", "agents/gsd-framework-selector.md": "85005d716f9d98f7", "agents/gsd-integration-checker.md": "17a8ee731986564d", "agents/gsd-intel-updater.md": "4953a465db9dadc1", @@ -109,7 +109,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "acf82db0f1b06ae8", + "gsd-core/bin/gsd-tools.cjs": "d119a8ff93c9c0a3", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0", @@ -359,7 +359,7 @@ "gsd-core/workflows/plant-seed.md": "fbe964fcdb244802", "gsd-core/workflows/pr-branch.md": "513f6cff722eff2d", "gsd-core/workflows/profile-user.md": "3b34dcb337d50f4b", - "gsd-core/workflows/progress.md": "f6739fc007de6f66", + "gsd-core/workflows/progress.md": "01079a85cbc41ffe", "gsd-core/workflows/quick.md": "0f49d4553de5da88", "gsd-core/workflows/reapply-patches.md": "44a96b52b975e9bb", "gsd-core/workflows/remove-phase.md": "8effc8742d58a11a", @@ -372,7 +372,7 @@ "gsd-core/workflows/settings-advanced.md": "94d61da368e9f85b", "gsd-core/workflows/settings-integrations.md": "dfe3672c4fabf139", "gsd-core/workflows/settings.md": "acdd79110699a608", - "gsd-core/workflows/ship.md": "cae3fbefba655818", + "gsd-core/workflows/ship.md": "30ff5e4ebf0edee8", "gsd-core/workflows/sketch-wrap-up.md": "d52a5462bafda830", "gsd-core/workflows/sketch.md": "dbe6acc4d976060c", "gsd-core/workflows/smart-entry.md": "1850447c045f36d8", diff --git a/tests/fixtures/golden-install-parity/claude.json b/tests/fixtures/golden-install-parity/claude.json index 4feb9fcc2..97615fa4d 100644 --- a/tests/fixtures/golden-install-parity/claude.json +++ b/tests/fixtures/golden-install-parity/claude.json @@ -15,7 +15,7 @@ "agents/gsd-domain-researcher.md": "5f7d366251b957fe", "agents/gsd-eval-auditor.md": "fea2759beff0a642", "agents/gsd-eval-planner.md": "112f6730f23854e3", - "agents/gsd-executor.md": "e63187a0bbff74eb", + "agents/gsd-executor.md": "a3c5bd2940dcf7c6", "agents/gsd-framework-selector.md": "c350ee693cb1aa4e", "agents/gsd-integration-checker.md": "c8b4e65dee89c8ea", "agents/gsd-intel-updater.md": "5b41e05f90ce89d9", @@ -38,7 +38,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "acf82db0f1b06ae8", + "gsd-core/bin/gsd-tools.cjs": "d119a8ff93c9c0a3", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0", @@ -288,7 +288,7 @@ "gsd-core/workflows/plant-seed.md": "af50e9f10d3cc6e1", "gsd-core/workflows/pr-branch.md": "ab157cd8e49621dd", "gsd-core/workflows/profile-user.md": "ff3820a27731ceb8", - "gsd-core/workflows/progress.md": "8aa2fcebb8b4876e", + "gsd-core/workflows/progress.md": "1b56219777ea078c", "gsd-core/workflows/quick.md": "46da35933571f3f6", "gsd-core/workflows/reapply-patches.md": "ba9406b60f2c4041", "gsd-core/workflows/remove-phase.md": "ada8a0546c686483", @@ -301,7 +301,7 @@ "gsd-core/workflows/settings-advanced.md": "339def28c34b0797", "gsd-core/workflows/settings-integrations.md": "53649313d20694ae", "gsd-core/workflows/settings.md": "7e7458cdb2b68ec5", - "gsd-core/workflows/ship.md": "f777449b79ee3357", + "gsd-core/workflows/ship.md": "cf9425876e736a52", "gsd-core/workflows/sketch-wrap-up.md": "121ed4b8127abf04", "gsd-core/workflows/sketch.md": "737c0492686fea2d", "gsd-core/workflows/smart-entry.md": "ad20cf74ae2e8291", diff --git a/tests/fixtures/golden-install-parity/cline.json b/tests/fixtures/golden-install-parity/cline.json index 6adc16b67..2c720c03b 100644 --- a/tests/fixtures/golden-install-parity/cline.json +++ b/tests/fixtures/golden-install-parity/cline.json @@ -19,7 +19,7 @@ "agents/gsd-domain-researcher.md": "0fecdaea86466a56", "agents/gsd-eval-auditor.md": "36c44303085df2f8", "agents/gsd-eval-planner.md": "3ddea88a69b4da3f", - "agents/gsd-executor.md": "343f7d59b160d74a", + "agents/gsd-executor.md": "8443c52fc864d84e", "agents/gsd-framework-selector.md": "564669d479433f15", "agents/gsd-integration-checker.md": "1bbbdd3d420b994e", "agents/gsd-intel-updater.md": "42c40fffbc720d0b", @@ -42,7 +42,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "476aa24e8c4f03cf", - "gsd-core/bin/gsd-tools.cjs": "81330ef8b3bb63c1", + "gsd-core/bin/gsd-tools.cjs": "36d17f5566bdec9e", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0", @@ -292,7 +292,7 @@ "gsd-core/workflows/plant-seed.md": "a2cdd513663226f1", "gsd-core/workflows/pr-branch.md": "9923878a4f6a2d91", "gsd-core/workflows/profile-user.md": "26f74db0a7fcd268", - "gsd-core/workflows/progress.md": "a2d948b084a48500", + "gsd-core/workflows/progress.md": "53b4310b873d233e", "gsd-core/workflows/quick.md": "f4d73a7a52b11bd1", "gsd-core/workflows/reapply-patches.md": "eb4272145a117904", "gsd-core/workflows/remove-phase.md": "e336350f8113a328", @@ -305,7 +305,7 @@ "gsd-core/workflows/settings-advanced.md": "69f3a19bf2c61160", "gsd-core/workflows/settings-integrations.md": "76eee76d6eb57657", "gsd-core/workflows/settings.md": "3701faed09d55247", - "gsd-core/workflows/ship.md": "a58d05ab03124d0b", + "gsd-core/workflows/ship.md": "e2eb0a000c515798", "gsd-core/workflows/sketch-wrap-up.md": "1f44789553180d84", "gsd-core/workflows/sketch.md": "2226779b6003a71c", "gsd-core/workflows/smart-entry.md": "9a64f43927641ca4", diff --git a/tests/fixtures/golden-install-parity/codebuddy.json b/tests/fixtures/golden-install-parity/codebuddy.json index aa1d65a8a..f55be6556 100644 --- a/tests/fixtures/golden-install-parity/codebuddy.json +++ b/tests/fixtures/golden-install-parity/codebuddy.json @@ -16,7 +16,7 @@ "agents/gsd-domain-researcher.md": "1c1a800108a2b225", "agents/gsd-eval-auditor.md": "99012004b14ea602", "agents/gsd-eval-planner.md": "4ebdd7fe9cbb0cfe", - "agents/gsd-executor.md": "44149e405a126f90", + "agents/gsd-executor.md": "87ae9188c389bde8", "agents/gsd-framework-selector.md": "7726fccc86bfeb50", "agents/gsd-integration-checker.md": "2d8339790bbb2dc3", "agents/gsd-intel-updater.md": "c51339956197cbd3", @@ -110,7 +110,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "acf82db0f1b06ae8", + "gsd-core/bin/gsd-tools.cjs": "d119a8ff93c9c0a3", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0", @@ -360,7 +360,7 @@ "gsd-core/workflows/plant-seed.md": "10b92ae08a6fdede", "gsd-core/workflows/pr-branch.md": "c87db7ac8c28be1b", "gsd-core/workflows/profile-user.md": "4fa910d15dea5695", - "gsd-core/workflows/progress.md": "9457fb10437c9402", + "gsd-core/workflows/progress.md": "46c83ad61b3c4a84", "gsd-core/workflows/quick.md": "68a60c8fe332cb4e", "gsd-core/workflows/reapply-patches.md": "39050f72601aec89", "gsd-core/workflows/remove-phase.md": "df9a45f0b1880999", @@ -373,7 +373,7 @@ "gsd-core/workflows/settings-advanced.md": "414db4dbea97ba44", "gsd-core/workflows/settings-integrations.md": "70515c5838fb9826", "gsd-core/workflows/settings.md": "d96ddf01fb85e61e", - "gsd-core/workflows/ship.md": "369f1461685d1897", + "gsd-core/workflows/ship.md": "4642ebbaa38f8cc2", "gsd-core/workflows/sketch-wrap-up.md": "2aba89ecd8f41a0d", "gsd-core/workflows/sketch.md": "5eedd93f9a5b49d5", "gsd-core/workflows/smart-entry.md": "449238eb94abe187", diff --git a/tests/fixtures/golden-install-parity/codex.json b/tests/fixtures/golden-install-parity/codex.json index 8dcf52bbc..c8421ecaa 100644 --- a/tests/fixtures/golden-install-parity/codex.json +++ b/tests/fixtures/golden-install-parity/codex.json @@ -102,8 +102,8 @@ "agents/gsd-eval-auditor.toml": "9b81d61b3c5f722d", "agents/gsd-eval-planner.md": "73f2ad2ff2797a51", "agents/gsd-eval-planner.toml": "09468ad1a34ac468", - "agents/gsd-executor.md": "ed8080096522f7ec", - "agents/gsd-executor.toml": "126d79d90e2fdf4f", + "agents/gsd-executor.md": "45442cc002be7052", + "agents/gsd-executor.toml": "bfb08e9397bcc358", "agents/gsd-framework-selector.md": "ebae32430887d2e0", "agents/gsd-framework-selector.toml": "637e4e021b7ec380", "agents/gsd-integration-checker.md": "9cc875676cf7d741", @@ -145,7 +145,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "acf82db0f1b06ae8", + "gsd-core/bin/gsd-tools.cjs": "d119a8ff93c9c0a3", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0", @@ -395,7 +395,7 @@ "gsd-core/workflows/plant-seed.md": "5b07de07e4593281", "gsd-core/workflows/pr-branch.md": "d13e1cc81de40896", "gsd-core/workflows/profile-user.md": "05828c8cc61ef384", - "gsd-core/workflows/progress.md": "03f12b223eb3a96e", + "gsd-core/workflows/progress.md": "e57d5a148df38508", "gsd-core/workflows/quick.md": "ef0af6ee7788f64b", "gsd-core/workflows/reapply-patches.md": "26297b84736e66a4", "gsd-core/workflows/remove-phase.md": "9ee0fddd11a0d9d4", @@ -408,7 +408,7 @@ "gsd-core/workflows/settings-advanced.md": "2431433811616f76", "gsd-core/workflows/settings-integrations.md": "77730321d3d6d317", "gsd-core/workflows/settings.md": "054c8c31b3905ced", - "gsd-core/workflows/ship.md": "2c2ab4e0243a2b27", + "gsd-core/workflows/ship.md": "ccd8cda30a4a4231", "gsd-core/workflows/sketch-wrap-up.md": "07724a390fbb43f6", "gsd-core/workflows/sketch.md": "576f300dfdde1b7d", "gsd-core/workflows/smart-entry.md": "6f686195ae531b03", diff --git a/tests/fixtures/golden-install-parity/copilot.json b/tests/fixtures/golden-install-parity/copilot.json index 65eb298ab..751eeaff6 100644 --- a/tests/fixtures/golden-install-parity/copilot.json +++ b/tests/fixtures/golden-install-parity/copilot.json @@ -16,7 +16,7 @@ "agents/gsd-domain-researcher.agent.md": "d603239b3e9fe428", "agents/gsd-eval-auditor.agent.md": "3c03009564de55c8", "agents/gsd-eval-planner.agent.md": "14751876fc2b5f16", - "agents/gsd-executor.agent.md": "e75e46a35b7b260a", + "agents/gsd-executor.agent.md": "bca7d1ab12392956", "agents/gsd-framework-selector.agent.md": "cafeec0b3489be45", "agents/gsd-integration-checker.agent.md": "30439b804927acc7", "agents/gsd-intel-updater.agent.md": "238c1a886f35a25c", @@ -40,7 +40,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "ea841e2865248e74", - "gsd-core/bin/gsd-tools.cjs": "2fcf88250beb9214", + "gsd-core/bin/gsd-tools.cjs": "8c5bf9788c3967be", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0", @@ -290,7 +290,7 @@ "gsd-core/workflows/plant-seed.md": "21e461cd39e5181b", "gsd-core/workflows/pr-branch.md": "2833905f119b5722", "gsd-core/workflows/profile-user.md": "5cc032206c99ef71", - "gsd-core/workflows/progress.md": "449b7f4de1bef7ae", + "gsd-core/workflows/progress.md": "97f4971366c381ee", "gsd-core/workflows/quick.md": "dff6d086ef16a14f", "gsd-core/workflows/reapply-patches.md": "8fd59e24b486f180", "gsd-core/workflows/remove-phase.md": "e262654e319d1bc4", @@ -303,7 +303,7 @@ "gsd-core/workflows/settings-advanced.md": "230a658de9c017a6", "gsd-core/workflows/settings-integrations.md": "a1d146d6bfd14db5", "gsd-core/workflows/settings.md": "f611f14f2f447f1e", - "gsd-core/workflows/ship.md": "7e52f4b11bd15b58", + "gsd-core/workflows/ship.md": "9a260c559e783769", "gsd-core/workflows/sketch-wrap-up.md": "f2590cb6ddbfad94", "gsd-core/workflows/sketch.md": "e2063966439af8c1", "gsd-core/workflows/smart-entry.md": "6c707b959a41900b", diff --git a/tests/fixtures/golden-install-parity/cursor.json b/tests/fixtures/golden-install-parity/cursor.json index cc330260b..d8af931f1 100644 --- a/tests/fixtures/golden-install-parity/cursor.json +++ b/tests/fixtures/golden-install-parity/cursor.json @@ -16,7 +16,7 @@ "agents/gsd-domain-researcher.md": "56395dbdabf076f6", "agents/gsd-eval-auditor.md": "ad2840fd5cd76172", "agents/gsd-eval-planner.md": "2049dac060d00eda", - "agents/gsd-executor.md": "e3d78836e115b18c", + "agents/gsd-executor.md": "e568f8171c7d12f1", "agents/gsd-framework-selector.md": "4b77eebbe9288d80", "agents/gsd-integration-checker.md": "5da30584d06b878c", "agents/gsd-intel-updater.md": "b8971c5d96e63b38", @@ -110,7 +110,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "2525f1ae8b086828", - "gsd-core/bin/gsd-tools.cjs": "31f45cfd36e5dfdb", + "gsd-core/bin/gsd-tools.cjs": "40e8e83311b87500", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0", @@ -360,7 +360,7 @@ "gsd-core/workflows/plant-seed.md": "7bccd151ce7b69f2", "gsd-core/workflows/pr-branch.md": "c67d90c65da47168", "gsd-core/workflows/profile-user.md": "8c943983241260b5", - "gsd-core/workflows/progress.md": "0f03aaf96f5abf7e", + "gsd-core/workflows/progress.md": "2dbc495deed1a36d", "gsd-core/workflows/quick.md": "ad40466dd04e4147", "gsd-core/workflows/reapply-patches.md": "ba9406b60f2c4041", "gsd-core/workflows/remove-phase.md": "ada8a0546c686483", @@ -373,7 +373,7 @@ "gsd-core/workflows/settings-advanced.md": "bf8ea69c8f7ae019", "gsd-core/workflows/settings-integrations.md": "166ca51b1f33c2a3", "gsd-core/workflows/settings.md": "0623c673eaf04799", - "gsd-core/workflows/ship.md": "6e3ac35dc933a39a", + "gsd-core/workflows/ship.md": "1515bb4909387b1d", "gsd-core/workflows/sketch-wrap-up.md": "5be73b7bdf96b539", "gsd-core/workflows/sketch.md": "373bc0d83368a411", "gsd-core/workflows/smart-entry.md": "c8fc316358cdcd7b", diff --git a/tests/fixtures/golden-install-parity/hermes.json b/tests/fixtures/golden-install-parity/hermes.json index 412b45e8b..834ba3dd0 100644 --- a/tests/fixtures/golden-install-parity/hermes.json +++ b/tests/fixtures/golden-install-parity/hermes.json @@ -16,7 +16,7 @@ "agents/gsd-domain-researcher.md": "412cdbb05ba252ea", "agents/gsd-eval-auditor.md": "4ffb265063c318e5", "agents/gsd-eval-planner.md": "03448fc9c5774b56", - "agents/gsd-executor.md": "b70c976372c77dc1", + "agents/gsd-executor.md": "e39b2cd4ec6b3afd", "agents/gsd-framework-selector.md": "ea9981d65d6b3429", "agents/gsd-integration-checker.md": "35b4f2969d279871", "agents/gsd-intel-updater.md": "5fe5edfae2719cb8", @@ -39,7 +39,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "3a3409215044af9f", - "gsd-core/bin/gsd-tools.cjs": "6df1804284c68471", + "gsd-core/bin/gsd-tools.cjs": "c7e11161f0296129", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0", @@ -289,7 +289,7 @@ "gsd-core/workflows/plant-seed.md": "f862f77fca983749", "gsd-core/workflows/pr-branch.md": "ecabd55e4eabf229", "gsd-core/workflows/profile-user.md": "de5030437226cf2c", - "gsd-core/workflows/progress.md": "debe980226d9260d", + "gsd-core/workflows/progress.md": "589f60eb809c0bb9", "gsd-core/workflows/quick.md": "fab58ce8581251fa", "gsd-core/workflows/reapply-patches.md": "158083a310859594", "gsd-core/workflows/remove-phase.md": "fce799aae3ab2715", @@ -302,7 +302,7 @@ "gsd-core/workflows/settings-advanced.md": "49be159144d7f426", "gsd-core/workflows/settings-integrations.md": "1dce76db0aca08a5", "gsd-core/workflows/settings.md": "9ced580679ed0255", - "gsd-core/workflows/ship.md": "8e8c1d3c8204769d", + "gsd-core/workflows/ship.md": "b2f16c18a7e1e19b", "gsd-core/workflows/sketch-wrap-up.md": "f1ece50ac65ea281", "gsd-core/workflows/sketch.md": "d5887983e62b574a", "gsd-core/workflows/smart-entry.md": "47f5c5e8608e5f7a", diff --git a/tests/fixtures/golden-install-parity/kilo.json b/tests/fixtures/golden-install-parity/kilo.json index 179c1e492..731b57f31 100644 --- a/tests/fixtures/golden-install-parity/kilo.json +++ b/tests/fixtures/golden-install-parity/kilo.json @@ -16,7 +16,7 @@ "agents/gsd-domain-researcher.md": "a3874d80bcbc7380", "agents/gsd-eval-auditor.md": "630d4cd3bd6ea195", "agents/gsd-eval-planner.md": "3db12cde12aeb2c1", - "agents/gsd-executor.md": "73278ea652cac62f", + "agents/gsd-executor.md": "14c1681bb5f7214f", "agents/gsd-framework-selector.md": "ad5f2c6b9bec6270", "agents/gsd-integration-checker.md": "c503e2f4a3d8ec05", "agents/gsd-intel-updater.md": "231393da62a45b2e", @@ -110,7 +110,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "acf82db0f1b06ae8", + "gsd-core/bin/gsd-tools.cjs": "d119a8ff93c9c0a3", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0", @@ -360,7 +360,7 @@ "gsd-core/workflows/plant-seed.md": "ffa5774304243649", "gsd-core/workflows/pr-branch.md": "ab157cd8e49621dd", "gsd-core/workflows/profile-user.md": "203ebe3f8f3876a8", - "gsd-core/workflows/progress.md": "c2a8a79af6603e70", + "gsd-core/workflows/progress.md": "172e4168e326ca61", "gsd-core/workflows/quick.md": "4ef7da71a596604a", "gsd-core/workflows/reapply-patches.md": "becf9728cdb124c4", "gsd-core/workflows/remove-phase.md": "ada8a0546c686483", @@ -373,7 +373,7 @@ "gsd-core/workflows/settings-advanced.md": "edd858cd6cfddaf1", "gsd-core/workflows/settings-integrations.md": "3ea8095d5fad891b", "gsd-core/workflows/settings.md": "1925ecc2225c2216", - "gsd-core/workflows/ship.md": "7afe19fa2d472013", + "gsd-core/workflows/ship.md": "cb5f2f25ca80bd3b", "gsd-core/workflows/sketch-wrap-up.md": "888c0548e63197b3", "gsd-core/workflows/sketch.md": "34a0c10fa56af7ea", "gsd-core/workflows/smart-entry.md": "7ffe4fdb93935400", diff --git a/tests/fixtures/golden-install-parity/kimi.json b/tests/fixtures/golden-install-parity/kimi.json index 6f59f0d01..0478e6f14 100644 --- a/tests/fixtures/golden-install-parity/kimi.json +++ b/tests/fixtures/golden-install-parity/kimi.json @@ -61,7 +61,7 @@ "agents/subagents/gsd-eval-auditor.yaml": "e3d868bd5fefe938", "agents/subagents/gsd-eval-planner.md": "70f8c5727bfb9876", "agents/subagents/gsd-eval-planner.yaml": "df8499f7af297ec2", - "agents/subagents/gsd-executor.md": "20cf0b2aa886d35c", + "agents/subagents/gsd-executor.md": "e37a910127d9ca5c", "agents/subagents/gsd-executor.yaml": "e29422986636fd64", "agents/subagents/gsd-framework-selector.md": "a15b7aa1e0576e16", "agents/subagents/gsd-framework-selector.yaml": "fb52c31cde27b0e3", @@ -103,7 +103,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "acf82db0f1b06ae8", + "gsd-core/bin/gsd-tools.cjs": "d119a8ff93c9c0a3", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0", @@ -353,7 +353,7 @@ "gsd-core/workflows/plant-seed.md": "10b92ae08a6fdede", "gsd-core/workflows/pr-branch.md": "c87db7ac8c28be1b", "gsd-core/workflows/profile-user.md": "5abfae83739fa978", - "gsd-core/workflows/progress.md": "9457fb10437c9402", + "gsd-core/workflows/progress.md": "46c83ad61b3c4a84", "gsd-core/workflows/quick.md": "2a9476454b69b4ff", "gsd-core/workflows/reapply-patches.md": "39050f72601aec89", "gsd-core/workflows/remove-phase.md": "df9a45f0b1880999", @@ -366,7 +366,7 @@ "gsd-core/workflows/settings-advanced.md": "414db4dbea97ba44", "gsd-core/workflows/settings-integrations.md": "70515c5838fb9826", "gsd-core/workflows/settings.md": "d96ddf01fb85e61e", - "gsd-core/workflows/ship.md": "369f1461685d1897", + "gsd-core/workflows/ship.md": "4642ebbaa38f8cc2", "gsd-core/workflows/sketch-wrap-up.md": "b767a1d3db129a8a", "gsd-core/workflows/sketch.md": "88cfdf4edcf222ab", "gsd-core/workflows/smart-entry.md": "449238eb94abe187", diff --git a/tests/fixtures/golden-install-parity/opencode.json b/tests/fixtures/golden-install-parity/opencode.json index 0acba8791..26dff0c7f 100644 --- a/tests/fixtures/golden-install-parity/opencode.json +++ b/tests/fixtures/golden-install-parity/opencode.json @@ -16,7 +16,7 @@ "agents/gsd-domain-researcher.md": "71250e759ca9e723", "agents/gsd-eval-auditor.md": "c88890105f32ace6", "agents/gsd-eval-planner.md": "60bddb70a937f796", - "agents/gsd-executor.md": "84c0a7dc19ddb6c5", + "agents/gsd-executor.md": "4946be74860ea68e", "agents/gsd-framework-selector.md": "1c0a10355e787675", "agents/gsd-integration-checker.md": "a9de5928e5a5c649", "agents/gsd-intel-updater.md": "493e07482fa6198a", @@ -110,7 +110,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "acf82db0f1b06ae8", + "gsd-core/bin/gsd-tools.cjs": "d119a8ff93c9c0a3", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0", @@ -360,7 +360,7 @@ "gsd-core/workflows/plant-seed.md": "b8dad652e31c2318", "gsd-core/workflows/pr-branch.md": "929b7cb0c99c7b9e", "gsd-core/workflows/profile-user.md": "248d59a31948e0ed", - "gsd-core/workflows/progress.md": "352920a61ed4f4db", + "gsd-core/workflows/progress.md": "c3d7149133d571a9", "gsd-core/workflows/quick.md": "f2641bad95c6614e", "gsd-core/workflows/reapply-patches.md": "a0e9b53f90abceb2", "gsd-core/workflows/remove-phase.md": "dea4661e8f89596f", @@ -373,7 +373,7 @@ "gsd-core/workflows/settings-advanced.md": "252b0d3edc315339", "gsd-core/workflows/settings-integrations.md": "d1711a95f44fdbde", "gsd-core/workflows/settings.md": "2e42ee34c791378a", - "gsd-core/workflows/ship.md": "fb70add4f61cf3e8", + "gsd-core/workflows/ship.md": "9e773a94f4abf0e6", "gsd-core/workflows/sketch-wrap-up.md": "681800323681c5c6", "gsd-core/workflows/sketch.md": "fcb7af914159ef7b", "gsd-core/workflows/smart-entry.md": "2a253fe437496eea", diff --git a/tests/fixtures/golden-install-parity/pi.json b/tests/fixtures/golden-install-parity/pi.json index 84e322588..c35b481cf 100644 --- a/tests/fixtures/golden-install-parity/pi.json +++ b/tests/fixtures/golden-install-parity/pi.json @@ -6,7 +6,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "acf82db0f1b06ae8", + "gsd-core/bin/gsd-tools.cjs": "d119a8ff93c9c0a3", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0", @@ -256,7 +256,7 @@ "gsd-core/workflows/plant-seed.md": "10b92ae08a6fdede", "gsd-core/workflows/pr-branch.md": "c87db7ac8c28be1b", "gsd-core/workflows/profile-user.md": "1bac7f69142801ef", - "gsd-core/workflows/progress.md": "9457fb10437c9402", + "gsd-core/workflows/progress.md": "46c83ad61b3c4a84", "gsd-core/workflows/quick.md": "c00a6574ff650a86", "gsd-core/workflows/reapply-patches.md": "39050f72601aec89", "gsd-core/workflows/remove-phase.md": "df9a45f0b1880999", @@ -269,7 +269,7 @@ "gsd-core/workflows/settings-advanced.md": "414db4dbea97ba44", "gsd-core/workflows/settings-integrations.md": "70515c5838fb9826", "gsd-core/workflows/settings.md": "d96ddf01fb85e61e", - "gsd-core/workflows/ship.md": "369f1461685d1897", + "gsd-core/workflows/ship.md": "4642ebbaa38f8cc2", "gsd-core/workflows/sketch-wrap-up.md": "838c701bd072ae73", "gsd-core/workflows/sketch.md": "c7725562b3efd311", "gsd-core/workflows/smart-entry.md": "449238eb94abe187", diff --git a/tests/fixtures/golden-install-parity/qwen.json b/tests/fixtures/golden-install-parity/qwen.json index 67572c89b..05e222031 100644 --- a/tests/fixtures/golden-install-parity/qwen.json +++ b/tests/fixtures/golden-install-parity/qwen.json @@ -16,7 +16,7 @@ "agents/gsd-domain-researcher.md": "bd054bb27beed2a7", "agents/gsd-eval-auditor.md": "57cc7458ab5de6b7", "agents/gsd-eval-planner.md": "01b665728dde4ccf", - "agents/gsd-executor.md": "6dcccfef842a49ac", + "agents/gsd-executor.md": "a6dd5b7da329d8f2", "agents/gsd-framework-selector.md": "82ba6abea84226b7", "agents/gsd-integration-checker.md": "90835dbc7dfa1691", "agents/gsd-intel-updater.md": "3cc4f6ddd04676ec", @@ -39,7 +39,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "6e98d76e955e35a2", - "gsd-core/bin/gsd-tools.cjs": "ebbae1afe8c7fcd5", + "gsd-core/bin/gsd-tools.cjs": "c67b43694423939a", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0", @@ -289,7 +289,7 @@ "gsd-core/workflows/plant-seed.md": "0a92ba12993ac261", "gsd-core/workflows/pr-branch.md": "cef0f65b16d500b4", "gsd-core/workflows/profile-user.md": "263c0693563d98da", - "gsd-core/workflows/progress.md": "4ceb0f6f5e34c4a4", + "gsd-core/workflows/progress.md": "475eb60a2e739eed", "gsd-core/workflows/quick.md": "f5de70665fbea4fa", "gsd-core/workflows/reapply-patches.md": "de0ee8acfe7245b2", "gsd-core/workflows/remove-phase.md": "e8ae4fbbfac700f0", @@ -302,7 +302,7 @@ "gsd-core/workflows/settings-advanced.md": "5e05212fb5cba531", "gsd-core/workflows/settings-integrations.md": "29c5de27fbbb18e9", "gsd-core/workflows/settings.md": "26b9b7979d3a5747", - "gsd-core/workflows/ship.md": "55cc3a0b0d188021", + "gsd-core/workflows/ship.md": "15420ac2a46fc203", "gsd-core/workflows/sketch-wrap-up.md": "89e0eab2af946b04", "gsd-core/workflows/sketch.md": "483387542d6fc3af", "gsd-core/workflows/smart-entry.md": "d309710bcabd4675", diff --git a/tests/fixtures/golden-install-parity/trae.json b/tests/fixtures/golden-install-parity/trae.json index ba7c89df6..ad8948f07 100644 --- a/tests/fixtures/golden-install-parity/trae.json +++ b/tests/fixtures/golden-install-parity/trae.json @@ -16,7 +16,7 @@ "agents/gsd-domain-researcher.md": "b80f76874c04e515", "agents/gsd-eval-auditor.md": "470bf16303ec4d2e", "agents/gsd-eval-planner.md": "22334fde85723c9d", - "agents/gsd-executor.md": "b3baa767abac0593", + "agents/gsd-executor.md": "a611d34bf51e6aec", "agents/gsd-framework-selector.md": "7726fccc86bfeb50", "agents/gsd-integration-checker.md": "7cd2072984411c7f", "agents/gsd-intel-updater.md": "83de6ba9172891c3", @@ -39,7 +39,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "de4627dff103d527", - "gsd-core/bin/gsd-tools.cjs": "1673458b3332be8a", + "gsd-core/bin/gsd-tools.cjs": "44e114ddf0f32e08", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0", @@ -289,7 +289,7 @@ "gsd-core/workflows/plant-seed.md": "856ad565b2eb0c47", "gsd-core/workflows/pr-branch.md": "79fd55b88ea2db9c", "gsd-core/workflows/profile-user.md": "672821e6b1266645", - "gsd-core/workflows/progress.md": "00de1afbe907efa5", + "gsd-core/workflows/progress.md": "1fe098b4d559ba55", "gsd-core/workflows/quick.md": "95ad5630aca7f1e2", "gsd-core/workflows/reapply-patches.md": "21b38c374f19fd78", "gsd-core/workflows/remove-phase.md": "a46c2fe853bf4e86", @@ -302,7 +302,7 @@ "gsd-core/workflows/settings-advanced.md": "39e66386f6c48025", "gsd-core/workflows/settings-integrations.md": "f8f756709ec02363", "gsd-core/workflows/settings.md": "44b10c59215633b8", - "gsd-core/workflows/ship.md": "7d6e81541d393db2", + "gsd-core/workflows/ship.md": "58d9fd3ee13ce1c2", "gsd-core/workflows/sketch-wrap-up.md": "dbec602d104cb951", "gsd-core/workflows/sketch.md": "44ff275150b6d045", "gsd-core/workflows/smart-entry.md": "d8018578571f08d5", diff --git a/tests/fixtures/golden-install-parity/windsurf.json b/tests/fixtures/golden-install-parity/windsurf.json index d4aaa76c3..4f2de3298 100644 --- a/tests/fixtures/golden-install-parity/windsurf.json +++ b/tests/fixtures/golden-install-parity/windsurf.json @@ -16,7 +16,7 @@ "agents/gsd-domain-researcher.md": "56395dbdabf076f6", "agents/gsd-eval-auditor.md": "fb64fc5acf359747", "agents/gsd-eval-planner.md": "2049dac060d00eda", - "agents/gsd-executor.md": "45c4632bc67a12a2", + "agents/gsd-executor.md": "067a4f846f4e020c", "agents/gsd-framework-selector.md": "4b77eebbe9288d80", "agents/gsd-integration-checker.md": "4ffb37fb230c2b90", "agents/gsd-intel-updater.md": "a81d77c143c02108", @@ -39,7 +39,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "5636ca0b726871b2", - "gsd-core/bin/gsd-tools.cjs": "ecc544c0f939432b", + "gsd-core/bin/gsd-tools.cjs": "3224906065bfb20c", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0", @@ -289,7 +289,7 @@ "gsd-core/workflows/plant-seed.md": "6cf61f540cdbd8b9", "gsd-core/workflows/pr-branch.md": "acd59f915d018ad4", "gsd-core/workflows/profile-user.md": "c4313672b81b5bcd", - "gsd-core/workflows/progress.md": "7b6b9564b34fcabb", + "gsd-core/workflows/progress.md": "3e4ac518362ba665", "gsd-core/workflows/quick.md": "baff92603ade7489", "gsd-core/workflows/reapply-patches.md": "d449a23d3acf6379", "gsd-core/workflows/remove-phase.md": "e7a6af429b36e77b", @@ -302,7 +302,7 @@ "gsd-core/workflows/settings-advanced.md": "2f86ec7b998f9485", "gsd-core/workflows/settings-integrations.md": "b082fc518b484c07", "gsd-core/workflows/settings.md": "002eb0ce3c10c741", - "gsd-core/workflows/ship.md": "6f611c07a1d34ff2", + "gsd-core/workflows/ship.md": "63ea5e85cbd33058", "gsd-core/workflows/sketch-wrap-up.md": "10063f56c2c7f141", "gsd-core/workflows/sketch.md": "25c1f8f7acfb1da9", "gsd-core/workflows/smart-entry.md": "bd81482cb6a7ac53", diff --git a/tests/fixtures/golden-install-parity/zcode.json b/tests/fixtures/golden-install-parity/zcode.json index 8b6529798..f615d8d69 100644 --- a/tests/fixtures/golden-install-parity/zcode.json +++ b/tests/fixtures/golden-install-parity/zcode.json @@ -16,7 +16,7 @@ "agents/gsd-domain-researcher.md": "049f588663814fa2", "agents/gsd-eval-auditor.md": "54870d3b07433525", "agents/gsd-eval-planner.md": "552e9fa164c51ce8", - "agents/gsd-executor.md": "59fbb1e047b9a7c4", + "agents/gsd-executor.md": "1154cdd7b22f9754", "agents/gsd-framework-selector.md": "8a795f230436ad2e", "agents/gsd-integration-checker.md": "c1760a0bbd4f7bf5", "agents/gsd-intel-updater.md": "944f1d903e2e9e09", @@ -110,7 +110,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "acf82db0f1b06ae8", + "gsd-core/bin/gsd-tools.cjs": "d119a8ff93c9c0a3", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0", @@ -360,7 +360,7 @@ "gsd-core/workflows/plant-seed.md": "10b92ae08a6fdede", "gsd-core/workflows/pr-branch.md": "c87db7ac8c28be1b", "gsd-core/workflows/profile-user.md": "e23bea0a69c0bb4b", - "gsd-core/workflows/progress.md": "9457fb10437c9402", + "gsd-core/workflows/progress.md": "46c83ad61b3c4a84", "gsd-core/workflows/quick.md": "5331f238c75a821f", "gsd-core/workflows/reapply-patches.md": "39050f72601aec89", "gsd-core/workflows/remove-phase.md": "df9a45f0b1880999", @@ -373,7 +373,7 @@ "gsd-core/workflows/settings-advanced.md": "414db4dbea97ba44", "gsd-core/workflows/settings-integrations.md": "70515c5838fb9826", "gsd-core/workflows/settings.md": "d96ddf01fb85e61e", - "gsd-core/workflows/ship.md": "369f1461685d1897", + "gsd-core/workflows/ship.md": "4642ebbaa38f8cc2", "gsd-core/workflows/sketch-wrap-up.md": "86db87b16548117e", "gsd-core/workflows/sketch.md": "e96f1866d3e60cab", "gsd-core/workflows/smart-entry.md": "449238eb94abe187", diff --git a/tests/loop-hooks-ship-pre-e2e.test.cjs b/tests/loop-hooks-ship-pre-e2e.test.cjs index 29e4744ae..c96808d97 100644 --- a/tests/loop-hooks-ship-pre-e2e.test.cjs +++ b/tests/loop-hooks-ship-pre-e2e.test.cjs @@ -291,19 +291,27 @@ describe('frontmatter get SECURITY.md threats_open — type contract', () => { describe('real registry ship:pre — structural guards', () => { - test('ship:pre byLoopPoint entry has exactly 1 gate and 0 steps/contributions', () => { + test('ship:pre byLoopPoint entry has only gates (no steps/contributions) and includes security + broken-windows', () => { const entry = realRegistry.byLoopPoint['ship:pre']; assert.ok(entry, 'ship:pre must exist in byLoopPoint'); assert.strictEqual(entry.steps.length, 0, 'ship:pre must have 0 steps'); assert.strictEqual(entry.contributions.length, 0, 'ship:pre must have 0 contributions'); - assert.strictEqual(entry.gates.length, 1, 'ship:pre must have exactly 1 gate (security)'); + // Two predicate-style gates as of #1950: security (workflow.security_enforcement) + // and broken-windows (workflow.windows_enforce). Both default-off in tests via + // their respective when keys; both surface in the registry regardless of activation. + assert.strictEqual(entry.gates.length, 2, 'ship:pre must have exactly 2 gates (security + broken-windows)'); + const capIds = entry.gates.map(g => g.capId).sort(); + assert.deepEqual(capIds, ['broken-windows', 'security'], 'ship:pre gate capIds must be {security, broken-windows}'); }); - test('ship:pre gate capId is "security" and check has predicate not query', () => { - const gate = realRegistry.byLoopPoint['ship:pre'].gates[0]; - assert.strictEqual(gate.capId, 'security'); - assert.ok(gate.check.predicate, 'ship:pre gate must use predicate, not query'); - assert.strictEqual(gate.check.query, undefined, 'ship:pre must NOT have a check.query (predicate-only gate)'); + test('every ship:pre gate uses predicate (not query) and the security gate is present', () => { + const gates = realRegistry.byLoopPoint['ship:pre'].gates; + for (const gate of gates) { + assert.ok(gate.check.predicate, `ship:pre gate ${gate.capId} must use predicate, not query`); + assert.strictEqual(gate.check.query, undefined, `ship:pre gate ${gate.capId} must NOT have a check.query (predicate-only gate)`); + } + const security = gates.find(g => g.capId === 'security'); + assert.ok(security, 'ship:pre must include the security gate'); }); }); diff --git a/tests/workflow-size-baseline.json b/tests/workflow-size-baseline.json index 09d378486..1be9398a4 100644 --- a/tests/workflow-size-baseline.json +++ b/tests/workflow-size-baseline.json @@ -58,7 +58,7 @@ "plant-seed.md": 11785, "pr-branch.md": 15963, "profile-user.md": 21246, - "progress.md": 31789, + "progress.md": 32632, "quick.md": 50993, "reapply-patches.md": 20312, "remove-phase.md": 8513, @@ -71,7 +71,7 @@ "settings-advanced.md": 40019, "settings-integrations.md": 15892, "settings.md": 33467, - "ship.md": 25575, + "ship.md": 27945, "sketch-wrap-up.md": 14267, "sketch.md": 20004, "smart-entry.md": 11124,