diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8a91c4b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,50 @@ +# Line-ending policy. Normalize all text to LF in the repo; check shell and PowerShell scripts out +# as LF on every platform so shebangs and heredocs parse and cross-platform parity holds (SW-21). +# PowerShell 5.1 reads either; pinning LF is cheap consistency and is unaffected by the pure-ASCII +# rule for *.ps1 (CLAUDE.md). With core.autocrlf=true (the Git default on Windows and on the +# windows-latest CI runner) an unpinned *.sh checks out CRLF, putting a trailing CR on the +# `#!/usr/bin/env bash` shebang (bad interpreter) and mis-parsing heredocs and `[[ ... ]]`. +* text=auto + +*.sh text eol=lf +*.ps1 text eol=lf + +# Byte-comparison fixtures must keep LF on every platform. +# +# scripts/aggregate-lessons.{ps1,sh} deliberately render LF and compare the +# result against tests/lessons/fixtures/expected-lessons.md byte for byte. With +# core.autocrlf=true (the Git default on Windows, and on the windows-latest CI +# runner) that fixture would be checked out as CRLF, and the comparison would +# fail for a reason that has nothing to do with the code under test. +# +# The `* text=auto` default above normalizes to LF in the repo but still yields a +# native (CRLF) checkout on Windows, so these fixture trees keep an explicit eol=lf pin. +tests/lessons/** text eol=lf + +# tests/task-format/** are field-grammar fixtures (SW-11). They assert how a +# reader splits a task block into fields; a CRLF checkout on Windows would make +# any byte-level or line-ending-sensitive reader disagree with the same file on +# Linux. Pinned for the same reason as the lesson fixtures above. +tests/task-format/** text eol=lf + +# tests/revision-log/** are revision-log integrity fixtures (SW-14). The +# valid-revision case proves the original plan prose stays byte-intact while a +# revision is appended below it; a CRLF checkout on Windows would break any +# byte-level comparison. Pinned for the same reason as the fixtures above. +tests/revision-log/** text eol=lf + +# tests/hooks/fixtures/**/_metrics/events.jsonl are pre-seeded rotation inputs +# (SW-15). The rotation trigger compares the file's raw byte size against +# maxSizeKb*1024, so a CRLF checkout on Windows would change the seed's size. +# The seed clears the 1 KB cap by a wide margin either way and the conformance +# comparison is JSON-normalized, so correctness does not depend on this - but +# pin LF anyway so the byte size is identical on every platform. Same reasoning +# as the fixture pins above. +tests/hooks/fixtures/**/_metrics/events.jsonl text eol=lf + +# tests/metrics/** is the /sd:status verification corpus (SW-16). The command +# counts events by exact substring over an LF-terminated log, and the malformed +# fixture asserts an exact skipped count - a CRLF checkout on Windows would +# change both the line contents under test and the well-formed-line test that +# anchors on a trailing '}'. Pinned for the same reason as the fixtures above. +tests/metrics/** text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ed5ab8..1cbd42b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,82 @@ jobs: shell: pwsh run: ./scripts/smoke-hooks.ps1 + # --- Lesson privacy validator, both directions. The clean fixture passing + # proves the grammar is accepted; the leaky fixture FAILING is what proves + # the checks still fire. Only the second one catches a validator that has + # rotted into a no-op --------------------------------------------------- + - name: Lesson validator (bash) + if: runner.os == 'Linux' || runner.os == 'macOS' + shell: bash + run: | + bash scripts/validate-lessons.sh tests/lessons/fixtures/clean-lessons.md + if bash scripts/validate-lessons.sh tests/lessons/fixtures/leaky-lessons.md; then + echo "::error::leaky fixture passed the validator - checks are not firing" + exit 1 + fi + + - name: Lesson validator (PowerShell) + if: runner.os == 'Windows' + shell: pwsh + run: | + ./scripts/validate-lessons.ps1 tests/lessons/fixtures/clean-lessons.md + if ($LASTEXITCODE -ne 0) { throw "clean fixture failed the validator" } + ./scripts/validate-lessons.ps1 tests/lessons/fixtures/leaky-lessons.md + if ($LASTEXITCODE -eq 0) { throw "leaky fixture passed the validator - checks are not firing" } + # The leaky fixture is EXPECTED to exit non-zero; without this reset, the + # implicit "exit $LASTEXITCODE" GitHub Actions appends to every pwsh step + # would fail the step on its own correctly-detecting assertion. + exit 0 + + # --- Lesson aggregator: pin the rendered output against a committed + # expected file, then assert idempotence via --check. Both implementations + # must land on the SAME bytes; the expected file is what makes a + # culture-aware sort or a CRLF write fail loudly instead of drifting ----- + - name: Lesson aggregator (bash) + if: runner.os == 'Linux' || runner.os == 'macOS' + shell: bash + run: | + bash scripts/aggregate-lessons.sh \ + --spec-dir tests/lessons/fixtures/corpus \ + --out tests/lessons/fixtures/expected-lessons.md --check + bash scripts/validate-lessons.sh tests/lessons/fixtures/expected-lessons.md + + - name: Lesson aggregator (PowerShell) + if: runner.os == 'Windows' + shell: pwsh + run: | + ./scripts/aggregate-lessons.ps1 -SpecDir tests/lessons/fixtures/corpus ` + -Out tests/lessons/fixtures/expected-lessons.md -Check + if ($LASTEXITCODE -ne 0) { throw "PowerShell aggregator output differs from the expected fixture" } + ./scripts/validate-lessons.ps1 tests/lessons/fixtures/expected-lessons.md + if ($LASTEXITCODE -ne 0) { throw "generated lessons file failed the privacy validator" } + + # --- Docs-consistency self-test: the validator above proves the docs are + # right; this proves the validator would NOTICE if they were wrong. Without + # it, a Check 7 that rotted into a no-op would still report green --------- + - name: Docs-consistency self-test (bash) + if: runner.os == 'Linux' || runner.os == 'macOS' + shell: bash + run: bash scripts/selftest-docs.sh + + - name: Docs-consistency self-test (PowerShell) + if: runner.os == 'Windows' + shell: pwsh + run: ./scripts/selftest-docs.ps1 + + # --- Cross-impl hook conformance: pipe each golden fixture into BOTH + # implementations (bash + pwsh); normalized decisions must match the + # golden and each other. Runs under pwsh on every OS: ubuntu/macos get + # bash natively + pwsh preinstalled, windows gets pwsh natively + Git + # Bash. A divergence in only one impl fails with a three-way diff ---- + - name: Hook conformance (bash vs PowerShell) + shell: pwsh + run: ./tests/hooks/run-conformance.ps1 + + - name: Hook conformance self-test (divergence detection) + shell: pwsh + run: ./tests/hooks/run-conformance.ps1 -SelfTest + # --- Install -> uninstall round-trip (CLAUDE.md sandbox recipe) -------- - name: Install -> uninstall round-trip (bash) if: runner.os == 'Linux' || runner.os == 'macOS' diff --git a/CHANGELOG.md b/CHANGELOG.md index 361537d..9825633 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,490 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- Three CI-only failures surfaced by PR #23, none reachable from a real install. (1) + `scripts/validate.sh` Check 7 used `declare -A` and `mapfile` (both bash 4+), which crash on + macOS's stock `/bin/bash` 3.2 (`declare: -A: invalid option`, then `mapfile: command not found` + once the first was fixed) - rewritten as plain indexed arrays with linear-scan + `q_get`/`q_set`/`fp_get`/`fp_append` lookup helpers and a `while read` loop in place of + `mapfile`, no behavior change. (2) The + "Lesson validator (PowerShell)" CI step asserts the leaky fixture correctly FAILS validation, + but GitHub Actions appends an implicit `exit $LASTEXITCODE` to every pwsh step, so the + intentional non-zero exit code from the leaky-fixture check failed the step even though the + assertion itself passed - fixed with an explicit `exit 0` after the assertion. (3) + `tests/hooks/run-conformance.ps1`'s `-SelfTest` stub bash script exits immediately without + reading stdin, and writing the JSON payload to its now-closed pipe raised an unhandled + `IOException: Broken pipe` on Linux runners - `Invoke-HookProcess` now wraps the + `StandardInput.Write`/`Close` pair in a try/catch, since a child that never reads its input is + not a harness failure. + +### Added +- `/sd:status` - a read-only reader for the metrics log (SW-16). SW-10 has been accumulating + `.specs/_metrics/events.jsonl` with no consumer; the data existed and was invisible. The new + 13th slash command summarises the **live** log plus `.specs/index.md`: specs in progress, gate + activity broken out by kind (`verify` / `protected` / `code-edit`) and decision + (`allow` / `warn` / `block`), lifecycle transitions, and a **friction** section ranking where the + operator is actually stuck - which specs are blocked most, which code-edit warns are being + ignored, which specs accumulate stale retros, and which in-progress specs are absent from the log + entirely. Read-only: no spec is created, no gate is evaluated, nothing is written. + Three decisions are worth recording because they diverge from a naive reading of the ticket. + (1) **`jq` is an oracle, not a runtime dependency.** The acceptance criterion "counts reconcile + against `jq`" reads like a dependency; it is not. The schema is flat, metadata-only and written in + fixed key order, so exact substring counting is deterministic - and `jq` *aborts* on a + partially-written line, which would lose the whole report to one interrupted append, exactly what + the ticket forbids. `jq` verifies the numbers; it does not produce them. + (2) **Counting is delegated to the shell, never to eyeballing.** A capped log is ~8000 lines; + the command prescribes the exact count commands rather than asking for a summary, because a + number that was estimated cannot reconcile with an independent count. + (3) **The live file only** - `events.jsonl.1` is noted in one header line and never read, per the + read contract set in SW-15. + Every degrade path is a *labelled* state (`ST001`-`ST005`): no config, metrics disabled, log + absent, log empty. A blank report would read as "no friction", so an empty table is treated as a + defect rather than an edge case. Malformed lines are skipped **and counted**, and the skipped + count is always shown - a silent skip and a clean file are not the same fact. + Verification corpus at `tests/metrics/` (populated / malformed / empty fixtures, expected numbers, + and the `jq` oracle procedure), pinned to LF in `.gitattributes`. It is documented as a **manual** + corpus: `commands/status.md` is a prompt file and CI cannot execute it, so it is deliberately not + wired into `scripts/validate.*`. + +- Size cap and single-generation rotation for the metrics log (SW-15). A new `hooks.metrics.maxSizeKb` + (default `1024` KB, ~1 MB) bounds `.specs/_metrics/events.jsonl`: before each append, if the live + file already meets or exceeds `maxSizeKb * 1024` bytes, the hook rolls it to `events.jsonl.1` + (single generation - any previous `.1` is overwritten) and starts fresh. Implemented in all four + metrics writers (`spec-gate` and `subagent-retro`, PowerShell and bash) so the two platforms roll + at the same raw-byte boundary (`(Get-Item).Length` / `wc -c`). Inherits every SW-10 invariant: + rotation is best-effort and **never stops the append** (a silent stop would read as "metrics + working" while dropping data - worse than unbounded growth, per the ticket), a failed roll (locked + file on Windows, read-only dir) is a silent no-op, and it never alters a gate decision or the + hook's exit code. An **absent** `maxSizeKb` is treated as `1024`, so a `project-config.json` + written before this feature stays bounded with no edit; an explicit `0`/negative disables rotation, + and any non-number is invalid and also disables it (SW-22 type-strictness). `events.jsonl.1` is a + grace buffer, **not** part of any read contract - there is no consumer of the log today, and when + one exists it reads only the live file. Added to `templates/project-config.template.json` and both + hooks' embedded default configs; documented in `docs/architecture.md` and `docs/troubleshooting.md`. + New conformance fixtures at `tests/hooks/fixtures/{spec-gate,subagent-retro}/metrics-rotates-at-cap` + and `.../metrics-rotation-failure-noop` prove PS and bash rotate identically. +- Sanctioned mid-execution re-plan loop (SW-14). A new `sd-replan-loop` skill defines a **HARD Gate + Re-plan** for the two workflows that produce a `01-plan.md` + `02-tasks.md` pair - `/sd:feature` + and `/sd:refactor` - so a plan-invalidating discovery adapts the plan without violating + immutability or skipping a gate. The gate is reachable from **both** the Execute phase and the + batch/holistic **review** (the one real corpus failure surfaced at review, not mid-task). On + approval it appends an `R` entry to an append-only `## Revisions` log at the end of `01-plan.md` + (original plan prose left intact), regenerates **only** the affected task blocks in `02-tasks.md` + via `sd-spec-architect` (`TASK = plan` with `REPLAN_SCOPE`, no new architect mode), and marks each + regenerated task `Revised-by: R` (a conditional field in `sd-atomic-task-format`, like refactor's + `Parallel batch`). Like Gate Complexity, it is a **conditional** gate that fires only on its trigger, + so `/sd:feature` still advertises 3 hard gates and `/sd:refactor` still 6. It never re-plans a + `done` spec. Scope was corrected from the ticket on evidence: `/sd:bug` and `/sd:rca` produce no + task list to re-plan, and `/sd:perf` already carries its own revert-and-reselect loop, so all three + are left untouched. See `docs/adr/0003-adaptive-replan-loop.md`. +- `SL070`-`SL073` in `/sd:spec validate`: a new **revision-log integrity** band cross-checking the + `## Revisions` log in `01-plan.md` against the `Revised-by` markers in `02-tasks.md`. `SL070` + (dangling marker), `SL071` (one-sided/unreferenced revision), and `SL072` (broken append-only + history) are ๐Ÿ”ด BLOCK; `SL073` (malformed entry) is ๐ŸŸ  WARN. The checks run only when a `## Revisions` + section or a `Revised-by` marker exists, so a never-re-planned spec produces no finding. `SL074`- + `SL079` reserved. Honest boundary recorded in the ADR: `validate` is a static linter with no + Plan-phase snapshot, so it enforces the revision record's internal consistency but cannot detect an + unmarked silent edit by diffing - that is prevented by the gate, not the lint. +- Conformance fixtures at `tests/revision-log/fixtures/` (SW-14): a valid revision record that passes + and a dangling-marker record that must BLOCK, pinned to LF via `.gitattributes`. They state the + contract; like the other fixture trees they have no runner (documented, not silently skipped). +- Complexity triage + forced decomposition in `/sd:feature` (SW-13). The architect writes a + spec-level `complexity` frontmatter field (`S` | `M` | `L`, distinct from a task's + `Estimated complexity`) with a one-line rationale at create time. Gate 2 then measures the actual + plan against decompose thresholds - **> 8 tasks, > 2 production layers (Tests/Config excluded), + > 8 impacted files, or an unresolved Open question** (the `> 8` line set from the corpus canyon + between 3-4-task and 10-12-task specs; the Tests/Config exclusion keeps ordinary 2-layer mediums + under threshold). + Over threshold, Gate 2 becomes a HARD **Gate Complexity** that refuses one oversized plan and + forces a split into medium child specs (`FEAT--`, linked via existing + `/sd:spec link spawns` / `depends-on`; the parent becomes an immutable `archived` umbrella). Under + threshold it stays the normal plan approval with **zero added friction** - still 3 hard gates, not + 4. A create-time `complexity: L` also escalates models a tier (explorer -> `sonnet`, architect -> + `opus`, aliases only, per-invocation), deepening the impact map and plan for genuinely large work. + Task counts use the tolerant `sd-atomic-task-format` heading grammar, not a naive `### T` + regex. See `docs/adr/0002-complexity-triage-decomposition.md`. Linting of the field + split + integrity is deferred to SW-4 (`/sd:spec validate`). +- Field label grammar in `sd-atomic-task-format` (SW-11). Task-block labels are now matched + case-insensitively, with `**` optional and the colon permitted inside or outside the emphasis - + all three forms found in live specs (`- **Files**:`, `- Files:`, `- **Files:**`) parse + identically. A field's value runs to the next field label, not the next newline, so multi-line + `Acceptance` and `Pattern refs` values are no longer truncated. The grammar is defined once and + applies to every field and every reader; per-field matchers are forbidden. +- `SL060` (WARN) in `/sd:spec validate`: a task block in `02-tasks.md` with no `Pattern refs` + field. `SL061`-`SL069` reserved for further task-block content rules. This is the first rule + that reads *inside* a spec artifact rather than around it - see + `docs/adr/0001-validate-parses-task-content.md`. +- `docs/adr/` for specwright's own engine-level decision records, numbered the same way `/sd:adr` + numbers them (`^[0-9]{4}-.md`). Deliberately **not** `.specs/_adr/`: `.specs/` is Layer 2 + (target-project context), and this repo has none. +- Conformance fixtures at `tests/task-format/fixtures/` covering the three label forms plus a + negative case, pinned to LF via `.gitattributes`. They state the contract; they have no runner + (documented, not silently skipped). + +- Lesson surfacing, part 3 and the close of the learning loop (SW-19, under epic SW-7): + `subagent-retro.{ps1,sh}` now emit a `` block when a subagent finishes work on an + in-progress spec, gated by `hooks.subagentRetro.injectLessons` (default `true`) and + `maxLessons` (default `3`). **Placement is load-bearing:** the emit sits beside the existing + metrics call site, *before* the staleness early-exit and *before* the debounce window - moved + down to the reminder block it would have surfaced lessons only to users already behind on their + retros, the population that needs them least. The one gate it keeps is the in-progress-spec + check, and that gate *is* the relevance filter: the workflow type of the in-progress spec selects + the scope (`FEAT-` pulls `feature`, `REF-` pulls `refactor`, and `all`-scoped lessons always + apply), so there is no ranking, no scoring, and no tie-break that could diverge between + implementations. This replaces the `prompt-router` placement and the + `hooks.promptRouter.injectLessons` key named in the SW-7 epic; the epic records why. + Repetition is bounded per **session** rather than by a clock - a new `shownLessons` key in the + hook state file records what has already been surfaced, so `maxLessons` caps how many *new* + lessons appear at one stop and a session converges to silence once it has said everything + relevant. A time debounce was rejected because it would suppress a lesson the user has never + seen purely because a different one was shown recently. Four cross-implementation conformance + fixtures cover surfacing, scope filtering, already-shown state and the disabled flag, and the + conformance decision object now captures emitted lessons in emission order (sorting them would + hide exactly the selection-order divergence the fixtures exist to catch). +- Lesson aggregator, part 2 of the closed learning loop (SW-18, under epic SW-7): + `scripts/aggregate-lessons.{ps1,sh}` collect tagged lesson lines from every + `/*/05-retro.md`, dedupe them, and render `/_lessons/lessons.md`. + `--check` / `-Check` writes nothing and exits non-zero on drift, which is how idempotence is + asserted in CI. Two decisions differ from the SW-18 description and are recorded here: (1) the + **retros** are append-only and `lessons.md` is a derived file regenerated on every run - the + ticket called `lessons.md` itself append-only, but dedupe-with-a-count requires rewriting the + line, so append-only and idempotent are mutually exclusive; (2) abstraction stays in the + `sd-retro-lessons` skill, so the aggregator makes no judgement calls and its output is + reproducible. Deduplication is on (tag, scope, case- and whitespace-normalised rule); + a repeat adds a count and **never** raises severity, and the surviving wording is resolved + independently of severity (byte-smallest) so a sloppier phrasing cannot win just by carrying a + lower one. All ordering is byte-wise - `LC_ALL=C` in bash, `[string]::CompareOrdinal` plus an + ordinal dictionary comparer in PowerShell, whose culture-aware defaults would otherwise + diverge - and PowerShell writes UTF-8 without BOM and LF endings rather than going through + `Set-Content`. A committed corpus fixture and expected output pin both implementations to the + same bytes in CI; the corpus deliberately includes retros containing only `/sd:spec status` + transition lines (which must contribute zero lessons) and an out-of-enum tag (which must be + skipped). No hook is modified; surfacing (SW-19) follows. +- Structured retro lessons, part 1 of the closed learning loop (SW-17, under epic SW-7): new + `sd-retro-lessons` skill defining a 10-tag enum, the one-line lesson record + (`- [tag] severity/scope: Rule sentence.`), and the abstraction discipline that turns a + retro note into a rule portable to another codebase. The tag enum is **derived from a mined + corpus of real retros**, not authored up front - two of the three tags originally proposed + in SW-7 were confirmed by that data and one (`pattern-violation`) was retired as overlapping + `sibling-repo-assumption` and `precedent-conflict`. New standalone validators + `scripts/validate-lessons.ps1` / `.sh` enforce grammar, the closed tag/severity/scope sets, a + 120-character ceiling, and the privacy contract (no paths, extensions, backticks, line + citations, or Pascal/camel/snake_case identifiers), so `.specs/_lessons/lessons.md` is + shareable outside the org as-is. They are **separate from `scripts/validate.*` on purpose**: + that validator checks this repo's own invariants, and specwright has no `.specs/` tree - these + take a file argument and default to `.specs/_lessons/lessons.md` in the current directory, so + a consumer repo can run them directly. Paired fixtures under `tests/lessons/fixtures/` assert + both directions in CI (clean must pass, leaky must fail) - a validator that rots into a no-op + would otherwise report green forever. No hook is modified by this change; aggregation (SW-18) + and surfacing (SW-19) follow. +- Local, privacy-safe spec metrics (SW-10): `spec-gate` and `subagent-retro` now append one JSON + line per gate decision, `index.md` lifecycle transition, and subagent-stop check to + `.specs/_metrics/events.jsonl` - metadata only (timestamp, spec ID, lifecycle phase, decision, + file extension), never a file path or code content. Controlled by `hooks.metrics.enabled` in + `.claude/project-config.json`, which **defaults to `true`** - an existing install starts writing + `.specs/_metrics/events.jsonl` on the next hook run after upgrading, with no action required. Set + `hooks.metrics.enabled` to `false` to opt out entirely. No log rotation in v1 (documented as a + known limitation; ~120 bytes/line). Foundation for the closed retro-learning loop (SW-7). +- `/sd:verify ` traceability gate: SC-/AC-IDs in the feature template, a `Covers` + task field, a `06-verify.md` pass artifact, and spec-gate hook enforcement (Rule 0 in + `spec-gate.{ps1,sh}`, flag `hooks.specGate.verifyGate`) that blocks a feature (FEAT-) + `index.md` row transitioning to `done` without a passing artifact. The gate is deliberately + scoped to feature specs - bug/refactor/perf/rca workflows produce no `02-tasks.md`, so + non-FEAT rows fall through to the unconditional protected-path block exactly as before, + pending a follow-up spec that integrates verify into those workflows. `/sd:spec status` + pre-checks the artifact before mutating any file on a FEAT `in-progress -> done` transition + (prevents an `SL030` frontmatter/index strand), and `/sd:feature` Phase 6 requires an + evidence citation before ticking an `AC-` checkbox. 11 new conformance fixtures pin the + gate, including the FEAT-only scoping (`block-index-done-bug-row-protected`) and the + documented bundled-edit limitation (`allow-index-done-with-verify-bundled-edit`). (SW-6) +- Cross-implementation hook conformance suite (`tests/hooks/`): golden fixtures are piped into + both the bash and PowerShell implementation of every hook and the normalized decisions must + match; wired into CI on all matrix platforms with a self-test proving divergence detection (E4). +- Six more seeded lint rules in `examples/spec-lint-fixture/broken/` (SW-4, seam 4), taking + coverage from 18 of 26 rules to 24: `SL004` (type/prefix mismatch), `SL005` + `SL043` (illegal + status, which cannot have a legal retro log and so always drags `SL043` with it), `SL021` + (`done` with a header-only retro), `SL041` (non-contiguous transition chain) and `SL044` + (`archived -> in-progress` with an empty reason, the one WARN in the transition family). The + two remaining rules, `SL001` and `SL013`, need the fixture or the engine install itself to be + broken, so they need a corrupting harness rather than another seeded spec. +- Boundary documentation on the four seeds whose neighbouring rules overlap (SW-4, seam 4). The + transition rules `SL040`-`SL044` are close enough that a linter can collapse several into one + and still look correct, so each seed is built to make exactly one fire and names in-file which + others must stay silent - e.g. `PERF-BROKEN-012` separates `SL021` (retro exists but is empty) + from `SL043` (no retro at all), and `REF-BROKEN-013` isolates `SL041` behind two legal edges, + a matching last entry and a present retro. +- Severity-tagged output for `/sd:spec validate` (SW-4, seam 3), with a stable rule table + (`SL001`-`SL054`). BLOCK is reserved for a registry that lies about itself or evidence that was + fabricated; WARN for a real but recoverable problem that leaves the registry truthful. The + command reads `sd-severity-taxonomy` and `sd-evidence-citation` from disk at runtime, because + only agents load skills via frontmatter and `validate` invokes no subagent. +- Anchor table in `sd-severity-taxonomy` (SW-4, seam 3): BLOCK/WARN still requires an anchor, but + the legal anchor now depends on the target - a constitution `ยงN.M` or acceptance criterion for + code, a lint rule ID for the `.specs/` tree. The code row stays strict. +- `examples/spec-lint-fixture/` (SW-4, seam 3): a clean `.specs/` tree that must report all-PASS + and a seeded-broken one covering 18 of the 26 lint rules, each violation self-documented with a + `SEEDED` comment. The two perf specs are a matched pair guarding the seam-1 regression: the + correct one (unfilled baseline at `approved`) must PASS and the fabricated one must BLOCK. + Run by hand - the linter is a prompt, so CI cannot execute it; see the fixture README. +- `linked_specs` frontmatter field on all five spec templates (SW-4, seam 2), replacing the + "Linked specs" body section that only `feature.template.md` ever had - `/sd:spec link` accepted + any spec ID but had nowhere to write on the other four types. Cross-references are now a + structured YAML list maintained by `link` on both sides. +- Four structural checks in `/sd:spec validate` (SW-4, seam 2): index <-> folder symmetry (orphan + folders, ghost rows, duplicate rows), transition replay against the state machine from the + `05-retro.md` append-only log (catches a hand-edited status that bypassed `/sd:spec status`), + link resolution (no dangling links), and link symmetry (no one-sided links). +- `<>` token in the spec templates (SW-4, seam 1 of the `/sd:spec validate` linter): + a distinguishable marker for cross-phase fields, replacing 20 phase-deferred fields that were + previously indistinguishable from author-fill `<>`s. This makes the engine's + cross-phase discipline machine-checkable in both directions - `validate` can now assert that an + author-fill token is *gone* by `approved` and that a phase-deferred token is *still there*, so + pre-filling a field from memory is caught rather than merely discouraged. +- `specwright.manifest.json` (SW-3): canonical inventory contract declaring where assets live + (`areas`) and where the docs publish numbers about them (`docClaims`). Stores no counts - they + are derived from disk at runtime, so adding a command/agent/skill/template means adding the file + and nothing else. +- Check 7 (docs consistency) in `scripts/validate.{ps1,sh}`: fails the build when a published + number disagrees with disk. Also fails on a *vacuous* claim (a pattern that matches nothing, i.e. + a reworded doc that silently disabled its own check) and on an *undeclared* claim (a number no + `docClaims` entry covers). Closes the gap that let SW-1's drift reach `main` with CI green. +- `scripts/selftest-docs.{ps1,sh}`: negative self-test proving Check 7 still bites, by corrupting a + throwaway repo copy across four scenarios. Runs in CI on Ubuntu, macOS and Windows. + +### Changed +- `Pattern refs` is required on **every** atomic task (SW-11), not only on tasks that create a new + file or public symbol. A task with no precedent writes `Pattern refs: none` explicitly - `none` + asserts the architect looked, an absent field asserts nothing. Legacy blocks with the field + missing are still read as `none`, so existing `.specs/` folders keep working; the omission is a + WARN, never a block. Task-block field count is now 11 across all docs (README, + `docs/architecture.md`, `commands/feature.md`, `commands/refactor.md`, + `agents/spec-architect.md`, `agents/implementer.md`), correcting a pre-existing drift where six + of those sites still said 9 after SW-6 bumped the skill to 10. +- SW-11 explicitly did **not** add the `Context refs` field its ticket asked for. `Pattern refs` + already covers the need with 22-of-22 adoption in the live corpus; renaming would touch 37 sites + across 10 files for no measurable gain. Recorded in the ADR and on the ticket. + +### Fixed +- Four `subagent-retro` conformance fixtures (`lessons-already-shown`, `lessons-disabled`, + `lessons-scope-filter`, `lessons-surfaced`) were non-deterministic: each expects a **fresh** retro + (`emitted: false`, `stale: []`, `subagent_stop` with `stale: 0`) but shipped no `setup.json`, so the + harness copied `05-retro.md` with its on-disk mtime and the case failed on any checkout older than + `retroStaleMinutes` (default 30 min) - the hook then read the retro as stale, flipped to + `emitted: true`, and the drifted lesson selection no longer matched the golden (SW-23). Each now + ships a `setup.json` that `touch`es its retro to `ageMinutes: 5`, mirroring how `remind-stale-retro` + (120) and `metrics-emits-when-debounced` pin their fixtures; the four cases were introduced with the + lesson-injection loop (SW-19) and the omission stayed latent because the suite is usually run while + the retro is still fresh. Verified by backdating the retros two days on disk and confirming + 65 passed / 0 failed (bash output byte-identical to pwsh, so this was always a fixture defect, never + a hook divergence). Test-only; no product-code or user-facing impact. +- Four PowerShell hook config reads used PowerShell truthiness where the bash twin asks a + type-strict question, so the two implementations disagreed on the same `project-config.json` + (SW-22). Two failure modes, both invisible to a scaffolded project (the template ships + `enabled: true` and non-zero numbers) but reachable by the hand-trimmed config a user writes to + change one setting. (1) **Absent `enabled` disabled the hook in PowerShell only.** A `subagentRetro` + / `specGate` block that omitted `enabled` left the property `$null`, and `-not $null` is `$true`, + so `subagent-retro.ps1` and `spec-gate.ps1` exited silently; `prompt-router.ps1` had the same class + via `[bool]$null` (which is `$false`). All three bash twins use `== false`, so only a literal + `false` disables. The reads are now type-strict (`-is [bool]` / return `$false` only for a real + boolean `false`), mirroring the `verifyGate` and `metrics.enabled` reads already fixed this way. + (2) **An explicit `0` was treated as absent.** `subagent-retro.ps1`'s `retroStaleMinutes` and + `debounceMinutes` reads used `if ($config...)`, and PowerShell treats `0` as falsy, so an explicit + `0` was ignored and the default kept, while the bash `// 30` / `// 10` accept `0`; both now use + `$null -ne`, matching the `maxLessons` read SW-19 fixed for the same reason. Bash was already + correct, so no `.sh` changed - the fix converges the pair. Five conformance fixtures added + (`subagent-retro/{enabled-absent-still-on,stale-minutes-zero-honored,debounce-minutes-zero-honored}`, + `spec-gate/enabled-absent-warns`, `prompt-router/enabled-absent-emits`); every one fails if its + read is reverted - the previous fixture set could not, because all of them set `enabled` explicitly + and used non-zero numbers. +- `docs/architecture.md` described the metrics `stale` field as a "count of stale/missing retros + observed for that spec". It is a per-event flag, `0` or `1` - `subagent-retro` emits one event per + in-progress spec per subagent stop and sets `1` when that spec's retro is stale or missing + (`hooks/bash/subagent-retro.sh` `emit_subagent_stop_metric`). Retro pressure is measured by + counting `1`s over time, never by reading a single value as a quantity. Found while building the + first reader of the log (SW-16); the field had no consumer until now, so nothing had contradicted + the prose. +- Check 7 could not see three whole classes of inventory claim, and each class had let a real, + wrong number sit in a tracked doc through many green runs (SW-24). The `claimPhrases` vocabulary + in `specwright.manifest.json` now closes all three: + (1) **Spelled-out numbers.** Every pattern was anchored on `[0-9]+`, so `README.md`'s intro line + saying "seven reusable skills" was invisible from the moment an eighth skill shipped in SW-17. + (2) **Capitalisation.** Adding a lowercase word alternation is *not* enough - a spelled-out count + in prose is usually sentence-initial, which is exactly where it is capitalised. `Three hooks ship + in cross-platform pairs` in `docs/architecture.md` escaped a lowercase-only fix. POSIX ERE (bash + `[[ =~ ]]`) has no inline case flag, so each word carries an explicit `[Tt]`-style class rather + than a flag only one of the two engines supports. + (3) **Bare nouns.** Only decorated forms were listed (`slash commands`, `workflow commands`), so + `Five commands invoke no subagent` matched nothing at all - a line added by SW-16 itself, one + commit before this one. Bare `commands` and `agents` are now in the vocabulary. + Measured across the whole tracked tree: 4 real claims surfaced, 0 false positives. + The four offending lines are resolved under a policy now recorded in the manifest + (`$claimPolicyComment`): **if a number is derivable from an area, write it in digits and declare + it; if it is not derivable, publish no number and let the names carry the meaning.** So + `README.md`'s intro became digits with five new `docClaims` entries, `Three hooks ship ...` + became `3 hooks ship ...` with a `docClaims` entry against `hooksPowerShell`, and the two counts + that no area derives (`Five commands invoke no subagent ...`, `the two hooks that record`) had + the number removed - both already listed every item by name. + `selftest-docs.{sh,ps1}` grow from 4 scenarios to 6, one per new escape, and they are kept + separate on purpose: a fix that only adds a lowercase alternation passes scenario 4 and fails 5, + and a fix that only handles decorated nouns passes 5 and fails 6. Both were verified by + sabotage - reverting the vocabulary to digit-only makes scenario 5 report `THE CHECK DID NOT + BITE` while 6 stays green, and removing the bare-noun entries produces the mirror image. + Check 7 now validates 52 published claims, up from 46. +- `subagent-retro.ps1` terminated its emitted block with `[Console]::Out.WriteLine`, which appends + `[Environment]::NewLine` - CRLF on Windows - so its output differed from `subagent-retro.sh` by + exactly one byte on the final line. Both the `` and the new `` + block now `Write` an explicitly LF-terminated string. Pre-existing; surfaced by SW-19's + byte-comparison requirement. +- `selftest-docs.{sh,ps1}` scenarios 2 and 3 had silently stopped testing anything (SW-20). Both + planted their corruption by string-replacing the literal `**11 slash commands**`; the repo now + ships 12, so the pattern matched nothing, the sandbox copy was never corrupted, the validator + correctly passed, and the scenario reported `THE CHECK DID NOT BITE`. Scenario 2's setup guard + could not catch this because it only checked that the *planted* text was present - and the + planted value (12) had since become the **true** value already in `README.md`, so the guard + found the real line and passed vacuously. Scenario 3 had no guard at all. Both counts are now + derived from disk (plant `true + 1`, which can never collide), and both scenarios assert the + *transition* rather than the destination, reporting a `fixture setup` failure when the pattern + does not match. Check 7 itself was never broken - only the proof that it still bites, which had + been absent since the 12th command landed on an unpushed branch CI never ran. A hardcoded count + in the selftest was the last instance in the repo of the exact anti-pattern + `specwright.manifest.json` exists to abolish. +- `subagent-retro`'s debounce state file, an on-disk contract shared between the two + implementations, was not written in the same shape by both (SW-5): `subagent-retro.ps1` wrote + the round-trip `o` format with 7 fractional digits while `subagent-retro.sh` wrote whole + seconds, so only the bash reader ever had to cope with fractions. PowerShell now writes the same + whole-second `yyyy-MM-ddTHH:mm:ssZ` stamp. The bash reader's two date fallbacks were also both + wrong on BSD/macOS: neither passed `-u`, so a UTC stamp was read as local time and skewed the + debounce window by the machine's offset, and the BSD branch handed `date -f` a string with a + trailing `Z` it would warn about on stderr - breaking the hook's silence. Both branches now + force UTC and the value is trimmed before parsing. The debounce branch had no fixture coverage + at all until now; `setup.json` grew a `write` action that plants a file whose content carries a + `{{UTCNOW-45M}}`-style token resolved at run time, so a state-file fixture cannot rot. +- `spec-gate` path matching disagreed on case (SW-5). `spec-gate.ps1` compared with + `OrdinalIgnoreCase` throughout; `spec-gate.sh` used case-sensitive `==` and `case` globs, so a + protected entry of `.specs/Constitution.md` blocked an edit to `.specs/CONSTITUTION.md` under + PowerShell and allowed it under bash. bash now lowercases both sides for the protected list, the + allow-listed directory prefixes and the cwd-prefix strip. Case-insensitive is the right + semantics for a gate, not merely the parity-preserving one: Windows and macOS filesystems are + case-insensitive by default, so a case-sensitive rule is bypassable there by retyping the path. +- The `spec-gate` basename allow-list let source files through under a documentation name (SW-5). + Both implementations allow-listed anything called `README*`, so `README.py` bypassed the gate + outright, and the two disagreed on multi-dot names - bash's `README.*` glob allowed + `README.old.py` while the PowerShell regex's single optional extension did not match it at all. + Only EXTENSION-LESS `README`/`CHANGELOG`/`CONTRIBUTING`/`LICENSE`/`NOTICE`/`AUTHORS` are now + allow-listed by name; everything with an extension is decided by the extension rules, so + `README.md` is still a doc and `README.old.py` is now correctly gated as Python. +- `spec-gate.sh` applied NO protected paths when `.claude/project-config.json` was absent or + unparseable (SW-5), while `spec-gate.ps1` applied its built-in defaults - so on a project that + had not run `/sd:setup` yet, the most common state there is, editing `.specs/constitution.md` + was blocked under PowerShell and silently allowed under bash. The bash fallback is now the same + full default document (`.specs/constitution.md`, `.specs/index.md`, `LICENSE` protected; + `mode: warn`) instead of `{}`. `Get-ProjectConfig` in all three PowerShell hooks now reads the + config with `-ErrorAction Stop`, since the script-wide `SilentlyContinue` preference could + otherwise turn a malformed config into a non-terminating error that skips the `catch` and + returns `$null` rather than the defaults. `prompt-router` and `subagent-retro` were checked for + the same asymmetry and have none - every value they read has a matching `//` default - which is + now stated in both scripts so a future read does not quietly reintroduce it. +- Conformance decision objects were too coarse to prove much (SW-5). `spec-gate` decisions kept + only `decision`/`permissionDecision` and threw away the human-readable `reason`, which the two + implementations hand-duplicate - the reason strings could have drifted completely and all 20 + cases would still have passed. The decision now carries `reason`, and reports + `REASON-MISMATCH-BETWEEN-SCHEMA-HALVES` if the legacy and `hookSpecificOutput` copies of it ever + disagree. `subagent-retro` decisions likewise dropped the measured age and the threshold it was + compared against, so the two implementations could have disagreed on the arithmetic unnoticed; + both are now asserted, and `subagent-retro.sh` rounds the age to the nearest minute instead of + truncating it, matching `subagent-retro.ps1`'s `[Math]::Round`. Every decision object now also + carries `stderr`, so the repo's "every failure path exits 0 SILENTLY" invariant is actually + checked rather than assumed - a hook that regressed into printing a diagnostic on every + invocation used to pass. +- Two bash hook bugs surfaced by the cross-implementation conformance suite (SW-5). `prompt-router`, + `spec-gate` and `subagent-retro` all read `enabled` with jq's `//` operator, which treats an + explicit JSON `false` as absent - a project that set `enabled: false` in `project-config.json` + got a hook that ran anyway; the three scripts now use an `if`/`then`/`else` jq expression that + compares directly against `false`. Separately, `spec-gate`'s protected-path loop never blocked a + protected path on Windows because Windows `jq.exe` emits CRLF for `join("\n")` output, leaving a + trailing `\r` on each path that broke the exact-match comparison; the loop now strips a trailing + CR before comparing, mirroring the existing strip in `prompt-router.sh`'s keyword loop. +- Three spec stubs in `examples/spec-lint-fixture/broken/` (SW-4, seam 4) raised an unlisted + `SL011` BLOCK: `BUG-BROKEN-001` and `BUG-BROKEN-008` carried none of the bug template's four + phase-3 tokens and `RCA-BROKEN-005` carried four of the rca template's seven, because each had + dropped the enclosing section wholesale. At `draft` a spec must carry at least its template's + per-phase token count, so all three failed a rule the fixture's expected-findings table does + not list - which would have read as a linter bug rather than a fixture one. Found by running + the linter against the tree rather than by inspection, which is the first time the SW-4 + acceptance criterion was executed end-to-end rather than reasoned about. +- Placeholder tokens spelled out inside `` comments in the fixture (SW-4, + seam 4). A token named in a comment is indistinguishable from a real one to any linter that + scans line-wise rather than parsing, so the comments explaining the placeholder rules were + themselves seeding phantom findings in a tree whose contract is "these findings and no others". + The comments now describe tokens in prose. +- `/sd:spec link` inverse map (SW-4) was partial and ambiguous: it accepted 9 relations but + defined inverses for only 5, so `blocks`, `blocked-by`, `spawned-by` and `superseded-by` had no + defined other side. `depends-on` and `blocked-by` also asserted the same edge in two spellings. + `blocked-by` is now an input alias normalized to `depends-on`, and the map is total and closed - + every stored relation has exactly one inverse, which is what makes link symmetry checkable. +- `/sd:spec validate` required-field rules (SW-4) had drifted from + `skills/sd-spec-templates/SKILL.md`, the skill that authors the specs: `validate` checked only + `id`/`type`/`status`/`created` (+`severity` for bug, +`incident_started` for rca), so it passed + malformed specs missing `target_metric` (perf), `smell` (refactor), `jira` (feature/bug) and + `incident_resolved` (rca). The rules are now per-type and match the skill. +- `/sd:spec validate` placeholder rule (SW-4) contradicted the templates it validates: "status >= + `approved` -> no `<>` remaining" failed a *correct* perf spec, whose baseline field + must still be unfilled at `approved` by the cross-phase rule in `CLAUDE.md`. Author-fill and + phase-deferred tokens are now separate forms with separate rules. +- Doc count/inventory drift (SW-1): `README.md` listed `/sd:setup` at no gates (`-` -> `2`, matching + the two approval gates in `commands/setup.md`) and omitted `sd-docs-writer` from + `sd-evidence-citation`'s "Used by" list (4 agents, not 3). +- Stale `MSSQL` references in the docs, left over from the stack-agnostic database rename + (`mcp.mssql` -> `mcp.database`): `docs/architecture.md` listed a hardcoded MSSQL tool in + `sd-debugger`'s tool surface and an `mssql` server in the project-scope MCP table; `README.md` + named MSSQL in the MCP-friendly summary and the MCP table; `docs/troubleshooting.md` had an + MSSQL-titled section. All now describe the project-provided database MCP, matching + `agents/debugger.md` and `templates/project-config.template.json`. Addresses `REVIEW-TODO.md` + item 5's doc half; the `agents/debugger.md` body-vs-allowlist defect it also names remains open. +- `spec-gate`'s protected-path matching could be bypassed via `..` path traversal under the bash + hook: `spec-gate.ps1` normalizes `file_path` with `[System.IO.Path]::GetFullPath`, which resolves + `..`/`.` segments before comparing against `paths.protected`, but `spec-gate.sh`'s `normalize_rel` + only normalized separators and stripped the cwd prefix - it never collapsed `..`. A path like + `/src/../.specs/constitution.md` reached the protected constitution file while presenting a + relative form (`src/../.specs/constitution.md`) that matched nothing in `paths.protected`, so + bash exited 0 and silently allowed editing a protected file that PowerShell correctly blocked. + `spec-gate.sh` now collapses `.`/`..` segments with pure string processing (no `realpath`, + `readlink -f`, or `cd`, since the file may not exist yet under `Write` and the decision must not + depend on filesystem state) before the protected-path and allow-list comparisons, clamping a + rooted `..` at its own root the same way `GetFullPath` does, and falling back to the raw, + un-collapsed path when resolution would escape the workspace entirely - matching + `ConvertTo-RelativePath`'s own fallback branch. Covered by three new conformance fixtures: + `..` traversing into a protected file, a bare `.` segment, and a benign `..` that resolves to a + non-protected code file, proving the fix does not over-block. +- The bash-side `..` traversal fix above was one-sided: `spec-gate.ps1` had the mirror-image + weakness, still live, letting the same class of edit through under PowerShell. Its + `ConvertTo-RelativePath` called `[System.IO.Path]::GetFullPath($FilePath)` on a RELATIVE + `file_path`, which resolves it against this hook PROCESS's own working directory rather than the + `cwd` supplied in the hook payload; the result then failed the base-prefix check and fell through + to the raw, un-collapsed path, matching nothing in `paths.protected`. A relative + `src/../.specs/constitution.md` therefore reached the protected constitution file while + PowerShell exited 0 silently and bash (already fixed) correctly blocked it. Separately, + `GetFullPath` preserves a trailing path separator, so `/.specs/constitution.md/` failed the + protected-path equality test outright and, since `GetExtension` also returns `""` for a + trailing-separator path, was not even caught by the code-file rule - a second silent bypass. + `spec-gate.ps1` now collapses `.`/`..` segments with the same pure string processing as + `spec-gate.sh`'s `collapse_dot_segments`/`normalize_rel` (a relative `file_path` is collapsed + directly rather than joined onto the process cwd; a trailing separator collapses away as a + no-op segment) so the two implementations resolve identically. `prompt-router` and + `subagent-retro` were checked for the same pattern and do not have it - neither reads + `tool_input.file_path` or compares a user-supplied path against `paths.protected`. Covered by + three new conformance fixtures: a relative `..` traversal into the protected constitution file, + a trailing separator on the protected constitution file, and a benign relative `..` resolving to + a non-protected code file, proving the fix does not over-block. +- Extended `.gitattributes` with a repo-wide `* text=auto` default plus `*.sh` and `*.ps1` pinned to + `eol=lf`, so shell scripts no longer check out as CRLF on Windows, where a `#!/usr/bin/env bash` + line with a trailing CR fails with `bad interpreter` and heredocs / `[[ ... ]]` mis-parse (SW-21). + Folds the previously narrow, fixtures-only policy into a repo-wide one; the byte-comparison fixture + pins (`tests/**`) stay because `* text=auto` still yields a native CRLF checkout on Windows. The + first checkout after this lands renormalizes line endings in existing Windows working trees - a + one-time large diff, not a real change. + ## [1.4.0] - 2026-07-05 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 7aff799..07ace7b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ Note: `templates/CLAUDE.template.md` is the template `/sd:setup` scaffolds into # Sandbox install test (run before any PR touching install/hooks/commands/agents) .\install\install.ps1 -BasePath C:\temp\sd-test -Get-ChildItem C:\temp\sd-test\commands\sd\ # expect 11 .md files +Get-ChildItem C:\temp\sd-test\commands\sd\ # expect 13 .md files .\install\uninstall.ps1 -BasePath C:\temp\sd-test -Force # round-trip: removes the 5 sd\ dirs Remove-Item -Recurse -Force C:\temp\sd-test # cleanup ``` @@ -44,11 +44,11 @@ Every PR adds a line under `## [Unreleased]` in `CHANGELOG.md` (Keep a Changelog | Source | Installs to | Contents | |---|---|---| -| `commands/` | `~/.claude/commands/sd/` | 11 slash commands (`/sd:feature`, `/sd:bug`, `/sd:rca`, `/sd:refactor`, `/sd:perf`, `/sd:spec`, `/sd:explore`, `/sd:review`, `/sd:setup`, `/sd:release`, `/sd:adr`) | +| `commands/` | `~/.claude/commands/sd/` | 13 slash commands (`/sd:feature`, `/sd:bug`, `/sd:rca`, `/sd:refactor`, `/sd:perf`, `/sd:spec`, `/sd:explore`, `/sd:review`, `/sd:setup`, `/sd:release`, `/sd:adr`, `/sd:verify`, `/sd:status`) | | `agents/` | `~/.claude/agents/sd/` | 6 subagents (`sd-spec-architect`, `sd-code-explorer`, `sd-debugger`, `sd-implementer`, `sd-reviewer`, `sd-docs-writer`) | | `hooks/powershell/` + `hooks/bash/` | `~/.claude/hooks/sd/` | 3 hooks ร— 2 platforms (`prompt-router`, `spec-gate`, `subagent-retro`) | | `templates/` | `~/.claude/templates/sd/` | 4 setup templates + 5 spec templates in `specs/` | -| `skills/` | `~/.claude/skills/sd/` | 6 rule packs, one folder per skill with `SKILL.md` | +| `skills/` | `~/.claude/skills/sd/` | 8 rule packs, one folder per skill with `SKILL.md` | Source filenames are unprefixed (`agents/reviewer.md`); the `sd-`/`sd:` namespace comes from frontmatter `name:` and the `sd/` install subfolder. The namespace exists for collision avoidance and clean uninstall โ€” never use bare names when assets reference each other. @@ -57,8 +57,8 @@ Source filenames are unprefixed (`agents/reviewer.md`); the `sd-`/`sd:` namespac - **Commands** are phased workflow definitions with **hard gates** โ€” checkpoints that STOP and wait for explicit user approval (silence โ‰  approval). Phase 0 always bootstraps (read CLAUDE.md, constitution, project-config, index); a state machine at the top of each file defines resume behavior on re-invocation. Gates marked HARD (bug reproduction, perf baseline) have no override path. - **Agents** declare frontmatter: `name`, `description`, `color`, `model`, minimal `tools` allowlist, and a `skills:` list. Tool allowlists enforce roles structurally โ€” the reviewer has no write tools, so it *cannot* auto-fix. Heavy reasoning agents (architect, debugger, reviewer) use `sonnet`; mechanical agents (explorer, implementer) use `haiku`. - **Skills** are shared rule packs loaded into agent context via frontmatter reference. A rule used by multiple agents (e.g. `sd-evidence-citation`, used by 3) lives in one `SKILL.md`, never copy-pasted into agent bodies. -- **Hooks** inject context (`prompt-router` on UserPromptSubmit, `subagent-retro` on SubagentStop) or guard edits (`spec-gate` on PreToolUse blocks code edits with no in-progress spec). `spec-gate` denials emit a dual-format JSON object carrying both the new schema (`hookSpecificOutput.permissionDecision: "deny"`) and the legacy schema (`decision: "block"`) for CLI version compatibility. -- **Spec artifacts** (`.specs//00-spec.md` โ€ฆ `05-retro.md`) are the input contract between agents, not after-the-fact docs. Spec templates intentionally leave cross-phase fields empty with `` comments โ€” workflows enforce sequencing through those empty fields. Do not pre-fill them. +- **Hooks** inject context (`prompt-router` on UserPromptSubmit, `subagent-retro` on SubagentStop) or guard edits (`spec-gate` on PreToolUse blocks code edits with no in-progress spec). `spec-gate` denials emit a dual-format JSON object carrying both the new schema (`hookSpecificOutput.permissionDecision: "deny"`) and the legacy schema (`decision: "block"`) for CLI version compatibility. `spec-gate` and `subagent-retro` also *record*: metadata-only events (spec ID, phase, decision - never a path) appended to `.specs/_metrics/events.jsonl`, opt-out via `hooks.metrics.enabled: false`. +- **Spec artifacts** (`.specs//00-spec.md` โ€ฆ `05-retro.md`) are the input contract between agents, not after-the-fact docs. Spec templates intentionally leave cross-phase fields empty, marked with a `<>` token (plus an explanatory `` comment) โ€” workflows enforce sequencing through those empty fields. Do not pre-fill them. ## Hard rules when editing @@ -67,7 +67,10 @@ Source filenames are unprefixed (`agents/reviewer.md`); the `sd-`/`sd:` namespac 3. **Model fields are aliases only** (`sonnet`, `haiku`, `opus`, `inherit`) โ€” never full model IDs. 4. **Stack-agnostic, no exceptions.** Commands and agents must not contain hardcoded stack commands (`dotnet test`, `npm test`) or language assumptions; reference `commands.test` etc. from `project-config.json`. An agent that hardcodes a stack is a bug. 5. **Minimal tool allowlists.** Read-only agents never get `Write`; add a tool only if the role requires it. -6. **Templates** use `<>` for user-filled fields and stay short. +6. **Templates** use `<>` for user-filled fields and stay short. Spec templates also + use `<>` for cross-phase fields that Phase N must fill from measured evidence โ€” + the two forms have opposite rules (author-fill must be gone by `approved`; phase-deferred must + still be there), and `/sd:spec validate` enforces both. Never pre-fill a `<>`. ## Style diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7111a69..f3c18f4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,6 +9,7 @@ per-file-type guidelines, and how to test changes locally. - [Project goals and non-goals](#project-goals-and-non-goals) - [Repo layout](#repo-layout) +- [The manifest](#the-manifest) - [PR process](#pr-process) - [Per-file-type guidelines](#per-file-type-guidelines) - [Commands (`commands/*.md`)](#commands-commandsmd) @@ -40,7 +41,7 @@ per-file-type guidelines, and how to test changes locally. ``` specwright/ - commands/ # 11 slash commands (markdown with frontmatter) + commands/ # 13 slash commands (markdown with frontmatter) agents/ # 6 subagent definitions (markdown with frontmatter) hooks/ powershell/ # 3 PowerShell hooks @@ -54,6 +55,51 @@ specwright/ --- +## The manifest + +`specwright.manifest.json` is the canonical inventory contract. Check 7 of +`scripts/validate.{ps1,sh}` reads it and fails the build when a number published in the docs +disagrees with what is actually on disk. A discipline tool that misdescribes itself has no +standing to lecture anyone about specs. + +The manifest **stores no counts**. It declares where assets live (`areas`, each with a `glob` or +an explicit `files` list) and where the docs make claims about them (`docClaims`); the numbers are +derived from disk at runtime. That is deliberate - a manifest holding hardcoded counts would be a +third place to update on every change and would reintroduce exactly the drift it exists to prevent. + +What this means in practice: + +- **Adding a command, agent, skill, or template**: add the file. Nothing else. The count follows. +- **Publishing a number in the docs**: add a `docClaims` entry - `file`, a `pattern` with exactly + one capture group around the number, and the `equals` quantity it must match. A number with no + entry fails the build as an *undeclared claim*, so this is not optional. +- **Rewording a sentence that carries a number**: update its `pattern` too. A pattern that matches + nothing fails as a *vacuous claim* rather than passing quietly - otherwise a reword would turn + the check into a no-op that still reports green. +- **Writing intentionally historical docs** (superseded counts as a past-state record): put the + path in `historicalExclusions`. `docs/history/`, `docs/superpowers/` and `CHANGELOG.md` are + already excluded. Never "fix" their numbers to match today's disk state. + +Two constraints on `pattern`: it must be valid in **both** POSIX ERE (bash `[[ =~ ]]`) and .NET +(PowerShell), so use `[0-9]` rather than `\d` and avoid lookarounds; and it is matched +**case-sensitively** on both platforms. + +`scripts/selftest-docs.{ps1,sh}` proves Check 7 still bites, by corrupting a throwaway copy of the +repo and asserting the validator catches it. CI runs it on all three OSes. + +`tests/hooks/run-conformance.ps1` (single cross-platform pwsh script by design - it must run BOTH +hook implementations in one process, so a bash twin would itself be a drift risk) pipes every +golden fixture under `tests/hooks/fixtures/` into the bash and PowerShell implementation of each +hook and fails if their normalized decisions diverge from each other or from the golden. Add a +fixture case whenever you add hook behavior; `-SelfTest` proves the harness still detects +divergence. + +Check 7 needs `jq` on Unix and **fails loudly without it**. This is the opposite of the hook rule +below (hooks exit `0` silently when `jq` is missing so they never block a user on their own bugs) - +a validator that skipped itself for a missing tool would turn CI green while checking nothing. + +--- + ## PR process 1. **Open an issue first** for anything larger than a typo or a small docs fix. State: @@ -71,8 +117,9 @@ specwright/ 4. **Run the validator** before opening the PR: `scripts/validate.ps1` (Windows) or `scripts/validate.sh` (Unix) runs every engine-invariant check at once (ASCII, hook-pair parity, - model aliases, install-target counts, changelog gate). CI runs the same on Windows + Ubuntu. - See also the [Local install test](#local-install-test) for a manual install smoke test. + model aliases, install-target counts, changelog gate, docs consistency). CI runs the same on + Windows + Ubuntu. See also the [Local install test](#local-install-test) for a manual install + smoke test, and [The manifest](#the-manifest) for what Check 7 enforces. 5. **Update the changelog.** Add a line under `## [Unreleased]` in `CHANGELOG.md`. diff --git a/README.md b/README.md index 305785b..6ad466e 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # specwright > **Spec-driven development workflows for Claude Code.** -> Eleven slash commands, six specialized subagents, three guard-rail hooks, nine templates, six reusable skills - all under the `sd:` namespace, stack-agnostic, cross-platform, and ready to drop into any project. +> 13 slash commands, 6 specialized subagents, 3 guard-rail hooks, 9 templates, 8 reusable skills - all under the `sd:` namespace, stack-agnostic, cross-platform, and ready to drop into any project. [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![Claude Code](https://img.shields.io/badge/Claude%20Code-compatible-blue)](https://docs.claude.com/en/docs/claude-code) @@ -26,13 +26,13 @@ The system is **stack-agnostic**. Agents read `CLAUDE.md` and `constitution.md` | Capability | What you get | |---|---| -| **11 slash commands** | `/sd:feature`, `/sd:bug`, `/sd:rca`, `/sd:refactor`, `/sd:perf`, `/sd:spec`, `/sd:explore`, `/sd:review`, `/sd:setup`, `/sd:release`, `/sd:adr` | +| **13 slash commands** | `/sd:feature`, `/sd:bug`, `/sd:rca`, `/sd:refactor`, `/sd:perf`, `/sd:spec`, `/sd:explore`, `/sd:review`, `/sd:setup`, `/sd:release`, `/sd:adr`, `/sd:verify`, `/sd:status` | | **6 specialized subagents** | `sd-spec-architect`, `sd-code-explorer`, `sd-debugger`, `sd-implementer`, `sd-reviewer`, `sd-docs-writer` | | **3 cross-platform hooks** | `prompt-router`, `spec-gate`, `subagent-retro` (PowerShell + bash) | | **9 templates** | 4 setup templates + 5 spec templates (feature / bug / refactor / perf / rca) | -| **6 reusable skills** | `sd-severity-taxonomy`, `sd-hypothesis-tree`, `sd-atomic-task-format`, `sd-evidence-citation`, `sd-spec-templates`, `sd-pattern-discipline` | +| **8 reusable skills** | `sd-severity-taxonomy`, `sd-hypothesis-tree`, `sd-atomic-task-format`, `sd-evidence-citation`, `sd-spec-templates`, `sd-pattern-discipline`, `sd-retro-lessons`, `sd-replan-loop` | | **Cross-platform installer** | `install.ps1` for Windows, `install.sh` for macOS/Linux. Content-hash dedup, timestamped backups, dry-run mode | -| **MCP-friendly** | Tooled out of the box for Atlassian, Context7, sequential-thinking, GitNexus, MSSQL, Playwright, Tavily | +| **MCP-friendly** | Tooled out of the box for Atlassian, Context7, sequential-thinking, GitNexus, your project's database MCP, Playwright, Tavily | | **Stack-agnostic** | Works for .NET, Node, Python, Go, Rust, anything with a `CLAUDE.md` | | **Cost-aware** | Sonnet for reasoning, Haiku for execution. Typical feature run ~$2-3 | @@ -88,7 +88,7 @@ Per-project artifacts (`.specs/`, `.claude/`, project `CLAUDE.md`) remain untouc | Command | Type | Hard gates | Purpose | |---|---|---|---| -| `/sd:feature ` | Workflow | 3 | Spec-driven feature: spec -> impact -> plan -> execute -> batch review -> close | +| `/sd:feature ` | Workflow | 3 | Spec-driven feature: spec -> impact -> plan (complexity triage) -> execute -> batch review -> close | | `/sd:bug ` | Workflow | 5 | Root-cause-first fix: capture -> reproduce -> investigate -> failing test -> minimal fix -> regression | | `/sd:rca ` | Workflow | 3 | Incident analysis. **Output is the spec - no code change.** | | `/sd:refactor ` | Workflow | 6 | Coverage-gated restructure: requires >=80% coverage before touching code | @@ -96,9 +96,11 @@ Per-project artifacts (`.specs/`, `.claude/`, project `CLAUDE.md`) remain untouc | `/sd:spec ` | Utility | - | Spec registry: list, show, status, link, archive, revive, search, validate, stats | | `/sd:explore ` | Utility | - | Read-only code navigation, single subagent call, optional save | | `/sd:review [path / "recent" / "spec ID"]` | Utility | - | Standalone constitution-compliance review with severity tags | -| `/sd:setup` | Utility | - | Idempotent project scaffold (interactive) | +| `/sd:setup` | Utility | 2 | Idempotent project scaffold (interactive) | | `/sd:release [version]` | Utility | 1 | Release notes from `done` specs -> Keep-a-Changelog sections, then archive them | | `/sd:adr ` | Utility | 1 | Author an ADR from a spec's decisions under `.specs/_adr/` | +| `/sd:verify ` | Utility | - | Verify criterion -> task -> test traceability; writes the close-out gate artifact | +| `/sd:status` | Utility | - | Read-only summary of the metrics log + spec registry: in progress, gate activity, friction | --- @@ -125,10 +127,11 @@ Skills are shared markdown rules that agents reference via frontmatter. They liv |---|---|---| | `sd-severity-taxonomy` | `sd-reviewer` | BLOCK / WARN / SUGGEST / PASS severity rules and the mandatory review output format. | | `sd-hypothesis-tree` | `sd-debugger` | Enumerate-and-verify protocol with the 5 mental models, score formula, and proximate-vs-root "why" ladder. | -| `sd-atomic-task-format` | `sd-spec-architect`, `sd-implementer` | The atomic task block (9 required fields + `Pattern refs`), canonical enums (`Step type`, `Complexity`, `Reversibility`), and atomicity rules. | -| `sd-evidence-citation` | `sd-code-explorer`, `sd-debugger`, `sd-reviewer` | Citation discipline โ€” every finding cites `file:line`. Snippet length, grouping, and what counts as evidence. | +| `sd-atomic-task-format` | `sd-spec-architect`, `sd-implementer` | The atomic task block (11 required fields, including `Pattern refs`), canonical enums (`Step type`, `Complexity`, `Reversibility`), and atomicity rules. | +| `sd-evidence-citation` | `sd-code-explorer`, `sd-debugger`, `sd-reviewer`, `sd-docs-writer` | Citation discipline โ€” every finding cites `file:line`. Snippet length, grouping, and what counts as evidence. | | `sd-spec-templates` | `sd-spec-architect` | Per-template authoring rules (feature / bug / refactor / perf / rca), including which cross-phase fields to leave empty. | | `sd-pattern-discipline` | `sd-spec-architect`, `sd-implementer`, `sd-reviewer` | Pattern discovery and adherence โ€” new code mirrors cited precedents (`Pattern refs`); existing utilities are reused, not duplicated. | +| `sd-replan-loop` | `sd-spec-architect` (frontmatter); `/sd:feature`, `/sd:refactor`, `/sd:spec validate` (read at runtime) | Sanctioned mid-execution re-plan: the HARD Gate Re-plan, the append-only `## Revisions` log, and the `Revised-by` marker that keeps adaptivity from violating immutability. | Agents declare the skills they apply via a `skills:` list in their frontmatter, e.g.: @@ -221,7 +224,7 @@ Full architecture: [`docs/architecture.md`](docs/architecture.md). | **Context7** | `sd-spec-architect`, `sd-implementer`, `sd-debugger` | Pull current library docs (no stale training-data examples) | | **sequential-thinking** | `sd-debugger`, `sd-reviewer` | Structured hypothesis enumeration and verification | | **GitNexus** | `sd-code-explorer`, `sd-debugger`, `sd-reviewer` | Fast symbol search, callers, call graph | -| **MSSQL** | `sd-debugger` (SELECT/EXPLAIN only) | Inspect schema and query plans during investigation | +| **Database** (project-provided, e.g. `mssql`, `postgres`) | `sd-debugger` (SELECT/EXPLAIN only) | Inspect schema and query plans during investigation | | **Playwright** | optional | E2E reproduction for `/sd:bug` | | **Tavily** | `sd-debugger` | Web search for error signatures / library issues | diff --git a/agents/implementer.md b/agents/implementer.md index f7fccfd..9b9d0c5 100644 --- a/agents/implementer.md +++ b/agents/implementer.md @@ -20,7 +20,7 @@ If a task feels like two changes, STOP and tell the main thread. 1. **Read `CLAUDE.md`** for stack, conventions, forbidden patterns, build/test/lint commands. 2. **Read `.specs/constitution.md`** sections cited in the spec's "Constitution check". 3. **Read the `SPEC_REF`** (`00-spec.md`). -4. **Read the `TASK_DETAILS`** block carefully. The 9 required fields (Files, Layer, Step type, Test, Acceptance, Depends on, Conflicts with, Estimated complexity, Reversibility) plus the optional `Pattern refs` field are the contract โ€” see **sd-atomic-task-format** skill for definitions. A task without `Pattern refs` is treated as `Pattern refs: none`. +4. **Read the `TASK_DETAILS`** block carefully. The 11 required fields (Files, Layer, Step type, Test, Acceptance, Covers, Depends on, Conflicts with, Estimated complexity, Reversibility, Pattern refs) are the contract โ€” see **sd-atomic-task-format** skill for definitions, including the field label grammar (label matching is case-insensitive, `**` optional, colon inside or outside the emphasis). A legacy task with no `Pattern refs` field is still read as `Pattern refs: none` โ€” do not refuse it. 5. **Read each file in `TASK_DETAILS.Files`** before editing it. Never edit a file you have not just read. 6. **Read every file cited in `TASK_DETAILS.Pattern refs`** (cap: 3) before creating or editing anything. These are the precedents your output must mirror โ€” see **sd-pattern-discipline** skill. 7. **If `IMPACT_REF` is provided** (evidence/analysis file, e.g. `03-decisions.md`) and the task creates a new file but has no Pattern refs, read ONLY the "Precedents & conventions" section of it. Do not read the whole file. diff --git a/agents/reviewer.md b/agents/reviewer.md index 15d917a..5bd419d 100644 --- a/agents/reviewer.md +++ b/agents/reviewer.md @@ -32,7 +32,7 @@ Key reminders: - Every BLOCK / WARN cites `ยงN.M` or a spec acceptance criterion โ€” no anchor = no BLOCK/WARN. - Every finding cites `file:line` (see **sd-evidence-citation** skill). - If a section has zero findings, write `_No findings._` โ€” never omit the section. -- Pattern findings (see **sd-pattern-discipline** skill): deviation from an explicit `Pattern refs` entry is WARN, anchored to the task block. Convention drift with no Pattern ref and no constitution anchor is SUGGEST. Never BLOCK solely because a task lacks a `Pattern refs` field. +- Pattern findings (see **sd-pattern-discipline** skill): deviation from an explicit `Pattern refs` entry is WARN, anchored to the task block. Convention drift with no Pattern ref and no constitution anchor is SUGGEST. Never BLOCK solely because a task lacks a `Pattern refs` field โ€” the field is required on every task, but a missing one is a spec-authoring defect that `/sd:spec validate` reports as `SL060` (WARN), not a defect in the code you are reviewing. --- @@ -63,6 +63,9 @@ Checklist (in addition to per-task items applied across the union of changes): - [ ] Test coverage did not decrease (compare to Phase 3 measurement in spec). - [ ] No new constitution exceptions across the union. - [ ] New files follow the precedents cited in their tasks' `Pattern refs`; no new utility duplicates an existing one (cite both `file:line`). +- [ ] Scenario/criterion coverage: every SC- and AC- ID in `00-spec.md` appears in at + least one task's `Covers` field in `02-tasks.md`, and each covering task's `Test` exists. + Report an uncovered ID as a ๐Ÿ”ด BLOCK finding citing the spec line. This is broader scope - look for emergent issues that per-task review missed. diff --git a/agents/spec-architect.md b/agents/spec-architect.md index 3a5323e..c92a94e 100644 --- a/agents/spec-architect.md +++ b/agents/spec-architect.md @@ -8,6 +8,7 @@ skills: - sd-atomic-task-format - sd-spec-templates - sd-pattern-discipline + - sd-replan-loop --- You are the spec architect for specwright. You produce written artifacts that downstream agents and the user trust: specs, plans, and atomic task lists. Your output is the input contract for everyone else. @@ -43,6 +44,12 @@ Output: `.specs//00-spec.md` matching the template structure exactly. Per-template authoring rules (what to fill, what to leave TBD, required frontmatter fields) are in the **sd-spec-templates** skill. Read the section matching the spec type being authored. +For a **feature** spec, that includes the `complexity` frontmatter field: your whole-spec size +estimate (`S` | `M` | `L`) plus a one-line rationale, per the "Complexity estimate" rubric in +**sd-spec-templates**. Estimate it honestly from Why / What / SC / AC / Open questions - it is not +always `M`, and a create-time `L` estimate escalates the impact and planning models downstream. It +is a spec-level estimate, distinct from a task's `Estimated complexity`. + --- ## Mode 2: `TASK = plan` @@ -62,7 +69,12 @@ Outputs: ### `02-tasks.md` task format (MANDATORY) -Apply the **sd-atomic-task-format** skill: task block (9 required fields + `Pattern refs`), field-by-field rules (Files, Layer, Step type, Acceptance, complexity, reversibility, Depends on / Conflicts with, Pattern refs), atomicity rules, and anti-patterns. +Apply the **sd-atomic-task-format** skill: task block (11 required fields, including `Pattern refs`), field-by-field rules (Files, Layer, Step type, Test, Acceptance, Covers, complexity, reversibility, Depends on / Conflicts with, Pattern refs), atomicity rules, and anti-patterns. + +- Fill `Covers` on every task: list the SC-/AC-IDs from `00-spec.md` the task implements or + proves. Before finishing, cross-check that every SC and AC ID in the spec appears in at + least one task's `Covers` - an uncovered criterion means the task list is incomplete, not + that the criterion is optional. ### Pattern refs protocol @@ -76,6 +88,59 @@ For every task that creates a new file or introduces a new public symbol: `mcp__context7__resolve-library-id` + `mcp__context7__query-docs` before writing the criterion - stale training data on library APIs is a real failure mode (same rule the implementer follows). +### Complexity self-assessment (feature plan only) + +After writing `01-plan.md` and `02-tasks.md`, measure the plan you just wrote against the decompose +thresholds in the **sd-spec-templates** skill ("Complexity estimate"). A plan is **over-threshold** +when **any** hold: tasks **> 8**, spans **> 2** production layers/subsystems (distinct `Layer` +values, **excluding `Tests` and `Config`**, which cross-cut every change), impact surface **> 8** +files (from `IMPACT`), or **any** unresolved Open question remains. Count tasks with the tolerant +heading grammar (`sd-atomic-task-format`) - never a naive `^### T` regex, which undercounts +drifted real specs. + +Then, in your return to the main thread: + +1. **Under threshold** - report normally: the plan path, task count, and measured complexity. No + friction, no decompose talk. This is the common case; do not manufacture concern. +2. **Over threshold, decomposable** - do NOT present the oversized plan as final. Return + `STATUS = needs-input` with a **decompose proposal**: 2+ child specs, each a medium slice, that + together cover every SC/AC of the parent. For each child give a title, the SC/AC IDs it owns + (partition the parent's - no SC/AC covered twice, none dropped), and a proposed + `FEAT--` ID. State the dependency order between children as + `depends-on` edges. The main thread runs the Gate Complexity approval and creates the children; + you only propose. Do not write the child specs yourself in this return. +3. **Over threshold but legitimately atomic (no clean split)** - some work is simply large and + cohesive; a forced split would produce worse specs than one honest plan (a real case: a + hand-decomposed corpus child still ran 12 tasks). Return `STATUS = needs-input` flagging + **no-split**: name why the work does not partition, and recommend the sanctioned model + escalation (main thread bumps you to `opus`, explorer to `sonnet` - aliases only). The user + decides at the gate. + +You never change your own model and you never create child specs - both are main-thread actions in +`commands/feature.md`. You measure, and you propose. + +### Scoped re-plan (`TASK = plan` with `REPLAN_SCOPE`) + +When Mode 2 is invoked with a `REPLAN_SCOPE` field, you are running a **mid-execution re-plan** +through the workflow's Gate Re-plan, not authoring a plan from scratch. Read the **sd-replan-loop** +skill first. Inputs: `REPLAN_SCOPE` (the affected task IDs, e.g. `T05, T07`), `REVISION` (the entry +number, e.g. `R2`), and the trigger the main thread passed. + +1. **Regenerate only the scoped task blocks** in `02-tasks.md`. Every task block **not** in + `REPLAN_SCOPE` is left byte-for-byte unchanged - do not reflow, renumber, or re-order them. +2. **Mark each regenerated block** with the `Revised-by: ` field (per the "Re-plan adds one + field" section of `sd-atomic-task-format`). Every other field stays fully populated per the + 11-field format. +3. **Append the `## Revisions` entry** to `01-plan.md` using the format in `sd-replan-loop` + (`### - `, with `Trigger`, `Phase`, `Gate: re-plan`, `Affected tasks`, + `Delta`, `revised-from`). **Append only** - never edit the original plan prose or a prior revision + entry. The `Affected tasks` list must exactly equal `REPLAN_SCOPE`. +4. Do not touch the spec's `status`, `00-spec.md`, or any `done`/`archived` spec. Re-plan runs only + on an `in-progress` spec; the workflow guarantees that before invoking you. + +Return a one-line summary naming the regenerated task IDs and the revision number - not the task +text. + --- ## Mode 3: `TASK = refine` @@ -147,7 +212,7 @@ For every spec you produce, in the "Constitution check" section: - **Hardcoding stack assumptions**. If you write a build/test command from a prior invocation instead of reading this project's `commands.test` (via `CLAUDE.md`), you have failed. Read CLAUDE.md every invocation - your prior knowledge of the project is stale by default. - **Skipping the template structure**. The template is the contract. If you "improve" it by reordering sections, downstream agents that key off section headers break. - **Filling cross-phase fields prematurely**. Bug's Root cause is empty for a reason. Perf's Results log is empty for a reason. -- **Inventing task structure**. The 9 required fields in the task format are required, not suggested. +- **Inventing task structure**. The 11 required fields in the task format are required, not suggested. - **New-file task without Pattern refs**. The implementer is haiku; it follows the refs you give it or it follows nothing. - **Glossing over a constitution violation**. If ยง1.1 is at risk, that is an Open question, not a footnote. - **Producing the spec in your prose response**. The spec lives in the file. Your response to the main thread is a one-paragraph summary plus the file path - not the full spec text. diff --git a/commands/feature.md b/commands/feature.md index a99cb07..7ab1706 100644 --- a/commands/feature.md +++ b/commands/feature.md @@ -1,5 +1,5 @@ --- -description: Spec-driven feature workflow. Spec -> impact -> plan -> execute -> batch review -> close. 3 hard gates. +description: Spec-driven feature workflow. Spec -> impact -> plan (complexity triage) -> execute -> batch review -> close. 3 hard gates. argument-hint: --- @@ -23,6 +23,7 @@ On re-invocation with the same ``, detect the current state of `.specs/FEAT | `02-tasks.md` exists, unchecked tasks remain | `in-progress` | Resume Phase 4 at next unchecked task | | All tasks checked, no integration pass | `tasks-complete` | Start Phase 5 | | status=`done` | `done` | Print summary, exit | +| status=`archived`, spawned children (has `spawns` links) | `umbrella` | Print the child IDs + `/sd:feature ` for each, in dependency order; exit | | status=`archived` | `archived` | Print archived notice, exit | --- @@ -51,7 +52,7 @@ On re-invocation with the same ``, detect the current state of `.specs/FEAT - `TEMPLATE = feature.template.md` - `TICKET_CONTEXT = ` - `SPEC_ID = FEAT-` -3. Spec-architect produces `.specs/FEAT-/00-spec.md` with: Why (business value), What (Given/When/Then), Success criteria, Out of scope, Open questions, Constitution check, Linked specs. +3. Spec-architect produces `.specs/FEAT-/00-spec.md` with: Why (business value), What (Given/When/Then), Success criteria, Out of scope, Open questions, Constitution check. Cross-references are not authored here - they go in the `linked_specs` frontmatter field via `/sd:spec link`. 4. If a ticket was fetched, spec-architect also snapshots it (ticket content + related tickets + linked Confluence pages, per its Ticket snapshot protocol) to `.specs/FEAT-/04-artifacts/ticket/`. 5. Register in `.specs/index.md` with status=`draft`. @@ -69,7 +70,12 @@ STOP. Present the spec to the user. Ask: ## Phase 2 - Impact analysis -1. Invoke `sd-code-explorer` with: +0. **Complexity escalation check.** Read the `complexity` frontmatter field of + `.specs/FEAT-/00-spec.md`. If it is `L`, invoke the explorer in step 1 with a model + override to `sonnet` (overriding its `haiku` default) - a create-time `L` estimate is exactly + the multi-subsystem case where the shallow haiku impact map degrades. For `S` / `M`, use the + default model. Aliases only - never a full model ID. +1. Invoke `sd-code-explorer` (model: default, or `sonnet` per step 0) with: - `TASK = impact-map` - `SPEC = .specs/FEAT-/00-spec.md` - `OUTPUT_TARGET = .specs/FEAT-/03-decisions.md` @@ -84,27 +90,78 @@ No gate here - impact analysis is informational. User reviews it in Phase 3. ## Phase 3 - Plan + tasks -1. Invoke `sd-spec-architect` with: +0. **Complexity escalation check.** Read the `complexity` frontmatter field of + `.specs/FEAT-/00-spec.md`. If it is `L`, invoke the architect in step 1 with a model + override to `opus` (overriding its `sonnet` default) - single-pass planning is where large scope + degrades non-linearly. For `S` / `M`, use the default model. Aliases only - never a full model ID. +1. Invoke `sd-spec-architect` (model: default, or `opus` per step 0) with: - `TASK = plan` - `SPEC = .specs/FEAT-/00-spec.md` - `IMPACT = .specs/FEAT-/03-decisions.md` 2. Spec-architect produces: - `.specs/FEAT-/01-plan.md` (approach, alternatives considered, rationale). - `.specs/FEAT-/02-tasks.md` with atomic tasks, each formatted per the - **sd-atomic-task-format** skill (9 required fields + `Pattern refs`; the architect applies + **sd-atomic-task-format** skill (11 required fields, including `Pattern refs`; the architect applies this format, do not re-specify it here). -3. Set status=`in-progress` in `00-spec.md` and `index.md`. + - It also self-assesses the plan against the decompose thresholds and returns either a normal + report (under threshold) or `STATUS = needs-input` carrying a **decompose proposal** or a + **no-split** flag (over threshold). See its "Complexity self-assessment" section. +3. **Do not set status yet** - the Gate 2 branch below decides whether this spec executes its own + plan or becomes an umbrella. Setting `in-progress` happens inside the resolved branch. -### โ›” Gate 2 - Plan approval +### โ›” Gate 2 - Plan approval (with complexity triage) -STOP. Present the plan and task list. Ask: +STOP. This gate has two faces. Which one you present is decided by the architect's self-assessment +from Phase 3 step 2 - **not** by adding a separate always-on gate. A spec **under** the decompose +thresholds sees only the normal plan approval below, with **zero added friction**. -> Approve plan for FEAT-? ( tasks, estimated ) (yes / refine / abort) +**Face A - normal plan approval** (plan is under threshold). Present the plan and task list. Ask: -- `yes` -> proceed. +> Approve plan for FEAT-? ( tasks, complexity ) (yes / refine / abort) + +- `yes` -> set status=`in-progress` in `00-spec.md` and `index.md`; proceed to Phase 4. - `refine` -> invoke `sd-spec-architect` with `TASK = refine`, `SPEC = .specs/FEAT-/00-spec.md`, `FEEDBACK = `. Loop. - `abort` -> set status=`archived`, exit. +**Face B - Gate Complexity (HARD)** (plan is over threshold: tasks > 8; spans > 2 production +layers/subsystems - distinct `Layer` values excluding `Tests`/`Config`, which cross-cut every +change; impact surface > 8 files; or an unresolved Open question remains). The workflow **refuses to +execute one oversized plan.** Present the architect's proposal and STOP. + +If the architect returned a **decompose proposal**, ask: + +> FEAT- exceeds the complexity threshold (). +> Split into child specs? (approve split / no-split / refine / abort) + +- `approve split` -> for each proposed child, in dependency order: + 1. Invoke `sd-spec-architect` with `TASK = create`, `TEMPLATE = feature.template.md`, + `SPEC_ID = FEAT--`, and a `TICKET_CONTEXT` carved from the parent (the + child's SC/AC slice + relevant Why/What). The child is a normal feature spec at status=`draft` + - its own `/sd:feature` run will plan and execute it later. Children inherit medium scope by + construction. + 2. Register the child in `.specs/index.md` at status=`draft`. + 3. Link it: `/sd:spec link FEAT- spawns FEAT--` (the command + writes the inverse `spawned-by` on the child). Then wire declared dependencies between + children with `/sd:spec link FEAT-...- depends-on FEAT-...-`. + Then make the **parent an umbrella record**: set parent status=`archived` in `00-spec.md` and + `index.md` (it spawned its children; it does not execute its own oversized plan), and append to + `05-retro.md` a one-line note naming the children it spawned and why it decomposed. The parent's + `00-spec.md`, `01-plan.md`, `02-tasks.md` are left intact as the historical record - **immutable, + never edited to match the split**. Print the child IDs and tell the user to run `/sd:feature + ` on each, respecting the dependency order. Exit this workflow. +- `no-split ` -> the user judges the work legitimately atomic (large but cohesive, no clean + partition). Apply the **sanctioned model escalation** if not already applied: the plan was written + by the escalated `opus` architect (Phase 3 step 0) only if `complexity` was `L`; if the estimate + under-called it, re-invoke Phase 3 once with the architect overridden to `opus`. Then treat as + Face A `yes`: set status=`in-progress`, proceed to Phase 4. Log the no-split decision and its + reason to `05-retro.md`. +- `refine` -> `sd-spec-architect` `TASK = refine`; loop back through the self-assessment. +- `abort` -> set status=`archived`, exit. + +If the architect returned a **no-split** flag itself (over threshold but it found no clean split), +present that reasoning and ask the same question - the user still owns the call between forcing a +split and accepting the escalated single plan. + --- ## Phase 4 - Execute (no per-task reviewer) @@ -125,6 +182,12 @@ For each unchecked task: - Did implementer stay within `Files` list? If not -> revert, re-invoke. - Does the test pass? If not -> re-invoke implementer with failure output. - Any obvious constitution violation visible from the diff? If yes -> re-invoke with feedback. + - **Plan-invalidating discovery?** If the implementer reports (or the diff reveals) that the task's + premise is false - a `Pattern refs` precedent does not exist, an interface differs from what the + task assumed, a `Depends on` edge is backwards, or a task is now known missing/redundant - do + NOT hack-edit `02-tasks.md`. Enter **Gate Re-plan** below. This is distinct from an ordinary + in-task adjustment, which the implementer handles within its own scope (see `sd-replan-loop` for + the boundary). 6. **Check off** the task in `02-tasks.md`. 7. Log a one-line summary to `.specs/FEAT-/05-retro.md`: `T: - `. @@ -132,6 +195,27 @@ For each unchecked task: Move to next task. Repeat until all tasks checked. +### Gate Re-plan (HARD) - adaptive re-plan on a plan-invalidating discovery + +Reachable from Phase 4 (self-check above) **and** Phase 5b (a batch-review finding that the plan +itself is wrong). Follow the **sd-replan-loop** skill; the protocol is defined there once and shared +with `/sd:refactor`. In brief: + +1. STOP. Surface the trigger, the affected task IDs, and the proposed delta. Ask: + + > Re-plan FEAT-? Discovery: . Affects . (approve / revise / abort task) + + - `approve` -> proceed. `revise` -> adjust the delta and re-ask. `abort task` -> normal task abort. +2. Invoke `sd-spec-architect` with `TASK = plan`, `REPLAN_SCOPE = `, + `REVISION = R` (next contiguous number). It appends the `## Revisions` entry to `01-plan.md` + (append-only; the original plan prose is never edited), regenerates ONLY the affected task blocks + in `02-tasks.md`, and marks each `Revised-by: R`. +3. Resume Phase 4 at the first regenerated task. Tasks the revision did not touch stay checked. + +This gate runs only while the spec is `in-progress` - it never re-plans a `done` spec. It adds no new +top-level gate to the workflow's count: it fires only on a plan-invalidating discovery, exactly as +Gate Complexity fires only over threshold. + --- ## Phase 5 - Integration + batch review @@ -168,7 +252,13 @@ Ask: > All clean for FEAT-? (yes / address findings / abort) Treat findings: -- Any ๐Ÿ”ด BLOCK -> route back to implementer with finding as feedback. Re-run batch review after fix. +- Any ๐Ÿ”ด BLOCK that is a **code defect** (the task was right, the implementation is wrong) -> route + back to implementer with the finding as feedback. Re-run batch review after fix. +- Any ๐Ÿ”ด BLOCK that reveals the **plan itself was wrong** (a spec/plan decision proved incorrect once + written - e.g. a task asserts a behavior the codebase contradicts) -> enter **Gate Re-plan** (Phase + 4) with `Phase: review`, not a bare implementer fix. This is the review-time re-plan path: the + wrong decision is recorded as a revision and the affected tasks are regenerated, rather than + hand-patched with only a retro sentence. - Any ๐ŸŸ  WARN -> ask user: address now or log to `05-retro.md` as follow-up? - ๐ŸŸก SUGGEST and ๐ŸŸข PASS -> log to retro, proceed. @@ -176,15 +266,26 @@ Treat findings: ## Phase 6 - Close-out -1. Append to `.specs/FEAT-/05-retro.md`: +1. Review each `AC-` checkbox in `00-spec.md` against evidence (a passing test, a measured + value, a reviewer verdict) and check it only with a `file:line` or test citation logged to + `.specs/FEAT-/05-retro.md`. Never tick a box just to make VF030 pass - an unearned + checkbox is a fabricated result, not a shortcut. +2. Run `/sd:verify FEAT-`. It must report `result: pass`. + - On FAIL: address the findings (uncovered criterion -> back to Phase 3 to add tasks; + failing tests -> back to Phase 4; unchecked `AC-` criterion with real evidence already + in hand -> gather the citation and check the box per step 1; unchecked criterion with no + evidence yet -> route back to the phase that produces it, e.g. Phase 4 for an untested + behavior). Re-run until it passes. Do NOT proceed on fail - the spec-gate hook will block + step 5 without a passing `06-verify.md`. +3. Append to `.specs/FEAT-/05-retro.md`: - Tasks completed (count + IDs). - Surprises encountered. - Deferred follow-ups (with reserved spec IDs, if any). - Constitution exceptions taken (should be none). - Cost rough estimate if available. -2. Set frontmatter status=`done` in `00-spec.md`. -3. Update `.specs/index.md`: state -> `done`, completion date. -4. Print a 5-line summary to the user. +4. Set frontmatter status=`done` in `00-spec.md`. +5. Update `.specs/index.md`: state -> `done`, completion date. +6. Print a 5-line summary to the user. --- @@ -192,6 +293,22 @@ Treat findings: - Phase 0 always runs. No exceptions, even on resume. - Gates 1-3 are HARD. The workflow refuses to proceed without explicit approval. +- **Gate Complexity is a face of Gate 2, not a fourth gate.** It fires ONLY when the plan is over + threshold; a spec under threshold sees the normal plan approval with zero added friction. This is + why the workflow still has 3 hard gates - do not describe it as 4. +- **Gate Re-plan is a conditional gate, not a fourth always-on gate.** Like Gate Complexity, it + fires ONLY on a specific trigger - a plan-invalidating discovery in Phase 4 or a plan-is-wrong + BLOCK in Phase 5. A run that never hits one never sees it. It is HARD when it does fire (explicit + approval, no override), and it never re-plans a `done` spec. The protocol lives in the + `sd-replan-loop` skill; `02-tasks.md` is re-planned only through it - never by a silent hand-edit. + Any revision is recorded append-only in `01-plan.md`'s `## Revisions` log with the original plan + prose left intact. +- **Model escalation is aliases only.** A create-time `complexity: L` bumps the explorer to + `sonnet` (Phase 2) and the architect to `opus` (Phase 3). Never introduce a full model ID; never + edit an agent's `model:` frontmatter - the override is per-invocation, from the main thread. +- **A decomposed parent is an immutable umbrella.** Once split, the parent's spec/plan/tasks are a + historical record and are never edited to match the children. Children are normal feature specs, + linked via `/sd:spec link spawns` / `depends-on` - no bespoke decomposition mechanism. - Implementer touches only files declared in the task's `Files` list. Any scope creep -> stop, surface to main thread, log to retro. - Reviewer is invoked ONCE in Phase 5b for the entire changeset (not per-task). This is a deliberate cost optimization. - BLOCK findings from the batch review must be addressed before close-out. diff --git a/commands/refactor.md b/commands/refactor.md index 0735c9f..6a1415f 100644 --- a/commands/refactor.md +++ b/commands/refactor.md @@ -132,7 +132,7 @@ If user picks (2) explicit exception, document the threshold reduction in `05-re - `IMPACT = .specs/REF--/03-decisions.md` - `MODE = refactor` 2. Architect writes `01-plan.md` (sequencing) and `02-tasks.md`. Each task follows the - **sd-atomic-task-format** skill (9 required fields + `Pattern refs`, plus the skill's + **sd-atomic-task-format** skill (11 required fields, including `Pattern refs`, plus the skill's "Refactor mode" `Parallel batch` field - do not re-specify the format here). Tasks sharing a batch number must have disjoint file sets and no `Depends on` / `Conflicts with` relationship. @@ -168,6 +168,26 @@ STOP after every batch. Display test results. - All green -> check off tasks in `02-tasks.md`, proceed to next batch. - Any red -> REFUSE to proceed. Revert the batch or fix the regression. The point of batched-with-tests-between is to localize failures. +### Gate Re-plan (HARD) - adaptive re-plan on a plan-invalidating discovery + +Reachable from Phase 5 (a batch reveals a task's premise is false or an invariant cannot be preserved +as planned) **and** Phase 6 (a holistic-review finding that the plan itself is wrong). Follow the +**sd-replan-loop** skill - the same protocol `/sd:feature` uses, defined there once. In brief: + +1. STOP. Surface the trigger, the affected task IDs, and the proposed delta. Ask: + + > Re-plan REF-? Discovery: . Affects . (approve / revise / abort task) + +2. `approve` -> invoke `sd-spec-architect` with `TASK = plan`, `MODE = refactor`, + `REPLAN_SCOPE = `, `REVISION = R`. It appends the `## Revisions` entry to + `01-plan.md` (append-only; original plan prose untouched), regenerates ONLY the affected task + blocks in `02-tasks.md` - preserving each task's `Parallel batch` field - and marks each + `Revised-by: R`. `revise` -> adjust and re-ask. `abort task` -> normal task abort. +3. Resume Phase 5 at the batch containing the first regenerated task. Re-check batch disjointness if a + regenerated task's `Files` set changed. + +Runs only while the spec is `in-progress`; never re-plans a `done` spec. + --- ## Phase 6 - Holistic review @@ -193,7 +213,10 @@ STOP. Display reviewer verdict counts + invariant verification table. Ask: > Refactor REF- ready for close-out? (yes / address findings / abort) - `yes` -> proceed. -- Any ๐Ÿ”ด BLOCK -> loop to implementer with the finding. +- Any ๐Ÿ”ด BLOCK that is a **code defect** -> loop to implementer with the finding. +- Any ๐Ÿ”ด BLOCK that reveals the **plan itself was wrong** (e.g. a planned step cannot preserve an + invariant) -> enter **Gate Re-plan** (Phase 5) with `Phase: review`, recording the wrong step as a + revision and regenerating the affected tasks rather than hand-patching them. --- @@ -218,3 +241,7 @@ STOP. Display reviewer verdict counts + invariant verification table. Ask: - Public API preservation is verified by reviewer (Phase 6), not assumed. - Max 3 parallel tasks per batch. More -> tests-between granularity is too coarse. - Each batch's tests must finish before the next batch starts. No "tests run in background while next batch starts". +- **Gate Re-plan is a conditional gate, not a seventh always-on gate.** It fires only on a + plan-invalidating discovery (Phase 5) or a plan-is-wrong BLOCK (Phase 6); a run that hits neither + never sees it. `02-tasks.md` is re-planned only through it (the `sd-replan-loop` skill), never by a + silent hand-edit, and never on a `done` spec. Revisions are append-only in `01-plan.md`. diff --git a/commands/setup.md b/commands/setup.md index 1a0abb4..39361f9 100644 --- a/commands/setup.md +++ b/commands/setup.md @@ -322,11 +322,11 @@ Setup complete. Generated: - .claude/settings.json (hooks: prompt-router, spec-gate, subagent-retro) Installed engine paths: - - ~/.claude/commands/sd/ (11 workflow commands) + - ~/.claude/commands/sd/ (13 workflow commands) - ~/.claude/agents/sd/ (6 specialist agents) - ~/.claude/hooks/sd/ (3 hooks) - ~/.claude/templates/sd/ (templates) - - ~/.claude/skills/sd/ (6 skills: severity-taxonomy, hypothesis-tree, atomic-task-format, evidence-citation, spec-templates, pattern-discipline) + - ~/.claude/skills/sd/ (8 skills: severity-taxonomy, hypothesis-tree, atomic-task-format, evidence-citation, spec-templates, pattern-discipline, retro-lessons, replan-loop) Next steps: 1. Fill placeholders in CLAUDE.md and .specs/constitution.md (open them in your editor). diff --git a/commands/spec.md b/commands/spec.md index 60e3409..7eddab9 100644 --- a/commands/spec.md +++ b/commands/spec.md @@ -68,7 +68,7 @@ Behavior: 2. Read `00-spec.md`. Display: - Frontmatter (id, type, status, created, jira/severity if present). - First H2 (title or "Why"). - - Status of each phase artifact: which of `00-spec.md`, `01-plan.md`, `02-tasks.md`, `03-decisions.md`, `04-artifacts/`, `05-retro.md` exist. + - Status of each phase artifact: which of `00-spec.md`, `01-plan.md`, `02-tasks.md`, `03-decisions.md`, `04-artifacts/`, `05-retro.md`, `06-verify.md` (written by `/sd:verify`; gates the `done` transition) exist. 3. If `02-tasks.md` exists, show task completion count `N/M`. 4. Print folder URL: `file://`. @@ -92,6 +92,12 @@ done -> archived archived -> in-progress (only via 'revive', with reason) ``` +The `in-progress -> done` transition of a feature (FEAT) spec is hook-enforced: spec-gate +blocks the `index.md` edit unless `//06-verify.md` exists and records +`result: pass`. Run `/sd:verify ` first. Disable only via `hooks.specGate.verifyGate: false` +in project-config. Other spec types (bug, refactor, perf, rca) close out as before - the hook +does not gate their `index.md` row. + 3. Illegal transitions are REFUSED. Do NOT mutate any file. Print a refusal that names the current state, the requested state, the valid next state(s) for the current state (from the machine above), and - when the requested state is reachable - the shortest legal path to it: @@ -110,6 +116,21 @@ Valid next state(s) from 'draft': approved. To reach 'done', follow: draft -> approved -> in-progress -> done. ``` 4. On valid transition: + - If this is an `in-progress -> done` transition of a feature (FEAT) spec: BEFORE mutating + any file, check `//06-verify.md` exists and records `result: pass`. If not, + REFUSE the transition with no file mutated: + +``` +Refused: cannot move from 'in-progress' to 'done' - no passing /sd:verify artifact. +Run /sd:verify first; close-out is allowed only after //06-verify.md records +'result: pass'. +``` + + This check exists because the frontmatter, index, and retro log are mutated one file at a + time below; without it, a spec-gate block on the `index.md` edit alone would strand the + frontmatter already updated to `done` while the index still says `in-progress` - an + `SL030` disagreement. Checking first keeps the transition atomic: either nothing moves, or + all three files do. - Update frontmatter `status:` field in `00-spec.md`. - Update the row in `.specs/index.md`. - Append a log entry to `.specs//05-retro.md`: @@ -130,22 +151,47 @@ Args: - `` (required). Behavior: -1. Verify both spec folders exist. -2. Validate relation is in the allow-list. -3. Update both specs: - - In `00-spec.md` "Linked specs" section of ID-A: add line `: `. - - In ID-B add the inverse: `: `. -4. Inverse map: - -| Relation | Inverse | -|---|---| -| `depends-on` | `blocks` (other side has `blocked-by`) | -| `spawns` | `spawned-by` | -| `supersedes` | `superseded-by` | -| `related-to` | `related-to` (symmetric) | -| `duplicate-of` | `duplicate-of` (symmetric) | - -5. Append a log line to both retros. +1. Verify both spec folders exist. A link to a non-existent ID is REFUSED - do not create a + dangling entry. +2. Validate relation is in the allow-list, then normalize it (see below). +3. Refuse a self-link (`ID-A` == `ID-B`). +4. If the link already exists on either side, do nothing and say so - `link` is idempotent. +5. Update the `linked_specs` frontmatter list on both specs: + - On ID-A: add `- : `. + - On ID-B: add `- : `. +6. Append a log line to both retros. + +### Relation vocabulary + +`blocked-by` is an **input alias**, not a stored relation: "A is blocked-by B" and "A depends-on B" +assert the same edge, so storing both would let one spec carry two spellings of one fact and make +symmetry unverifiable. `link` normalizes `blocked-by` to `depends-on` and reports the rewrite. +The other eight inputs are already canonical and are stored as given. + +| Input | Stored as | Inverse written on the other side | +|---|---|---| +| `depends-on` | `depends-on` | `blocks` | +| `blocked-by` | `depends-on` (alias) | `blocks` | +| `blocks` | `blocks` | `depends-on` | +| `spawns` | `spawns` | `spawned-by` | +| `spawned-by` | `spawned-by` | `spawns` | +| `supersedes` | `supersedes` | `superseded-by` | +| `superseded-by` | `superseded-by` | `supersedes` | +| `related-to` | `related-to` | `related-to` (symmetric) | +| `duplicate-of` | `duplicate-of` | `duplicate-of` (symmetric) | + +The map is total and closed: every stored relation has exactly one inverse, and that inverse is +itself a stored relation. This is what makes the link-symmetry check in `validate` decidable. + +### linked_specs format + +A YAML list of single-key maps in `00-spec.md` frontmatter. Empty is `[]`. + +```yaml +linked_specs: + - depends-on: FEAT-INV-2501 + - related-to: BUG-1247 +``` --- @@ -194,19 +240,275 @@ Args: - `` (optional, default `--all`). Behavior: -1. For each target spec: +1. Per-spec checks. For each target spec: - Frontmatter present and parseable. - - Required fields per type: `id`, `type`, `status`, `created`. Bugs need `severity`. RCAs need `incident_started`. + - Required fields present, per type (see "Required frontmatter fields" below). - `id` field matches the folder name. - `type` matches the prefix. - `status` is in `spec.lifecycle` from project-config. + - Placeholder discipline (see "Placeholder tokens" below). - Expected files present per status: - - status >= `approved` -> `00-spec.md` must NOT have "<>" tokens remaining. - status >= `in-progress` -> `01-plan.md` and `02-tasks.md` exist (feature and refactor only; bug, perf, and rca do not produce plan/tasks artifacts). - status == `done` -> `05-retro.md` exists with at least one entry. - Index row matches frontmatter status. -2. Output: one line per spec with PASS / FAIL and the first failure reason. + - Transition history is legal (see "Transition replay" below). + - Links resolve and are symmetric (see "Link integrity" below). + - Task-block content, when `02-tasks.md` exists (see "Task-block checks" below). This is the + only check that reads inside an artifact rather than around it. +2. Tree-wide checks (run once, only when the target is `--all`): + - Index <-> folder symmetry (see below). +3. Report every finding using the severity taxonomy (see "Output" below). Do not stop at the + first failure - a spec with three problems reports three findings. + +### Rule table + +Every finding cites one of these IDs. The ID is the finding's anchor - the taxonomy forbids a +BLOCK or WARN without one. IDs are stable: renumbering them breaks anyone who has pinned a rule. + +| ID | Rule | Severity | +|---|---|---| +| `SL001` | Frontmatter missing or unparseable | ๐Ÿ”ด BLOCK | +| `SL002` | Required field missing for type | ๐Ÿ”ด BLOCK | +| `SL003` | `id` does not match folder name | ๐Ÿ”ด BLOCK | +| `SL004` | `type` does not match ID prefix | ๐Ÿ”ด BLOCK | +| `SL005` | `status` not in `spec.lifecycle` | ๐Ÿ”ด BLOCK | +| `SL006` | `linked_specs` missing, or present but not a list | ๐Ÿ”ด BLOCK | +| `SL010` | Author-fill `<<...>>` token remains at status >= `approved` | ๐Ÿ”ด BLOCK | +| `SL011` | Phase-deferred `<>` token pre-filled at `draft` / `approved` | ๐Ÿ”ด BLOCK | +| `SL012` | Phase-deferred token still unfilled at `done` | ๐ŸŸ  WARN | +| `SL013` | Type template unreadable - placeholder checks could not run | ๐ŸŸ  WARN | +| `SL020` | Required artifact missing for status | ๐Ÿ”ด BLOCK | +| `SL021` | Status `done` but `05-retro.md` has no entry | ๐Ÿ”ด BLOCK | +| `SL030` | Index row status disagrees with frontmatter status | ๐Ÿ”ด BLOCK | +| `SL031` | Orphan folder - spec exists but has no index row | ๐Ÿ”ด BLOCK | +| `SL032` | Ghost row - index row whose folder does not exist | ๐Ÿ”ด BLOCK | +| `SL033` | Duplicate index rows for one ID | ๐Ÿ”ด BLOCK | +| `SL040` | Illegal transition edge in the retro log | ๐Ÿ”ด BLOCK | +| `SL041` | Transition chain not contiguous | ๐Ÿ”ด BLOCK | +| `SL042` | Last logged transition disagrees with frontmatter status | ๐Ÿ”ด BLOCK | +| `SL043` | Status is not `draft` but there is no retro log | ๐Ÿ”ด BLOCK | +| `SL044` | `archived -> in-progress` logged with an empty reason | ๐ŸŸ  WARN | +| `SL050` | Link target does not resolve to an existing spec | ๐Ÿ”ด BLOCK | +| `SL051` | One-sided link - inverse entry missing on the target | ๐Ÿ”ด BLOCK | +| `SL052` | Self-link | ๐Ÿ”ด BLOCK | +| `SL053` | Stored relation is `blocked-by` - an input alias, so the field was hand-edited | ๐ŸŸ  WARN | +| `SL054` | Duplicate entry in `linked_specs` | ๐ŸŸ  WARN | +| `SL055` | Spec status `done` but `06-verify.md` is missing or records `result: fail` | ๐ŸŸ  WARN | +| `SL060` | Task block in `02-tasks.md` has no `Pattern refs` field | ๐ŸŸ  WARN | +| `SL070` | Task carries `Revised-by: R` but `01-plan.md` has no matching `## Revisions` entry `R` | ๐Ÿ”ด BLOCK | +| `SL071` | A `## Revisions` entry `R` names an `Affected task` that does not carry `Revised-by: R` (or does not exist) | ๐Ÿ”ด BLOCK | +| `SL072` | Revision numbering is non-contiguous, duplicated, or a prior entry was rewritten (append-only violated) | ๐Ÿ”ด BLOCK | +| `SL073` | A `## Revisions` entry is malformed - missing `Trigger`, `Gate: re-plan`, `Phase`, or `revised-from` | ๐ŸŸ  WARN | + +`SL061`-`SL069` are **reserved** for further task-block content rules. Claim from this band rather +than extending another one - `SL05x` is link integrity and has nothing to do with task content. + +`SL070`-`SL079` are the **revision-log integrity** band (the `sd-replan-loop` `## Revisions` log in +`01-plan.md`, cross-checked against `Revised-by` markers in `02-tasks.md`). It is a distinct band on +purpose: it is neither task-block *content* (`SL06x`) nor `linked_specs` symmetry (`SL05x`), though it +borrows the two-sided-symmetry shape of the latter. `SL074`-`SL079` are reserved for further +revision-record rules. + +Severity rationale: BLOCK is for a registry that **lies** (its own contents contradict each other, +so `list` / `stats` / downstream agents read something untrue) or evidence that was **fabricated** +(`SL011` - a measured field filled from memory). WARN is for a real problem that leaves the +registry still truthful and is recoverable by re-running a command. There is no SUGGEST rule +today; the section is still printed, per the taxonomy. + +`SL060` is WARN by that same test: a task with no `Pattern refs` leaves the registry truthful and +is fixed by re-planning the spec. It is deliberately **not** BLOCK - in the only corpus measured, +every task authored after the field shipped already carried it (22 of 22), while the two specs +without it predate the field entirely. Blocking would fail old specs for a rule they could not +have followed, and would gain nothing on new ones. + +`SL070`-`SL072` are BLOCK: a task pointing at a revision that does not exist, a revision pointing at +a task that does not carry its marker, or a rewritten revision entry, are all a registry that **lies** +about its own audit trail - the append-only guarantee the `## Revisions` log exists to provide is +exactly what these catch. `SL073` is WARN: an entry missing a descriptive line (`Trigger`, `Gate`, +`Phase`, `revised-from`) is a poor record but leaves the task/log symmetry decidable and truthful, +and is recoverable by editing the entry - the same test that makes `SL044` a WARN. + +### Task-block checks + +Run only when `02-tasks.md` exists. Parse it per the **Field label grammar** in the +`sd-atomic-task-format` skill - label matching is case-insensitive, `**` around the label is +optional, and the colon may sit inside or outside the emphasis. All three forms occur in live +specs; a reader that accepts only the canonical `- **Label**:` form reports false `SL060`s against +correctly authored tasks, which is worse than not running the check at all. + +A field's value runs to the next field label, not to the next newline - `Acceptance` and +`Pattern refs` are routinely multi-line with nested bullets. + +Report one `SL060` per offending task block, citing the task heading (e.g. `02-tasks.md` `T01`). +A block that writes `Pattern refs: none` is **compliant** - the explicit `none` is the assertion +the rule is asking for. Only an absent field is a finding. + +### Revision-log integrity + +Runs only when there is a revision record to check: `01-plan.md` has a `## Revisions` section, **or** +any task block in `02-tasks.md` carries a `Revised-by` field. A spec with neither - the overwhelming +common case, an original plan never re-planned - produces no `SL07x` finding. This is a cross-artifact +check: the `## Revisions` log lives in `01-plan.md`, its markers in `02-tasks.md`, and the two must +agree. Parse task blocks with the same tolerant **Field label grammar** used for `SL060`. + +The `## Revisions` log is written only by the Gate Re-plan of `/sd:feature` and `/sd:refactor` via the +**sd-replan-loop** skill. Each entry is `### R - ` followed by `Trigger`, `Phase`, +`Gate: re-plan`, `Affected tasks`, `Delta`, and `revised-from` lines. Checks: + +- **`SL070` - dangling marker.** A task carrying `Revised-by: R` with no `### R` entry in + `01-plan.md`. Cite the task's `Revised-by` line; name the absent entry in the finding. +- **`SL071` - one-sided / unreferenced revision.** An `### R` entry whose `Affected tasks` list + names a task that either does not exist or does not carry `Revised-by: R`. An entry with no + parseable `Affected tasks` line is also `SL071` - the back-reference cannot be established, so the + revision points at nothing. Cite the entry's `Affected tasks` line (or the `### R` heading when + the line is absent). +- **`SL072` - broken append-only history.** Revision numbers must run contiguously from `R1` with no + gap, no duplicate, and no reuse. A gap (`R1`, `R3`), a duplicate `R`, or a heading that reuses a + number already logged means the log was rewritten rather than appended. Cite the first offending + `### R` heading. +- **`SL073` - malformed entry.** An `### R` entry missing any of `Trigger`, `Gate: re-plan`, + `Phase`, or `revised-from`. (A missing `Affected tasks` line is `SL071`, not `SL073` - it breaks + symmetry, not just completeness.) Cite the `### R` heading. + +Report one finding per offending task or entry - a log with three problems reports three findings. +`validate` is a static linter: it verifies the revision record is internally consistent and +append-only shaped. It has no Plan-phase snapshot of `02-tasks.md`, so it **cannot** detect an +undocumented silent edit by diffing - an edit that adds no `Revised-by` marker and no `## Revisions` +entry is invisible here and is prevented by the HARD Gate Re-plan, not by this lint. Do not report, +or imply, a finding the checks above cannot actually decide. + +### Output + +Read `~/.claude/skills/sd/sd-severity-taxonomy/SKILL.md` and +`~/.claude/skills/sd/sd-evidence-citation/SKILL.md` before emitting the report, and follow them. +This command has no `skills:` frontmatter - only agents load skills that way, and `validate` +invokes no subagent - so the rules are read at runtime instead. If either file is unreadable, say +so and fall back to plain PASS / FAIL lines rather than inventing a format. + +Structure: the taxonomy's mandated output, plus a per-spec summary table above it. + +```markdown +# Spec lint: spec(s) under `.specs/` + +**Verdict**: ๐Ÿ”ด BLOCK, ๐ŸŸ  WARN, ๐ŸŸก SUGGEST, ๐ŸŸข PASS across specs. + +| Spec | Status | Result | +|---|---|---| +| `FEAT-INV-2501` | in-progress | PASS | +| `BUG-1247` | done | 2 findings (1 BLOCK, 1 WARN) | + +## ๐Ÿ”ด BLOCK + +### B1: `id` does not match folder name +- **File:line**: `.specs/BUG-1247/00-spec.md:2` +- **Rule**: `SL003` +- **Finding**: Frontmatter declares `id: BUG-1246` but the folder is `BUG-1247`. `show` and + `link` resolve by folder, `list` renders the frontmatter - so the two disagree about what + this spec is called. +- **Suggested direction**: Decide which ID is real, then fix the other side and the index row. +``` + +Citation rules for this command, applying `sd-evidence-citation`: + +- Every finding cites `file:line`, relative to the project root. +- A finding about something **absent** (a missing artifact, a missing inverse link) has no line + of its own. Cite the line that **creates the obligation** - e.g. for `SL020`, the `status:` + line whose value requires the artifact - and name the absent path in the finding text. Never + cite a bare directory: the skill rejects it, and the obligation line is the better evidence. +- Tree-wide findings (`SL031` / `SL032` / `SL033`) cite `.specs/index.md:` where a row + exists, and `.specs//00-spec.md:1` for an orphan folder that has no row to point at. +- A clean tree prints the summary table with every spec `PASS`, and `_No findings._` under each + of the four severity sections. Do not omit the empty sections. + +### Index <-> folder symmetry + +Only meaningful for `--all`; a single-ID run cannot see orphans. Compare the set of spec folders +under `spec.dir` against the set of rows in `spec.indexFile`: + +- **Orphan folder**: a spec folder with no index row. The spec is invisible to `list` / `stats`. +- **Ghost row**: an index row whose folder does not exist. Points at nothing. +- **Duplicate row**: the same ID on more than one index row. + +Directories whose name starts with `_` are engine-reserved (`_explorations/`, `_reviews/`, +`_adr/`, `_archived/`) and are NOT specs - skip them. Skip `index.md` and `constitution.md` too. + +### Transition replay + +`05-retro.md` is the append-only status log written by `status` / `link`. Replay it against the +state machine in the `status` section above: + +- Parse every `- [] Status: -> .` line, in file order. +- Each ` -> ` must be a legal edge. `archived -> in-progress` is legal only when the + entry's reason is non-empty (that edge exists only via `revive`). +- The chain must be contiguous: each entry's `` equals the previous entry's ``. +- The last entry's `` must equal the current frontmatter `status`. A mismatch means a + status was hand-edited, bypassing `status` - which is exactly what the log exists to catch. +- A spec with no retro and status `draft` is fine (nothing has transitioned yet). Any other + status with no retro is a failure. + +Replay reads only committed history - it cannot see a transition that was never logged. A +hand-edit that updated frontmatter, index, *and* forged a matching log line is out of scope. + +### Link integrity + +For every entry in a spec's `linked_specs`: + +- The relation is a **stored** relation from the `link` vocabulary. A stored `blocked-by` is a + failure: it is an input alias that `link` normalizes away, so its presence means the field was + hand-edited. +- The target ID resolves to an existing spec folder (no dangling links). +- The inverse entry exists on the target, pointing back (no one-sided links). Symmetric relations + (`related-to`, `duplicate-of`) require the same relation back. +- No self-link, no duplicate entries. + +### Required frontmatter fields + +Presence is what is checked, not value - a field may legitimately hold a `<>` at +`draft`. The "Placeholder tokens" rules below govern when a value must be real. + +Every type additionally requires `linked_specs` (a YAML list; `[]` when the spec stands alone). + +| Type | Required fields (beyond `linked_specs`) | +|---|---| +| `feature` | `id`, `type`, `status`, `jira`, `created` | +| `bug` | `id`, `type`, `severity`, `status`, `jira`, `created` | +| `refactor` | `id`, `type`, `smell`, `status`, `created` | +| `perf` | `id`, `type`, `status`, `target_metric`, `created` | +| `rca` | `id`, `type`, `status`, `severity`, `incident_started`, `incident_resolved`, `created` | + +`jira` is required to be present but may hold `none`. `incident_resolved` may hold a placeholder +while an incident is still open - an RCA for an unresolved incident cannot pass `approved`. +`linked_specs` must be present and a list; `[]` is the valid empty form, a bare `none` is not. + +### Placeholder tokens + +Two token forms with opposite rules. Both live in `00-spec.md`. + +| Form | Meaning | Filled by | +|---|---|---| +| `<>` | Author-fill. Written when the spec is drafted. | The spec author | +| `<>` | Phase-deferred. MUST NOT be pre-filled - the workflow's Phase N fills it from measured evidence. | Phase N of the owning workflow | + +The reference set of phase-deferred tokens for a type is the `<>` tokens in +`~/.claude/templates/sd/specs/.template.md`. If that template is unreadable, raise `SL013` +and skip only the placeholder checks for that spec - never silently pass them. + +Rules: +- status >= `approved` -> no author-fill `<<...>>` token remains. Phase-deferred tokens are + exempt and are NOT a failure. +- status in {`draft`, `approved`} -> for each phase `N`, the spec MUST carry at least as many + `<>` tokens as the template does. A filled-in phase-deferred field at these + states is a failure: it means the value was written from memory rather than measured. This is + the check that makes the cross-phase discipline enforceable rather than advisory. + + Match on the `< no assertion either way. The lifecycle records status, not which + phase is current, so a phase-deferred token may legitimately be filled or unfilled. This is a + known blind spot, not an oversight: narrowing it would mean tracking phase in frontmatter. +- status == `done` -> no `<>` token remains. --- diff --git a/commands/status.md b/commands/status.md new file mode 100644 index 0000000..83cc305 --- /dev/null +++ b/commands/status.md @@ -0,0 +1,177 @@ +--- +description: Read-only summary of the metrics log and spec registry - what is in progress, where gates fire, where friction concentrates +argument-hint: (none) +--- + +# /sd:status - metrics and registry summary + +Read-only reporting command. **No spec is created, no code is changed, no gate is evaluated, and +nothing is written anywhere.** It summarises two files that already exist: the metrics log +`.specs/_metrics/events.jsonl` (written by the `spec-gate` and `subagent-retro` hooks) and the spec +registry `.specs/index.md`. + +The event schema is documented in `docs/architecture.md` - see "Event log". This command reads the +**live** `events.jsonl` only. A rotated `events.jsonl.1` is a grace buffer, explicitly **not** part +of any read contract: a generation may be discarded on the next roll, so counting it would report a +window that cannot be reproduced. + +Takes no argument. + +## State machine + +Evaluated in order. The first matching row wins. Every state produces a labelled result - an empty +report that renders like "no friction" is a defect, not an edge case. + +| # | Condition | State | Behavior | +|---|---|---|---| +| ST001 | `.claude/project-config.json` missing | no-config | STOP: "No project config found - run `/sd:setup` first." | +| ST002 | `hooks.metrics.enabled` is `false` | disabled | Skip the metrics sections entirely, say so, still render Registry | +| ST003 | Metrics directory or log file absent | never-recorded | "No metrics recorded yet", still render Registry | +| ST004 | Log file present but zero bytes | empty | "Metrics log exists but is empty", still render Registry | +| ST005 | Log file present and non-empty | populated | Full report | + +`.specs/index.md` missing is not a STOP - render the metrics sections and label Registry +"no spec index found". + +## Phase 0 - Bootstrap + +1. Read `.claude/project-config.json`. Missing -> ST001. +2. Resolve, with defaults when a key is absent: + - `spec.dir` (default `.specs`) + - `spec.indexFile` (default `/index.md`) + - `hooks.metrics.enabled` (default `true`; **absent means enabled**, matching the hooks) + - `hooks.metrics.path` (default `/_metrics/events.jsonl`) +3. `hooks.metrics.enabled === false` -> ST002. +4. Stat the log path. Absent -> ST003. Zero bytes -> ST004. Otherwise ST005. +5. If `.1` exists, record that fact for the header line. **Do not read it.** + +## Phase 1 - Count (ST005 only) + +Counting is done by the shell, never by reading the file and tallying by eye. A populated log runs +to thousands of lines; an eyeballed number will not reconcile with an independent count, which is +the one property this report must have. + +The schema is flat, metadata-only, one JSON object per line, in a **fixed key order** - so exact +substring counting is correct. `jq` is **not** required; when it is available it is a useful +independent oracle, not the mechanism. + +Run these from the project root against `` (Bash form; use `Select-String -Pattern +'...' -SimpleMatch | Measure-Object -Line` for the PowerShell equivalent): + +| Number | Count | +|---|---| +| Total lines | `wc -l` | +| Well-formed lines | lines matching `^\{"ts":".*","event":"` and ending in `}` | +| Skipped lines | total minus well-formed | +| Events by kind | `grep -c '"event":"gate"'`, `'"event":"spec_transition"'`, `'"event":"subagent_stop"'` | +| Gate decisions by kind | `grep -c '"gate":"verify"'`, `'"gate":"protected"'`, `'"gate":"code-edit"'` | +| Decision ratio | `grep -c '"decision":"allow"'`, `'"decision":"warn"'`, `'"decision":"block"'` | +| Extensions | `grep -o '"ext":"[^"]*"' | sort | uniq -c | sort -rn` | +| Per-spec | `grep -o '"spec_id":"[^"]*"' | sort | uniq -c | sort -rn` | +| Stale observations | `grep -c '"event":"subagent_stop","stale":1'` | +| Window | first and last `ts` values (`head -1` / `tail -1`) | + +Field notes that change how a number must be read: + +- `ext` is **optional even on a `code-edit` gate** - the hook omits the key when it cannot resolve an + extension. Extension counts therefore do not sum to the `code-edit` total; never present them as + if they do. +- `stale` is a per-event flag, `0` or `1` - not a count of retros. See Friction below. +- `spec_id` and `phase` are `-` when no spec is in scope. Treat `-` as its own bucket; do not drop it + and do not rank it as a spec. + +**Malformed lines.** A line is well-formed if and only if it starts with `{"ts":"`, contains +`"event":"`, and ends with `}`. A partially-written trailing line (the hook was interrupted +mid-append), a blank line, and any non-JSON content all fail that test. Such lines are **skipped and +counted** - never allowed to abort the report. Report the skipped count explicitly even when it is +zero: a silent skip and a clean file are not the same fact. + +Decision counts are scoped to the events that carry a `decision` field (`gate` and +`spec_transition`); do not present them as a ratio over all events. + +## Phase 2 - Registry + +Parse `` rows: `| ID | Type | Status | Created | Title |`. Collect specs whose +Status is `in-progress`, then `draft` / `approved` counts, then `done`. Tolerate a missing trailing +newline on the last row. + +## Phase 3 - Friction + +Counts say how much happened; friction says where it is stuck. Derive from the same numbers, and +present only lines that have data behind them - omit an empty friction section rather than printing +"none found" three times. + +- **Blocked specs**: `spec_id` values ranked by `event: gate` + `decision: block` count. The top + entries are where the operator is fighting the gate. +- **Repeated code-edit warns**: `spec_id` values with a high `gate: code-edit` + `decision: warn` + count - the gate is set to warn and is being ignored repeatedly. +- **Retro pressure**: `spec_id` values ranked by **how many** `subagent_stop` events carry + `"stale":1`. `stale` is a per-event flag (`0` or `1`), not a magnitude - the hook emits one event + per in-progress spec per subagent stop and sets `1` when that spec's retro is stale or missing. + Rank by occurrence count; never present a `stale` value as a quantity of retros. +- **Silent specs**: rows in the registry with status `in-progress` that appear **zero** times in the + log. Either the work is not happening or metrics started after the spec did; say which is not + determinable from the log. + +## Phase 4 - Render + +``` +# Spec status + +Window: -> ( events, skipped) + +Note: an earlier generation was rotated to events.jsonl.1 and is NOT included - this +summary covers the live log only. + +## In progress + +| ID | Type | Created | Title | +... +(or: "No specs in progress." / "No spec index found at .") + +## Gate activity + +| Gate | allow | warn | block | total | +|---|---|---|---|---| +| verify | ... | +| protected | ... | +| code-edit | ... | + +Extensions seen on code-edit gates: .cs (12), .ts (4) + +## Lifecycle transitions + +| Spec | From -> To | Count | +... + +## Friction + +- : blocked N times at the gate +- : N code-edit warns ignored +- : retro stale count reached N +- : in progress but absent from the log +``` + +Degrade states render the same skeleton with the metrics sections replaced by exactly one labelled +line: + +- ST002 -> `Metrics recording is disabled (hooks.metrics.enabled = false). No gate data to report.` +- ST003 -> `No metrics recorded yet - does not exist. Hooks write it on the first gate decision.` +- ST004 -> `Metrics log exists at but is empty (0 bytes).` + +Each names the reason and the path, so "quiet" is never confused with "clean". + +## Hard constraints + +- **Read-only.** Never write, create, or edit any file - including `.specs/_explorations/`. This + command has no save option. +- Never invoke a subagent. +- Never start a spec lifecycle, never evaluate a gate, never modify `.specs/index.md`. +- Never read `events.jsonl.1`, and never merge it into the counts. +- Never abort the report because of a malformed line - skip it and count it. +- Never render an empty table as a result. A missing input is a labelled state (ST002-ST004), not a + blank section. +- Stack-agnostic: this command reads only specwright's own artifacts. It never runs a build, a test + command, or anything from `commands.*` in project-config. +- Do not guess at numbers. Every figure in the output comes from a counting command that was + actually run; if a count could not be produced, say so in place of the number. diff --git a/commands/verify.md b/commands/verify.md new file mode 100644 index 0000000..3f4b3f4 --- /dev/null +++ b/commands/verify.md @@ -0,0 +1,122 @@ +--- +description: Verify criterion -> task -> test traceability for a spec and write the 06-verify.md close-out gate artifact +argument-hint: +--- + +# /sd:verify - traceability verification gate + +Pure verification command - no subagent, no code changes, no edits outside the spec's own +folder. Proves that every success criterion and scenario in `00-spec.md` is implemented by at +least one task in `02-tasks.md` and observable through at least one existing test, then runs +the project test suite and writes `//06-verify.md` recording the verdict. + +The spec-gate hook blocks an `index.md` row transitioning to `done` unless this artifact +records `result: pass`. Re-run the command after fixing findings; it overwrites the artifact. + +## State machine + +| Condition | State | Behavior | +|---|---|---| +| Spec folder missing | not-found | STOP with VF001 | +| `02-tasks.md` missing | not-planned | STOP with VF002 | +| Otherwise | verifiable | Run all applicable checks, write artifact | + +Any status may be verified (verification before `in-progress` is allowed and useful), but the +artifact only matters to the hook at the `in-progress -> done` transition. + +## Phase 0 - Bootstrap (always) + +1. Read `.claude/project-config.json` -> `spec.dir`, `spec.indexFile`, `commands.test`. + Missing config -> STOP: "No project config found - run /sd:setup first." +2. Resolve `` against `/`: accept a full ID (`FEAT-1042`) or unique suffix. + Ambiguous or missing -> STOP listing candidates. +3. Read from disk (commands cannot load skills via frontmatter): + - `~/.claude/skills/sd/sd-severity-taxonomy/SKILL.md` + - `~/.claude/skills/sd/sd-evidence-citation/SKILL.md` + +## Checks + +Stable rule IDs (report every finding as `VF0xx`, severity per sd-severity-taxonomy, citing +`file:line` relative to project root). Generic rules apply to all spec types; traceability +rules apply when the corresponding section exists in `00-spec.md`. + +| ID | Applies | Check | Severity | +|---|---|---|---| +| VF001 | all | Spec folder and `00-spec.md` exist | BLOCK | +| VF002 | all | `02-tasks.md` exists | BLOCK | +| VF003 | all | Spec frontmatter `id` matches the folder name | BLOCK | +| VF010 | spec has `SC-:` scenario headings | Every SC ID is listed in >=1 task's `Covers` | BLOCK | +| VF011 | spec has `AC-:` criteria | Every AC ID is listed in >=1 task's `Covers` | BLOCK | +| VF012 | tasks have `Covers` | Every ID referenced in a `Covers` exists in `00-spec.md` | BLOCK | +| VF013 | feature specs | `## Success criteria` checkboxes carry `AC-:` prefixes | WARN | +| VF020 | all | Every task whose `Covers` != none has a `Test` field that is not `none`/empty | BLOCK | +| VF021 | all | Every file path named in a `Test` field exists (use Glob; a `Test` naming a suite/pattern instead of a path is checked by VF022 only) | BLOCK | +| VF022 | all | `commands.test` from project-config runs and exits green | BLOCK | +| VF023 | `commands.test` empty/null | Cannot run tests - report and continue | WARN | +| VF030 | all | Every `## Success criteria` checkbox in `00-spec.md` is checked (`- [x]`) | BLOCK | + +Parsing shapes (exact): + +- Scenario IDs: headings matching `^### SC-([0-9]+):` in `00-spec.md`. +- Criterion IDs: lines matching `^- \[[ xX]\] AC-([0-9]+):` in `00-spec.md`. +- Covers: task lines matching `^- \*\*Covers\*\*: (.+)$` in `02-tasks.md`; split on commas; + `none` means no IDs. A task block with no `Covers` line is treated as `Covers: none` + (legacy compatibility). +- Test files: from each `- **Test**: ...` value, extract tokens that look like relative paths + (contain `/` or a file extension); check each with Glob. + +VF022 execution: run `commands.test` via Bash from the project root. Capture the exit code. +Do not guess a test command when `commands.test` is empty - that is VF023 (stack-agnostic +rule: never hardcode `dotnet test`, `npm test`, etc.). + +## Artifact + +ALWAYS write `//06-verify.md` (overwrite an existing one) - on pass AND on fail - +once the spec reaches the `verifiable` state. The `not-found` (VF001) and `not-planned` (VF002) +STOP states above are reached BEFORE that point and write no artifact at all: there is no spec +folder, or no `02-tasks.md` to check traceability against, so there is nothing yet to record. + +```markdown +--- +spec: +result: +date: +failures: +--- + +# Verification report - + +## Traceability + +| ID | Kind | Covered by | Test(s) | Status | +|---|---|---|---|---| +| SC-1 | scenario | T01, T03 | tests/... | PASS | +| AC-1 | criterion | T02 | tests/... | PASS | + +## Test run + +- Command: `` +- Exit code: + +## Findings + + +``` + +`result: pass` if and only if there are zero BLOCK-severity findings. WARN findings (VF013, +VF023) do not fail the run but must appear under Findings. + +## Output + +Print to the user: the traceability table, the findings list, the artifact path, and one of: + +- `[OK] verified - result: pass recorded in //06-verify.md` +- `[FAIL] verification failed ( BLOCK findings) - result: fail recorded. Close-out is + blocked until /sd:verify passes.` + +## Hard constraints + +- Never edit any file except `//06-verify.md`. +- Never invoke a subagent. +- Never mark a criterion covered without a concrete task ID + existing test citation. +- Findings without a `file:line` citation are invalid (sd-evidence-citation). diff --git a/docs/adr/0001-validate-parses-task-content.md b/docs/adr/0001-validate-parses-task-content.md new file mode 100644 index 0000000..5446154 --- /dev/null +++ b/docs/adr/0001-validate-parses-task-content.md @@ -0,0 +1,75 @@ +# ADR 0001: `/sd:spec validate` may parse artifact content, not only file structure + +- Status: proposed +- Date: 2026-07-21 +- Source spec: Jira SW-11 (`FEAT-context-refs-gate`); plan at + `_bmad-output/SW-11-implementation-plan.md` +- Supersedes: none + +## Context + +Until now `/sd:spec validate` has been a pure file-ops command. Every rule in its table +(`commands/spec.md`, `SL001`-`SL055`) inspects a spec from the *outside*: frontmatter fields, +placeholder tokens, which artifacts exist for a given status, index/folder symmetry, transition +replay, link integrity. **No rule has ever opened `02-tasks.md` and read what is inside it.** + +SW-11 asked for a gate that fails a spec whose atomic tasks carry no context reference for the +implementer. `sd-implementer` is an isolated subagent whose entire input is the task block +(`agents/implementer.md:23`), so a task with no precedent citation starves it, and the failure +surfaces late - at implement time rather than at spec time. + +There was no existing home for such a check. `/sd:verify` already reads task fields +(`commands/verify.md:45-56`, `VF010`-`VF030`) but runs *after* implementation, which is exactly +the late failure SW-11 wants to eliminate. Placing the check in `/sd:spec validate` is the only +option that fires early - and it costs the command its file-ops-only character. + +The measured evidence also shapes the rule's severity. In `asian-sportsbook-v2`, the only live +repo running specwright, 29 tasks span 4 specs: the 22 authored after the `Pattern refs` field +shipped (`CHANGELOG.md:558`) all carry refs, and the 7 without it belong to two specs that predate +the field. No retro in that corpus records an implementer starved of context. + +Separately, the same corpus shows the field label syntax has drifted three ways +(`- **Files**:`, `- Files:`, `- **Files:**`), so any content parser needs a tolerant grammar or it +false-fails the best-authored specs. + +## Decision + +`/sd:spec validate` is permitted to parse the *content* of spec artifacts, not only their +structure and existence. The first such rule is `SL060` - a task block in `02-tasks.md` with no +`Pattern refs` field - at severity **WARN**. + +Content parsing is bounded by three conditions: + +1. **One grammar, centrally defined.** Readers use the `Field label grammar` section of + `skills/sd-atomic-task-format/SKILL.md`. No command, agent, or script writes its own per-field + matcher. +2. **A reserved band.** `SL060`-`SL069` belongs to task-block content rules. Future content checks + claim from this band rather than extending an unrelated one. +3. **WARN, not BLOCK.** A missing field leaves the registry truthful and is fixed by re-planning, + which is the existing WARN test stated in `commands/spec.md`. This is a deliberate reversal of + what SW-11 requested. + +## Consequences + +**Positive.** The gate fires at spec time instead of implement time, which is the whole point of +the ticket. The tolerant grammar is defined once and fixes a latent class of bug - `Files` drifted +three ways, so `Depends on` will too. The reserved band gives SW-11's successor +(SW-13, `FEAT-complexity-triage`, which targets the same task format) a home instead of an +arbitrary number. + +**Negative.** `/sd:spec validate` is no longer cheap to reason about: it now depends on a skill +file's grammar, so a change to `sd-atomic-task-format` can change validate's behavior at a +distance. The command also becomes the natural place to hang every future content check, and that +pressure needs resisting - the band is a sign, not a fence. + +**Unresolved.** `SL060` has **no CI coverage**. It lives in `commands/spec.md` as model-executed +prose, and no script in this repo parses task blocks, so `scripts/validate.{ps1,sh}` cannot +exercise it. The fixtures at `tests/task-format/fixtures/` state the conformance contract but have +no runner. Building one means a new script pair and overlaps SW-4's territory (already `Done`). +This is recorded rather than solved; a check that cannot fail is a failure mode this repo has +already shipped once (SW-20). + +**Scope declined.** SW-11 asked for a new field named `Context refs`. It was not created. The +existing `Pattern refs` field already covers the need, has 22-of-22 adoption under its current +name, is semantically tighter (it cites *precedent to mirror*), and renaming would touch 37 sites +across 10 live files plus 23 lines in the live corpus for no measurable gain. diff --git a/docs/adr/0002-complexity-triage-decomposition.md b/docs/adr/0002-complexity-triage-decomposition.md new file mode 100644 index 0000000..46daf03 --- /dev/null +++ b/docs/adr/0002-complexity-triage-decomposition.md @@ -0,0 +1,89 @@ +# ADR 0002: complexity triage forces decomposition through a conditional face of Gate 2 + +- Status: proposed +- Date: 2026-07-22 +- Source spec: Jira SW-13 (`FEAT-complexity-triage`); plan at + `_bmad-output/SW-13-implementation-plan.md` +- Supersedes: none + +## Context + +External-user feedback: `sd-spec-architect` is accurate on medium tasks but degrades on complex +ones. The cause is structural, not model quality. Two things compound: + +1. **Single-pass planning.** One `TASK = plan` invocation authors the full `01-plan.md` + + `02-tasks.md`. Sequencing, dependency-graph, and Pattern-refs quality drop non-linearly as scope + grows. +2. **A shallow impact map.** The `haiku` explorer's `03-decisions.md` is thin on multi-subsystem + features, so the plan is built on a weak foundation before the architect even starts. + +The measured evidence names the shape of "complex". In `asian-sportsbook-v2`, the only live repo +running specwright, feature specs cluster at **3-4 tasks** (`FEAT-ASF-245`, `FEAT-ASF-246`) and at +**10-12 tasks** (`FEAT-ASF-251` and `FEAT-ASF-251-LeagueContainer`) - with nothing in between. The +two large ones matter twice over: they share one Jira key (`ASF-251`), created the same day, so a +human **had already hand-decomposed** one ticket into a parent + child. The pain and the workaround +both exist on disk; SW-13 paves that cowpath. + +The same corpus repeats SW-11's lesson. Task headings are written `### T01` in one spec and +`### โœ… T01` in another; a naive `^### T` counter reads the second as **zero tasks** and the +gate silently never fires. Any task-count heuristic must reuse the tolerant grammar from +`skills/sd-atomic-task-format/SKILL.md`. + +## Decision + +`/sd:feature` gains complexity triage, governed structurally (a gate), not by prose exhortation. + +1. **A spec-level `complexity` frontmatter field** (`S` | `M` | `L`) with a one-line rationale, + written by the architect at `create`. It reuses the `S|M|L` vocabulary of a task's + `Estimated complexity` but is a **different field at a different altitude** (whole spec vs. one + line item); the skill names the distinction so the two are never conflated. +2. **Estimate at create, measure at plan.** `complexity` is an honest estimate at create time (no + plan exists yet), so it is a plain author-fill token, not a `<>` deferred token. + Phase 3 measures the *actual* plan against the decompose thresholds. +3. **Decompose thresholds**, any of which trips the gate: tasks **> 8**, spans **> 2** production + layers (distinct `Layer` values, **excluding `Tests`/`Config`** - they cross-cut every change), + impact surface **> 8** files, or an unresolved Open question at plan time. `> 8` is set from the + corpus canyon between the 3-4 and 10-12 clusters; the Tests/Config exclusion is set from the same + data, where the 3-4-task mediums touch `Application` + `Domain` + `Tests` and a naive layer count + would have tripped the gate on exactly the specs that must pass friction-free. +4. **Gate Complexity is a conditional face of Gate 2, not a fourth gate.** Under threshold, Gate 2 + is the plan approval it has always been - zero added friction for the median 3-task spec. Over + threshold, the same gate becomes a HARD decompose approval: the architect refuses one oversized + plan and proposes 2+ child specs that partition the parent's SC/AC, with a dependency order. +5. **Sanctioned model escalation, aliases only.** A create-time `complexity: L` bumps the explorer + to `sonnet` (Phase 2, deeper map) and the architect to `opus` (Phase 3, harder plan). This is the + escape hatch for the legitimately-atomic large spec that does not partition cleanly. The bump is + a per-invocation main-thread override, mirroring the existing `sd-implementer` sonnet override; + no agent's `model:` frontmatter changes, and no full model ID is introduced. +6. **The parent becomes an immutable umbrella.** On split, the parent goes `archived` with a retro + note naming its children; its spec/plan/tasks are never edited to match the split. Children are + normal feature specs, linked with the existing `/sd:spec link spawns` / `depends-on` machinery - + no bespoke decomposition mechanism is invented. + +## Consequences + +**Positive.** Complex work is forced into the medium regime the engine already plans well, instead +of producing one degraded oversized plan. The create-time estimate does double duty: it is both the +field SW-4 will validate and the trigger that deepens the impact map *upstream* of the gate, +addressing both root causes rather than only the planning one. Decomposition reuses link relations +that already exist, so the parent/child graph is queryable by every tool that already reads +`linked_specs`. + +**Negative.** The `S|M|L` vocabulary now names two different things (spec vs. task); the skill +carries the disambiguation, and a careless reader can still conflate them. Gate 2 is now a branch, +not a straight line - more logic to reason about at the one gate authors hit every run. The child +IDs (`FEAT--`) grow a naming convention that must stay stable, borrowed from +the corpus (`FEAT-ASF-251-LeagueContainer`). + +**Unresolved.** Like `SL060`, the gate is **model-executed prose** with no CI coverage - no script +in this repo drives a `/sd:feature` run, so nothing exercises the threshold arithmetic or the +tolerant task count automatically. The conformance evidence is a manual trace (a 3-task corpus spec +stays under; a 10-12-task one trips), recorded rather than automated. A check that cannot fail is a +failure mode this repo has shipped before (SW-20); this one is asserted by trace, not by runner. + +**Scope declined.** SW-13 does **not** add an `SL` lint rule. The `complexity` field is frontmatter, +so validation is `SL00x` territory, not the `SL06x` task-block band SW-11 reserved - there is no +collision to pre-empt and nothing to reserve here. The acceptance criteria assign linting of the +`complexity` field and split integrity to **SW-4** (`/sd:spec validate`), which owns it later. This +ADR records that hand-off; building it inside SW-13 would duplicate SW-4's job. Mid-execution +re-planning is also out of scope (a separate v2 epic) - triage here is pre-execution only. diff --git a/docs/adr/0003-adaptive-replan-loop.md b/docs/adr/0003-adaptive-replan-loop.md new file mode 100644 index 0000000..899e3e1 --- /dev/null +++ b/docs/adr/0003-adaptive-replan-loop.md @@ -0,0 +1,94 @@ +# ADR 0003: a sanctioned mid-execution re-plan loop, gated and append-only + +- Status: proposed +- Date: 2026-07-22 +- Source spec: Jira SW-14 (`FEAT-adaptive-replan`) +- Relates to: SW-13 (ADR 0002, pre-execution complexity triage); SW-4 (`/sd:spec validate`) +- Supersedes: none + +## Context + +The Planning pattern's core strength is **adaptivity** - re-planning when execution reveals new +information. specwright's immutability rule is right for audit, but it left the adaptive path +**undefined**. When an implementer discovers mid-Execute that `02-tasks.md` is wrong, the sanctioned +move did not exist: a model silently hack-edits the plan (violating sequencing, leaving no trail) or +stalls. ADR 0002 blocked oversized plans *before* execution; this is the second half of the +complex-task accuracy problem - fixing wrong plans *during* execution so errors stop compounding. + +The measured evidence reshaped the ticket. In `asian-sportsbook-v2`, the only live repo running +specwright, 29 tasks across 5 specs contain exactly **one** real case of a wrong plan +(`FEAT-ASF-251`, retro line 27): *"My spec decision B was wrong to copy euro's CASESENSITIVE ... +Updated T8 to assert NO CASESENSITIVE. Spec Scenario 1 / decision B corrected."* Two facts matter: + +1. The discovery surfaced at **batch review (Phase 5b)**, not mid-task. The gate must be reachable + from the review phase, not only from the Execute loop. +2. The fix was a **hand-edit of T8 plus a spec correction, recorded as one retro sentence** - exactly + the un-sanctioned path this work replaces. Immutability was already violated there, and nothing + stopped it. No `## Revisions` log exists anywhere in the corpus. + +Reading the workflow files corrected the ticket's stated scope. The ticket named "commands/feature.md +(+ bug/refactor/perf Execute phases)". But only **feature** and **refactor** produce a `01-plan.md` + +`02-tasks.md` pair (confirmed by `/sd:spec validate`'s own artifact-presence rule). `/sd:bug` and +`/sd:rca` have no atomic task list to re-plan; `/sd:perf` already carries its own adaptive loop +(Phase 4 reverts a failed hypothesis and re-selects at Gate 4). So the real scope is the two +plan+tasks workflows - and because both need the *identical* protocol for a shared linter to parse, +the protocol is defined once, not copy-pasted. + +## Decision + +1. **A shared skill, not a 12th command.** The re-plan protocol lives in one place - + `skills/sd-replan-loop/SKILL.md` - read at runtime by `/sd:feature` and `/sd:refactor` (the same + pattern by which `/sd:spec validate` reads `sd-severity-taxonomy`). The ticket's preferred + in-workflow loop is honored *and* the copy-paste-drift the CLAUDE.md "one SKILL.md" rule forbids + is avoided. Command count stays 12; skill count goes 7 -> 8. + +2. **Gate Re-plan is a conditional HARD gate, reachable from Execute and review.** Like Gate + Complexity (ADR 0002), it is not a new always-on gate: it fires only on a plan-invalidating + discovery (Phase 4 self-check, or a Phase 5/6 review BLOCK that the *plan* - not the code - was + wrong). When it fires it is HARD: explicit user approval, no override. A run that never hits a + plan-invalidating discovery never sees it, so `/sd:feature` still advertises 3 hard gates and + `/sd:refactor` still advertises 6. + +3. **Append-only `## Revisions` log in `01-plan.md`.** On approval, a revision entry `R` is + appended below the original plan prose (never editing it): `Trigger`, `Phase`, `Gate: re-plan`, + `Affected tasks`, `Delta`, `revised-from`. Numbering is contiguous from `R1`; a prior entry is + never rewritten. Only the affected task blocks in `02-tasks.md` are regenerated (by + `sd-spec-architect` via `TASK = plan` with a `REPLAN_SCOPE`), each marked `Revised-by: R`; every + other block stays byte-for-byte unchanged. + +4. **No new architect mode.** Re-plan reuses `TASK = plan` with a `REPLAN_SCOPE` / `REVISION` pair + rather than a fourth mode - a re-plan is a scoped plan, and it runs at most a handful of times per + spec. `Revised-by` is a conditional field (like refactor's `Parallel batch`), present only on a + regenerated task, never authored speculatively. + +5. **`SL070`-`SL073` in `/sd:spec validate`.** A new **revision-log integrity** band, distinct from + the `SL06x` task-content band and the `SL05x` link band (though it borrows the latter's two-sided + symmetry shape). `SL070` dangling marker, `SL071` one-sided/unreferenced revision, `SL072` broken + append-only history - all BLOCK (a lying audit trail). `SL073` malformed entry - WARN (poor record, + still decidable). The checks run only when a `## Revisions` section or a `Revised-by` marker + exists, so the common never-re-planned spec produces no finding. + +## Consequences + +**Positive.** The adaptive path is now sanctioned and audited. The one real corpus failure mode - a +plan proved wrong at review, then hand-patched with a retro sentence - now routes through a gate that +records the delta append-only and regenerates only the affected tasks, with the original plan intact. +Reuse of the existing `TASK = plan` mode, the `linked_specs`-style symmetry check, and the runtime +skill-read pattern means no new mechanism is invented. `/sd:perf` and `/sd:bug` are correctly left +alone. + +**Negative.** `01-plan.md` and `02-tasks.md` are now a two-sided record that can disagree; the linter +carries `SL070`-`SL072` to catch that, which is more surface to maintain. `Revised-by` adds a +conditional field a careless author could sprinkle onto a Plan-phase task (where it is wrong); the +skill and the validate rule together catch it. + +**Unresolved (stated, not hidden).** `/sd:spec validate` is a **static linter** with no Plan-phase +snapshot of `02-tasks.md`. It enforces the *internal consistency* of the revision record; it +**cannot** detect an undocumented silent edit by diffing. An edit that adds neither a `Revised-by` +marker nor a `## Revisions` entry is invisible to the lint and is prevented by the HARD gate, not by +`SL07x`. This is the honest boundary: the AC "validate rejects a `02-tasks.md` changed after Plan +phase with no revision log" is decidable precisely when the change is *marked*, and a compliant +re-plan always marks its work. Like `SL060` and the ADR-0002 gate, the gate itself is model-executed +prose with no CI driving a live `/sd:feature` run; conformance is asserted by the fixtures in +`tests/revision-log/` (a valid record passes, a dangling marker BLOCKs), not by a runner exercising a +full workflow. diff --git a/docs/architecture.md b/docs/architecture.md index 6b19b2a..a1db5a8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -10,11 +10,11 @@ specwright is a thin layer on top of Claude Code that enforces spec-driven devel +--------------------------------------------------------------------+ | Layer 1 - USER scope (~/.claude/, installed once) | | | -| commands/sd/ 11 workflow definitions | +| commands/sd/ 13 workflow definitions | | agents/sd/ 6 subagent prompt files | | hooks/sd/ 3 cross-platform hook scripts | | templates/sd/ 4 setup + 5 spec templates | -| skills/sd/ 6 reusable rule packs (referenced by agents) | +| skills/sd/ 8 reusable rule packs (referenced by agents) | | | | Generic engine. Never changes per project. Updated by re-running | | the installer. | @@ -89,7 +89,7 @@ Each subagent has a focused role, a minimal tool allowlist, and a model assignme |---|---|---|---| | `sd-spec-architect` | sonnet | Read/Write/Edit + Grep/Glob + Atlassian + Context7 | Authors specs, plans, tasks. Constitution-aware. | | `sd-code-explorer` | haiku | Read/Grep/Glob + GitNexus | Read-only navigation. Every finding cites `file:line`. | -| `sd-debugger` | sonnet | Read/Grep/Glob/Bash + sequential-thinking + GitNexus + MSSQL (SELECT only) + Tavily + Context7 | Hypothesis-tree investigation. Distinguishes proximate vs root cause. | +| `sd-debugger` | sonnet | Read/Grep/Glob/Bash + sequential-thinking + GitNexus + Tavily + Context7 | Hypothesis-tree investigation. Distinguishes proximate vs root cause. | | `sd-implementer` | haiku | Read/Write/Edit/MultiEdit/Grep/Glob/Bash + Context7 | Executes ONE atomic task with scope discipline. | | `sd-reviewer` | sonnet | Read/Grep/Glob + sequential-thinking + GitNexus | Severity-tagged review (๐Ÿ”ด BLOCK / ๐ŸŸ  WARN / ๐ŸŸก SUGGEST / ๐ŸŸข PASS). Cannot write. | | `sd-docs-writer` | sonnet | Read/Write/Glob/Grep + sd-evidence-citation | Authors one MADR-style ADR from a spec's decisions. Writes only the ADR file. | @@ -117,13 +117,16 @@ fan-out each command performs (left to right = invocation order; `(xN)` = once p /sd:spec -> (none - pure file ops on .specs/) /sd:setup -> (none - scaffolds CLAUDE.md / .specs/ / .claude/) /sd:release -> (none - pure file ops; mirrors /sd:spec) +/sd:verify -> (none - pure file ops; traceability check + gate artifact) +/sd:status -> (none - pure file ops; read-only report over events.jsonl + index.md) ``` -Three commands invoke no subagent at all (`/sd:spec`, `/sd:setup`, `/sd:release`) - they are deterministic -file operations the main thread performs directly. The rest share one backbone: the architect frames the -spec, an investigator (explorer or debugger) gathers evidence, the implementer makes the change one atomic -task at a time, and the reviewer gates the result. The reviewer has no write tools, so the loop cannot -auto-fix - findings always route back through a fresh implementer call. +Some commands invoke no subagent at all - `/sd:spec`, `/sd:setup`, `/sd:release`, `/sd:verify` and +`/sd:status` are deterministic file operations the main thread performs directly. The rest share one +backbone: the architect frames the spec, an investigator (explorer or debugger) gathers evidence, +the implementer makes the change one atomic task at a time, and the reviewer gates the result. The +reviewer has no write tools, so the loop cannot auto-fix - findings always route back through a +fresh implementer call. --- @@ -141,10 +144,11 @@ The split exists for three reasons: |---|---|---| | `sd-severity-taxonomy` | `sd-reviewer` | Severity levels + per-severity rules + mandatory output markdown. | | `sd-hypothesis-tree` | `sd-debugger` | Enumerate / verify protocol, the 5 mental models, score formula `(L ร— I) / C`, proximate-vs-root ladder. | -| `sd-atomic-task-format` | `sd-spec-architect`, `sd-implementer` | The task block (9 required fields + `Pattern refs`) + canonical enums (`Step type`, `Complexity`, `Reversibility`). | +| `sd-atomic-task-format` | `sd-spec-architect`, `sd-implementer` | The task block (11 required fields, including `Pattern refs`) + canonical enums (`Step type`, `Complexity`, `Reversibility`). | | `sd-evidence-citation` | `sd-code-explorer`, `sd-debugger`, `sd-reviewer`, `sd-docs-writer` | `file:line` discipline, snippet length, evidence taxonomy, grouping. | | `sd-spec-templates` | `sd-spec-architect` | Per-template authoring rules; which cross-phase fields to leave empty. | | `sd-pattern-discipline` | `sd-spec-architect`, `sd-implementer`, `sd-reviewer` | Pattern discovery and adherence: precedent sampling, `Pattern refs` authoring/following, conformance review. | +| `sd-replan-loop` | `sd-spec-architect`; `/sd:feature`, `/sd:refactor`, `/sd:spec validate` (runtime read) | Mid-execution re-plan protocol: HARD Gate Re-plan, append-only `## Revisions` log in `01-plan.md`, `Revised-by` task marker. Shared by the two plan+tasks workflows so the revision format is defined once. | Agents declare the skills they apply via a `skills:` list in YAML frontmatter: @@ -162,9 +166,11 @@ A skill is **not** an agent. It cannot be invoked directly, has no tools of its --- -## Hooks as context injection + guardrails +## Hooks as context injection, guardrails, and recording -Three hooks ship in cross-platform pairs (PowerShell + bash): +3 hooks ship in cross-platform pairs (PowerShell + bash). Each plays one of three roles: +`prompt-router` injects context, `spec-gate` guards edits (and records), `subagent-retro` +reminds about stale retros (and records). ### `prompt-router` (`UserPromptSubmit`) @@ -184,6 +190,11 @@ Runs before any code-editing tool. Decides: This catches the common failure mode where the user (or Claude) jumps straight to editing code without creating a spec first. +Alongside guarding, `spec-gate` also **records**: every gate decision (verify / protected / +code-edit) and every `.specs/index.md` lifecycle transition it observes is appended as one JSON +line to `.specs/_metrics/events.jsonl`. Recording is purely observational - it never alters a +gate decision, only measures it after the fact. See the event log section below for the schema. + **Block output schema (dual-format).** When `spec-gate` denies a tool call, it emits a single JSON object that carries **both** the new and the legacy schema fields so it works across Claude Code CLI versions: ```json @@ -205,6 +216,58 @@ This catches the common failure mode where the user (or Claude) jumps straight t Runs after every subagent invocation. If any in-progress spec has a `05-retro.md` older than `retroStaleMinutes`, emits a `` block. Debounced per session via `.claude/.hookstate/`. +`subagent-retro` also **records**: it appends one `subagent_stop` event per in-progress spec to +`.specs/_metrics/events.jsonl`, carrying the same stale/missing-retro count the reminder is based +on. Recording happens regardless of debounce - debounce only suppresses the user-facing reminder, +not the measurement. + +### Event log (`.specs/_metrics/events.jsonl`) + +`spec-gate` and `subagent-retro` are the hooks that record. Each appends one JSON object per +line (append-only, UTF-8, LF-terminated) to `.specs/_metrics/events.jsonl`, in a fixed key order so +the PowerShell and bash implementations produce byte-comparable lines: + +| Field | Present | Values | +|---|---|---| +| `ts` | always | `yyyy-MM-ddTHH:mm:ssZ` - whole-second UTC, the same format used by the `subagent-retro` debounce state file | +| `spec_id` | always | `FEAT-x` / `BUG-x` / ... , or `-` when no spec is in scope | +| `phase` | always | lifecycle status of `spec_id` (`draft` / `approved` / `in-progress` / `done`), or `-` | +| `event` | always | `gate` \| `spec_transition` \| `subagent_stop` | +| `gate` | when `event` is `gate` | `verify` \| `protected` \| `code-edit` | +| `decision` | when `event` is `gate` or `spec_transition` | `allow` \| `block` \| `warn` - on a transition, whether the index edit was ultimately allowed through. Most direct index edits are blocked by `paths.protected`, so `block` is the common case; a verified `done` close-out is the path that yields `allow`. | +| `from` | when `event` is `spec_transition` | previous lifecycle status, or `-` if not derivable | +| `ext` | when `gate` is `code-edit` | lowercased file extension, e.g. `.ps1` - never a path | +| `stale` | when `event` is `subagent_stop` | `0` or `1` - a flag, not a count. One event is emitted per in-progress spec per subagent stop; `1` means that spec's `05-retro.md` was stale or missing at that moment. Retro pressure is measured by counting `1`s over time, never by reading a single value as a quantity | + +Example lines: + +```json +{"ts":"2026-07-21T09:14:02Z","spec_id":"FEAT-spec-metrics","phase":"in-progress","event":"spec_transition","from":"approved","decision":"block"} +{"ts":"2026-07-21T09:31:44Z","spec_id":"FEAT-spec-metrics","phase":"in-progress","event":"gate","gate":"code-edit","decision":"warn","ext":".ps1"} +{"ts":"2026-07-21T09:40:55Z","spec_id":"FEAT-spec-metrics","phase":"in-progress","event":"subagent_stop","stale":1} +``` + +The log is metadata-only by design: no file paths, no code content, no commit messages - only spec +IDs, lifecycle phases, decisions, and file extensions. Controlled by `hooks.metrics` in +`.claude/project-config.json` (`enabled`, default `true`; `path`, default +`.specs/_metrics/events.jsonl`; `maxSizeKb`, default `1024`). Set `hooks.metrics.enabled` to +`false` to stop writing entirely. + +**Rotation (`maxSizeKb`).** Before each append, if the live file already meets or exceeds +`maxSizeKb * 1024` bytes, the hook rolls `events.jsonl` to `events.jsonl.1` (single generation - any +previous `.1` is overwritten) and starts a fresh log. Both hooks measure the same raw byte count +(`(Get-Item).Length` / `wc -c`) so PowerShell and bash roll at the same boundary. The default is +`1024` (~1 MB); an absent `maxSizeKb` is also treated as `1024`, so a `project-config.json` written +before this feature stays bounded with no edit. Set `maxSizeKb` to `0` to disable rotation and let +the log grow unbounded; any non-number is treated as invalid and also disables it. Rotation is +**best-effort** and inherits every metrics invariant: the roll never stops the append (a silent stop +would read as "metrics working" while dropping data - worse than growth), a failed roll (locked file +on Windows, read-only dir) is a silent no-op that falls through to the append, and it never alters a +gate decision or the hook's exit code. `events.jsonl.1` is a grace buffer, **not** part of any read +contract: the one consumer of the log, `/sd:status`, reads the **live file only** and merely notes +that a `.1` exists - a `.1` generation may be lost on the next roll, so counting it would report a +window that cannot be reproduced. + Hooks are **defensive**: any failure path exits `0` silently. They never block the user on their own bugs. --- @@ -217,7 +280,7 @@ Every workflow writes to a structured folder under `.specs//`: .specs/FEAT-INV-2501/ โ”œโ”€โ”€ 00-spec.md Why / What / Success criteria / Constitution check โ”œโ”€โ”€ 01-plan.md Phased implementation plan -โ”œโ”€โ”€ 02-tasks.md Atomic tasks (9 required fields + Pattern refs) +โ”œโ”€โ”€ 02-tasks.md Atomic tasks (11 required fields, incl. Pattern refs) โ”œโ”€โ”€ 03-decisions.md Impact analysis from sd-code-explorer + debugger output โ”œโ”€โ”€ 04-artifacts/ Evidence: logs, queries, traces, baselines, ticket snapshots โ””โ”€โ”€ 05-retro.md Append-only log: status transitions, surprises, follow-ups @@ -306,7 +369,7 @@ specwright is built around a small set of MCP servers most useful for spec-drive |---|---|---| | `atlassian` | Fetch JIRA tickets for `` arguments + snapshot ticket / related tickets / linked Confluence pages | spec-architect, commands | | `gitnexus` | Fast symbol search, callers, call graph | code-explorer, debugger, reviewer | -| `mssql` (SELECT/EXPLAIN only) | Inspect schema and query plans | debugger | +| `database` (project-provided, e.g. `mssql`, `postgres`; SELECT/EXPLAIN only) | Inspect schema and query plans | debugger | The split exists because user-scope servers are generic (any project benefits from `context7`), while project-scope servers carry project-specific connection strings or credentials. @@ -322,7 +385,7 @@ Every command, agent, and (conceptually) namespaced asset uses the `sd:` prefix: The prefix exists for three reasons: 1. **Collision avoidance.** A project may have its own `/feature` or `/review` slash command. `sd:` carves out a namespace. -2. **Discoverability.** Typing `/sd:` in Claude Code lists all 11 commands. The namespace is its own table of contents. +2. **Discoverability.** Typing `/sd:` in Claude Code lists all 13 commands. The namespace is its own table of contents. 3. **Removability.** Uninstalling the engine removes everything under `sd/` subfolders, leaving the rest of `~/.claude/` intact. --- diff --git a/docs/superpowers/plans/2026-07-20-sw5-hook-conformance.md b/docs/superpowers/plans/2026-07-20-sw5-hook-conformance.md new file mode 100644 index 0000000..b3f0fc9 --- /dev/null +++ b/docs/superpowers/plans/2026-07-20-sw5-hook-conformance.md @@ -0,0 +1,788 @@ +# SW-5 Cross-Platform Hook Conformance Tests Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A golden-fixture harness proving the PowerShell and bash hook implementations produce equivalent decisions for the same stdin JSON, wired into CI so a behavioral divergence in only one implementation fails the build with a clear diff. + +**Architecture:** Fixture cases live under `tests/hooks/fixtures///`, each holding an `input.json` (hook stdin with a `{{CWD}}` placeholder), an optional `workspace/` tree copied into a fresh temp dir per implementation run, an optional `setup.json` (mtime backdating), and an `expected.json` golden. A single cross-platform PowerShell 7 runner (`tests/hooks/run-conformance.ps1`) pipes each fixture into BOTH implementations, normalizes what each did into a small decision object, and asserts bash == pwsh == golden. A `-SelfTest` switch proves the harness detects divergence by substituting a stub bash hook. + +**Tech Stack:** PowerShell 7 (runner), bash + jq (bash hook impls), GitHub Actions (existing `ci.yml` matrix). + +## Global Constraints + +- All `.ps1` files are PURE ASCII (PowerShell 5.1 reads UTF-8-no-BOM as Windows-1252). Use `-`, `->`, `[OK]`, `[WARN]`, `[FAIL]`. Verify: `grep -nP "[^\x00-\x7F]" tests/hooks/*.ps1` must output nothing. +- PowerShell style: PascalCase functions, `$camelCase` variables, 4-space indent, explicit `param()` blocks. +- Hooks themselves are NOT modified by this work. **If a fixture exposes a real behavioral divergence between the bash and pwsh implementation of a hook, STOP: do not adjust the golden or the runner to paper over it. Report the divergence to the user - fixing it is a paired hook change with its own commit.** +- The runner is deliberately a SINGLE cross-platform script, not a bash/pwsh pair: conformance must run both implementations in one process, and a duplicated runner would itself be a drift risk. This exception to the pairs convention is documented in the script header. +- Every PR adds a line under `## [Unreleased]` in `CHANGELOG.md`. +- Do not use `Date.now`-style timestamps in fixture files; the only time-dependent fixture (stale retro) uses `setup.json` mtime backdating applied by the runner at execution time. +- Branch: work happens on `feature/optimize-workflow/v1` (already checked out). Commit style: imperative mood, 50-char subject. + +## File Structure + +``` +tests/hooks/ + run-conformance.ps1 # single cross-platform runner + -SelfTest + fixtures/ + spec-gate/ + allow-doc-edit/ # each case: input.json + expected.json + allow-in-progress-spec/ # + optional workspace/ tree + block-code-no-spec/ + warn-code-no-spec/ + block-protected-path/ + allow-other-tool/ + allow-mode-off/ + allow-disabled/ + prompt-router/ + route-single-keyword/ + route-multi-workflow/ + ticket-with-spec-folder/ + ticket-without-spec-folder/ + silent-no-hints/ + silent-disabled/ + in-progress-surfaced/ + subagent-retro/ + remind-missing-retro/ + remind-stale-retro/ # only case with setup.json + silent-done-only/ + silent-rca-only/ + silent-disabled/ +.github/workflows/ci.yml # + 2 steps (conformance, self-test) +CHANGELOG.md # + 1 line under [Unreleased] +CONTRIBUTING.md # + short paragraph on the suite +``` + +Normalized decision schemas (also the shape of every `expected.json`; property order matters because comparison is canonical-JSON string equality): + +- spec-gate: `{"exitCode": 0, "decision": "allow"|"warn"|"block", "permissionDecision": "deny"}` - `permissionDecision` present only when `decision` is `block`. +- prompt-router: `{"exitCode": 0, "emitted": bool, "workflows": [], "ticketIds": [], "specFolders": [], "inProgress": []}` - all arrays sorted. +- subagent-retro: `{"exitCode": 0, "emitted": bool, "stale": [{"id": "...", "reason": "missing"|"stale"}]}` - sorted by id. + +--- + +### Task 1: Runner core + spec-gate fixtures + +**Files:** +- Create: `tests/hooks/run-conformance.ps1` +- Create: `tests/hooks/fixtures/spec-gate/<8 cases>/input.json`, `expected.json`, `workspace/...` (detailed below) + +**Interfaces:** +- Produces: `run-conformance.ps1` with functions `Resolve-BashPath`, `New-CaseWorkspace -CaseDir`, `Invoke-HookProcess -Exe -ProcArgs -Payload`, `Invoke-HookImpl -Impl -HookScript -CaseDir`, `ConvertTo-SpecGateDecision -Run`, `Get-CanonicalJson -Obj`, `Invoke-ConformanceCase -HookName -CaseDir -BashHook -PwshHook`, and a `$hookNormalizers` hashtable keyed by hook name. Tasks 2-4 add entries/normalizers and reuse `Invoke-ConformanceCase` unchanged. +- Consumes: `hooks/bash/spec-gate.sh`, `hooks/powershell/spec-gate.ps1` (read-only). + +- [ ] **Step 1: Create the spec-gate fixture cases (these are the failing tests)** + +Shared workspace pieces (each case gets its own copy under its `workspace/`; content per case listed after). + +`index-in-progress` variant of `.specs/index.md` (marker and ID on the same line): + +```markdown +| ID | Type | Status | Title | +|---|---|---|---| +| FEAT-TEST-001 | feature | in-progress | Conformance fixture feature | +``` + +`index-header-only` variant of `.specs/index.md` ("in-progress" appears only in a header, so NO spec is in progress - guards the same-line-detection semantics): + +```markdown +| ID | Type | Status (in-progress = active work) | Title | +|---|---|---|---| +| FEAT-DONE-002 | feature | done | Finished feature | +``` + +Config template for `workspace/.claude/project-config.json` (vary `mode` / `enabled` per case): + +```json +{ + "spec": { "dir": ".specs", "indexFile": ".specs/index.md" }, + "paths": { "protected": [".specs/constitution.md"] }, + "hooks": { "specGate": { "enabled": true, "mode": "block" } } +} +``` + +The 8 cases (create each dir under `tests/hooks/fixtures/spec-gate/`): + +**allow-doc-edit** - docs are allow-listed even in block mode with no spec. +- `input.json`: `{"tool_name":"Edit","cwd":"{{CWD}}","tool_input":{"file_path":"{{CWD}}/docs/guide.md"}}` +- `workspace/`: config with `"mode": "block"`; index-header-only. +- `expected.json`: `{"exitCode":0,"decision":"allow"}` + +**allow-in-progress-spec** - code edit passes when a spec is in progress. +- `input.json`: `{"tool_name":"Edit","cwd":"{{CWD}}","tool_input":{"file_path":"{{CWD}}/src/Foo.py"}}` +- `workspace/`: config with `"mode": "block"`; index-in-progress. +- `expected.json`: `{"exitCode":0,"decision":"allow"}` + +**block-code-no-spec** - code edit blocked in block mode without a spec. +- `input.json`: `{"tool_name":"Edit","cwd":"{{CWD}}","tool_input":{"file_path":"{{CWD}}/src/Foo.py"}}` +- `workspace/`: config with `"mode": "block"`; index-header-only. +- `expected.json`: `{"exitCode":0,"decision":"block","permissionDecision":"deny"}` + +**warn-code-no-spec** - same edit only warns in warn mode. +- `input.json`: same as block-code-no-spec. +- `workspace/`: config with `"mode": "warn"`; index-header-only. +- `expected.json`: `{"exitCode":0,"decision":"warn"}` + +**block-protected-path** - protected beats the `.specs/` allow-list (rule ordering). +- `input.json`: `{"tool_name":"Edit","cwd":"{{CWD}}","tool_input":{"file_path":"{{CWD}}/.specs/constitution.md"}}` +- `workspace/`: config with `"mode": "block"`; index-in-progress. +- `expected.json`: `{"exitCode":0,"decision":"block","permissionDecision":"deny"}` + +**allow-other-tool** - non-edit tools are ignored. +- `input.json`: `{"tool_name":"Read","cwd":"{{CWD}}","tool_input":{"file_path":"{{CWD}}/src/Foo.py"}}` +- `workspace/`: config with `"mode": "block"`; index-header-only. +- `expected.json`: `{"exitCode":0,"decision":"allow"}` + +**allow-mode-off** - mode=off short-circuits everything. +- `input.json`: same as block-code-no-spec. +- `workspace/`: config with `"mode": "off"`; index-header-only. +- `expected.json`: `{"exitCode":0,"decision":"allow"}` + +**allow-disabled** - enabled=false short-circuits everything. +- `input.json`: same as block-code-no-spec. +- `workspace/`: config with `"enabled": false, "mode": "block"`; index-header-only. +- `expected.json`: `{"exitCode":0,"decision":"allow"}` + +- [ ] **Step 2: Verify the suite fails (runner does not exist yet)** + +Run: `pwsh -NoProfile -File tests/hooks/run-conformance.ps1` +Expected: error - file not found. (This is the red state.) + +- [ ] **Step 3: Write the runner** + +Create `tests/hooks/run-conformance.ps1` with exactly this content: + +```powershell +#requires -Version 7.0 +<# +.SYNOPSIS + specwright: cross-implementation hook conformance runner. + +.DESCRIPTION + For every fixture case under tests/hooks/fixtures///: + 1. Create a fresh temp workspace PER IMPLEMENTATION and copy the + case's workspace/ tree into it (fresh copy means hook state such + as the subagent-retro debounce file cannot leak across runs). + 2. Apply setup.json actions (currently: backdating file mtimes). + 3. Substitute {{CWD}} in input.json with the workspace path + (forward slashes; both implementations accept them) and pipe the + payload into the implementation on stdin. + 4. Normalize what the hook did into a small decision object. + 5. Assert bash decision == pwsh decision == expected.json golden. + + A behavioral divergence in only one implementation fails the suite + and prints all three decision objects for a clear diff. + + -SelfTest substitutes a stub bash spec-gate hook that always allows, + then asserts the harness DETECTS the divergence. Proves the + comparison would notice real drift (mirror of scripts/selftest-docs). + +.NOTES + PURE ASCII ONLY (see hooks/powershell/prompt-router.ps1 for why). + Single cross-platform runner by design: unlike the platform-native + scripts/ checks, conformance must run BOTH implementations in one + process, so a bash twin of this script would itself be a drift risk. + CI runs this under pwsh on every matrix OS. +#> + +[CmdletBinding()] +param( + [switch]$SelfTest +) + +$ErrorActionPreference = 'Stop' + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$repoRoot = (Resolve-Path (Join-Path $scriptDir '..' '..')).Path +$fixturesDir = Join-Path $scriptDir 'fixtures' + +$script:pass = 0 +$script:fail = 0 + +function Write-Ok { + param([string]$Message) + Write-Host " [OK] $Message" + $script:pass++ +} + +function Write-Bad { + param([string]$Message) + Write-Host " [FAIL] $Message" + $script:fail++ +} + +function Resolve-BashPath { + # On Windows prefer Git Bash explicitly: System32 bash.exe is WSL's + # stub and fails when no distro is installed. + if ($IsWindows) { + $gitBash = 'C:\Program Files\Git\bin\bash.exe' + if (Test-Path -LiteralPath $gitBash) { return $gitBash } + } + $cmd = Get-Command bash -ErrorAction SilentlyContinue + if ($null -ne $cmd) { return $cmd.Source } + return $null +} + +function New-CaseWorkspace { + param([string]$CaseDir) + + $name = 'sd-conformance-' + [System.Guid]::NewGuid().ToString('N').Substring(0, 12) + $ws = Join-Path ([System.IO.Path]::GetTempPath()) $name + New-Item -ItemType Directory -Path $ws -Force | Out-Null + + $src = Join-Path $CaseDir 'workspace' + if (Test-Path -LiteralPath $src) { + # -Force on Get-ChildItem: fixture trees are mostly dot-dirs + # (.claude, .specs) which Unix wildcard copies would skip. + Get-ChildItem -LiteralPath $src -Force | ForEach-Object { + Copy-Item -LiteralPath $_.FullName -Destination $ws -Recurse -Force + } + } + + $setupPath = Join-Path $CaseDir 'setup.json' + if (Test-Path -LiteralPath $setupPath) { + $setup = Get-Content -LiteralPath $setupPath -Raw | ConvertFrom-Json + foreach ($t in @($setup.touch)) { + if ($null -eq $t) { continue } + $target = Join-Path $ws $t.path + if (Test-Path -LiteralPath $target) { + $item = Get-Item -LiteralPath $target + $item.LastWriteTimeUtc = [System.DateTime]::UtcNow.AddMinutes(-1 * [double]$t.ageMinutes) + } + } + } + + return $ws +} + +function Invoke-HookProcess { + param( + [string]$Exe, + [string[]]$ProcArgs, + [string]$Payload + ) + $psi = [System.Diagnostics.ProcessStartInfo]::new() + $psi.FileName = $Exe + foreach ($a in $ProcArgs) { $psi.ArgumentList.Add($a) } + $psi.RedirectStandardInput = $true + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $psi.UseShellExecute = $false + $proc = [System.Diagnostics.Process]::Start($psi) + $proc.StandardInput.Write($Payload) + $proc.StandardInput.Close() + # Hook output is tiny (well under pipe buffer size), so sequential + # reads cannot deadlock. + $stdout = $proc.StandardOutput.ReadToEnd() + $stderr = $proc.StandardError.ReadToEnd() + $proc.WaitForExit() + return [pscustomobject]@{ + ExitCode = $proc.ExitCode + Stdout = $stdout.Replace("`r", '') + Stderr = $stderr.Replace("`r", '') + } +} + +function Invoke-HookImpl { + param( + [string]$Impl, + [string]$HookScript, + [string]$CaseDir + ) + $ws = New-CaseWorkspace -CaseDir $CaseDir + try { + $wsForward = $ws.Replace('\', '/') + $inputPath = Join-Path $CaseDir 'input.json' + $payload = (Get-Content -LiteralPath $inputPath -Raw).Replace('{{CWD}}', $wsForward) + if ($Impl -eq 'bash') { + return Invoke-HookProcess -Exe $script:bashExe -ProcArgs @($HookScript) -Payload $payload + } + return Invoke-HookProcess -Exe 'pwsh' -ProcArgs @('-NoProfile', '-File', $HookScript) -Payload $payload + } finally { + Remove-Item -LiteralPath $ws -Recurse -Force -ErrorAction SilentlyContinue + } +} + +# ---- normalizers: one per hook, each maps a raw run to a decision object ---- + +function ConvertTo-SpecGateDecision { + param($Run) + $decision = 'allow' + $permission = $null + $stdoutTrim = $Run.Stdout.Trim() + if ($stdoutTrim.Length -gt 0) { + try { + $obj = $stdoutTrim | ConvertFrom-Json + if ($obj.decision) { $decision = [string]$obj.decision } + if ($obj.hookSpecificOutput -and $obj.hookSpecificOutput.permissionDecision) { + $permission = [string]$obj.hookSpecificOutput.permissionDecision + } + } catch { + $decision = 'unparseable-stdout' + } + } elseif ($Run.Stderr.Contains('[WARN]')) { + $decision = 'warn' + } + $out = [ordered]@{ exitCode = $Run.ExitCode; decision = $decision } + if ($null -ne $permission) { $out.permissionDecision = $permission } + return [pscustomobject]$out +} + +function ConvertTo-PromptRouterDecision { + param($Run) + $workflows = [System.Collections.Generic.List[string]]::new() + $ticketIds = [System.Collections.Generic.List[string]]::new() + $specFolders = [System.Collections.Generic.List[string]]::new() + $inProgress = [System.Collections.Generic.List[string]]::new() + $section = '' + foreach ($line in ($Run.Stdout -split "`n")) { + if ($line -match '^Workflow keyword matches:') { $section = 'workflows'; continue } + if ($line -match '^Ticket IDs detected: (.*)$') { + $section = 'tickets' + foreach ($t in ($Matches[1] -split ', ')) { + if ($t.Trim()) { $ticketIds.Add($t.Trim()) } + } + continue + } + if ($line -match '^Matching spec folders') { $section = 'folders'; continue } + if ($line -match '^No matching spec folder') { $section = ''; continue } + if ($line -match '^Specs currently in-progress') { $section = 'inprogress'; continue } + if ($line -match '^\s+-\s+(.+)$') { + $item = $Matches[1].Trim() + switch ($section) { + 'workflows' { + if ($item -match '^/sd:([a-z]+)') { $workflows.Add($Matches[1]) } + } + 'folders' { $specFolders.Add($item) } + 'inprogress' { $inProgress.Add($item) } + } + } + } + return [pscustomobject][ordered]@{ + exitCode = $Run.ExitCode + emitted = $Run.Stdout.Contains('') + workflows = @($workflows | Sort-Object) + ticketIds = @($ticketIds | Sort-Object) + specFolders = @($specFolders | Sort-Object) + inProgress = @($inProgress | Sort-Object) + } +} + +function ConvertTo-SubagentRetroDecision { + param($Run) + $stale = [System.Collections.Generic.List[object]]::new() + foreach ($line in ($Run.Stdout -split "`n")) { + if ($line -match '^\s+-\s+([A-Za-z0-9_\-]+): 05-retro\.md (missing|last touched)') { + $reason = if ($Matches[2] -eq 'missing') { 'missing' } else { 'stale' } + $stale.Add([pscustomobject][ordered]@{ id = $Matches[1]; reason = $reason }) + } + } + return [pscustomobject][ordered]@{ + exitCode = $Run.ExitCode + emitted = $Run.Stdout.Contains('') + stale = @($stale | Sort-Object -Property id) + } +} + +$hookNormalizers = @{ + 'spec-gate' = ${function:ConvertTo-SpecGateDecision} + 'prompt-router' = ${function:ConvertTo-PromptRouterDecision} + 'subagent-retro' = ${function:ConvertTo-SubagentRetroDecision} +} + +function Get-CanonicalJson { + param($Obj) + return ($Obj | ConvertTo-Json -Depth 5 -Compress) +} + +function Invoke-ConformanceCase { + param( + [string]$HookName, + [string]$CaseDir, + [string]$BashHook, + [string]$PwshHook + ) + $normalizer = $hookNormalizers[$HookName] + $bashRun = Invoke-HookImpl -Impl 'bash' -HookScript $BashHook -CaseDir $CaseDir + $pwshRun = Invoke-HookImpl -Impl 'pwsh' -HookScript $PwshHook -CaseDir $CaseDir + $expected = Get-Content -LiteralPath (Join-Path $CaseDir 'expected.json') -Raw | ConvertFrom-Json + $bashJson = Get-CanonicalJson (& $normalizer $bashRun) + $pwshJson = Get-CanonicalJson (& $normalizer $pwshRun) + $expectedJson = Get-CanonicalJson $expected + return [pscustomobject]@{ + CaseName = Split-Path -Leaf $CaseDir + Bash = $bashJson + Pwsh = $pwshJson + Expected = $expectedJson + Match = ($bashJson -eq $expectedJson) -and ($pwshJson -eq $expectedJson) + } +} + +function Write-CaseDiff { + param($Result) + Write-Host " expected : $($Result.Expected)" + Write-Host " bash : $($Result.Bash)" + Write-Host " pwsh : $($Result.Pwsh)" +} + +# ---- preconditions ---------------------------------------------------------- + +$script:bashExe = Resolve-BashPath +if ($null -eq $script:bashExe) { + Write-Host '[FAIL] bash not found; conformance requires both implementations.' + exit 1 +} +if ($null -eq (Get-Command jq -ErrorAction SilentlyContinue)) { + # Without jq the bash hooks exit 0 silently, which would make every + # bash decision look like "allow" and the comparison meaningless. + Write-Host '[FAIL] jq not found; the bash hooks would silently no-op.' + exit 1 +} + +# ---- self-test mode --------------------------------------------------------- + +if ($SelfTest) { + Write-Host '=== conformance self-test: harness must DETECT divergence ===' + $stubName = 'sd-selftest-' + [System.Guid]::NewGuid().ToString('N').Substring(0, 8) + '.sh' + $stub = Join-Path ([System.IO.Path]::GetTempPath()) $stubName + "#!/usr/bin/env bash`nexit 0`n" | Set-Content -LiteralPath $stub -NoNewline -Encoding ascii + try { + $caseDir = Join-Path $fixturesDir 'spec-gate' 'block-code-no-spec' + $result = Invoke-ConformanceCase -HookName 'spec-gate' -CaseDir $caseDir ` + -BashHook $stub -PwshHook (Join-Path $repoRoot 'hooks' 'powershell' 'spec-gate.ps1') + } finally { + Remove-Item -LiteralPath $stub -Force -ErrorAction SilentlyContinue + } + if ($result.Pwsh -ne $result.Expected) { + Write-Bad 'self-test precondition: real pwsh impl no longer matches the golden' + Write-CaseDiff $result + exit 1 + } + if ($result.Match) { + Write-Bad 'self-test: harness did NOT detect an always-allow bash stub' + Write-CaseDiff $result + exit 1 + } + Write-Ok 'self-test: divergence in one implementation was detected' + exit 0 +} + +# ---- main ------------------------------------------------------------------- + +foreach ($hookDir in (Get-ChildItem -LiteralPath $fixturesDir -Directory | Sort-Object Name)) { + $hookName = $hookDir.Name + if (-not $hookNormalizers.ContainsKey($hookName)) { + Write-Bad "unknown fixture hook '$hookName' (no normalizer registered)" + continue + } + $bashHook = Join-Path $repoRoot 'hooks' 'bash' "$hookName.sh" + $pwshHook = Join-Path $repoRoot 'hooks' 'powershell' "$hookName.ps1" + Write-Host '' + Write-Host "=== $hookName ===" + foreach ($caseDir in (Get-ChildItem -LiteralPath $hookDir.FullName -Directory | Sort-Object Name)) { + $result = Invoke-ConformanceCase -HookName $hookName -CaseDir $caseDir.FullName ` + -BashHook $bashHook -PwshHook $pwshHook + if ($result.Match) { + Write-Ok $result.CaseName + } else { + Write-Bad $result.CaseName + Write-CaseDiff $result + } + } +} + +Write-Host '' +Write-Host "=== Summary: $($script:pass) passed, $($script:fail) failed ===" +if ($script:fail -gt 0) { exit 1 } +exit 0 +``` + +- [ ] **Step 4: Run the spec-gate cases and verify green** + +Run: `pwsh -NoProfile -File tests/hooks/run-conformance.ps1` +Expected: `=== spec-gate ===` section with 8 `[OK]` lines, summary `8 passed, 0 failed`, exit 0. (prompt-router / subagent-retro fixture dirs do not exist yet, so only spec-gate runs.) + +If a case fails with bash != pwsh: STOP per Global Constraints - that is a real divergence; report it. + +- [ ] **Step 5: ASCII check** + +Run: `grep -nP "[^\x00-\x7F]" tests/hooks/run-conformance.ps1` +Expected: no output. + +- [ ] **Step 6: Commit** + +```bash +git add tests/hooks/ +git commit -m "Add hook conformance runner + spec-gate fixtures" +``` + +--- + +### Task 2: prompt-router fixtures + +**Files:** +- Create: `tests/hooks/fixtures/prompt-router/<7 cases>/input.json`, `expected.json`, `workspace/...` + +**Interfaces:** +- Consumes: `Invoke-ConformanceCase` and `ConvertTo-PromptRouterDecision` from Task 1 (already registered in `$hookNormalizers`; no runner change needed). +- Produces: 7 fixture cases; the runner discovers them by directory name. + +- [ ] **Step 1: Create the prompt-router fixture cases** + +All `input.json` files have shape `{"prompt":"...","cwd":"{{CWD}}"}`. Deliberately do NOT write `workflow.keywords` into any config - these cases conformance-test the DEFAULT keyword lists hardcoded in both implementations, which is exactly where silent drift would hide. + +Config for cases that need one (`workspace/.claude/project-config.json`): + +```json +{ + "spec": { "dir": ".specs", "indexFile": ".specs/index.md" }, + "ticket": { "pattern": "^[A-Z]+-[0-9]+$" }, + "hooks": { "userPromptRouter": { "enabled": true } } +} +``` + +Reuse the two index variants from Task 1 (in-progress / header-only) as noted per case. Prompts below are chosen so no unintended keyword substring matches (e.g. "hello there" and "continue" contain no default keyword). + +**route-single-keyword** +- `input.json`: `{"prompt":"please fix this bug","cwd":"{{CWD}}"}` +- `workspace/`: config; index-header-only. +- `expected.json`: `{"exitCode":0,"emitted":true,"workflows":["bug"],"ticketIds":[],"specFolders":[],"inProgress":[]}` + +**route-multi-workflow** +- `input.json`: `{"prompt":"implement this feature, performance is slow","cwd":"{{CWD}}"}` +- `workspace/`: config; index-header-only. +- `expected.json`: `{"exitCode":0,"emitted":true,"workflows":["feature","perf"],"ticketIds":[],"specFolders":[],"inProgress":[]}` + +**ticket-with-spec-folder** +- `input.json`: `{"prompt":"continue INV-2501","cwd":"{{CWD}}"}` +- `workspace/`: config; index-header-only; plus empty file `workspace/.specs/FEAT-INV-2501-payment/.gitkeep` (keeps the folder in git). +- `expected.json`: `{"exitCode":0,"emitted":true,"workflows":[],"ticketIds":["INV-2501"],"specFolders":["FEAT-INV-2501-payment"],"inProgress":[]}` + +**ticket-without-spec-folder** +- `input.json`: `{"prompt":"continue INV-9999","cwd":"{{CWD}}"}` +- `workspace/`: config; index-header-only. +- `expected.json`: `{"exitCode":0,"emitted":true,"workflows":[],"ticketIds":["INV-9999"],"specFolders":[],"inProgress":[]}` + +**silent-no-hints** +- `input.json`: `{"prompt":"hello there","cwd":"{{CWD}}"}` +- `workspace/`: config; index-header-only. +- `expected.json`: `{"exitCode":0,"emitted":false,"workflows":[],"ticketIds":[],"specFolders":[],"inProgress":[]}` + +**silent-disabled** +- `input.json`: `{"prompt":"please fix this bug","cwd":"{{CWD}}"}` +- `workspace/`: config but with `"userPromptRouter": { "enabled": false }`; index-header-only. +- `expected.json`: `{"exitCode":0,"emitted":false,"workflows":[],"ticketIds":[],"specFolders":[],"inProgress":[]}` + +**in-progress-surfaced** +- `input.json`: `{"prompt":"hello there","cwd":"{{CWD}}"}` +- `workspace/`: config; index-in-progress (FEAT-TEST-001). +- `expected.json`: `{"exitCode":0,"emitted":true,"workflows":[],"ticketIds":[],"specFolders":[],"inProgress":["FEAT-TEST-001"]}` + +- [ ] **Step 2: Run and verify green** + +Run: `pwsh -NoProfile -File tests/hooks/run-conformance.ps1` +Expected: spec-gate 8 [OK] + `=== prompt-router ===` with 7 [OK], summary `15 passed, 0 failed`, exit 0. + +- [ ] **Step 3: Commit** + +```bash +git add tests/hooks/fixtures/prompt-router/ +git commit -m "Add prompt-router conformance fixtures" +``` + +--- + +### Task 3: subagent-retro fixtures (incl. deterministic stale-mtime case) + +**Files:** +- Create: `tests/hooks/fixtures/subagent-retro/<5 cases>/input.json`, `expected.json`, `workspace/...`, one `setup.json` + +**Interfaces:** +- Consumes: `Invoke-ConformanceCase`, `ConvertTo-SubagentRetroDecision`, and the `setup.json` mtime support in `New-CaseWorkspace` from Task 1. +- Produces: 5 fixture cases. + +- [ ] **Step 1: Pre-check the stale comparison in both implementations** + +Run: `grep -n "age" hooks/bash/subagent-retro.sh | grep -i thresh` and open the matching region of `hooks/powershell/subagent-retro.ps1`. +Expected: both treat `age >= threshold` the same way (bash uses `(( age >= threshold_secs ))`). If the PowerShell side uses a strictly-greater comparison, that is a REAL divergence - STOP and report it per Global Constraints. (The stale fixture below uses age 120 min vs threshold 30 min, so it stays deterministic either way; this check is about knowing, not about making the fixture pass.) + +- [ ] **Step 2: Create the subagent-retro fixture cases** + +All `input.json` files: `{"cwd":"{{CWD}}","session_id":"conformance-fixture"}`. + +Config template (`workspace/.claude/project-config.json`; vary per case): + +```json +{ + "spec": { "dir": ".specs", "indexFile": ".specs/index.md" }, + "hooks": { "subagentRetro": { "enabled": true, "retroStaleMinutes": 30, "debounceMinutes": 10 } } +} +``` + +Fresh temp workspaces per implementation mean no debounce state exists at run time, so debounce never suppresses these cases (per-impl debounce behavior stays covered by scripts/smoke-hooks). + +**remind-missing-retro** - missing retro is reported; RCA row in the same index is skipped. +- `workspace/`: config; `.specs/index.md`: + +```markdown +| ID | Type | Status | Title | +|---|---|---|---| +| FEAT-TEST-001 | feature | in-progress | Conformance fixture feature | +| RCA-2026-001 | rca | in-progress | Incident writeup | +``` + +- No `.specs/FEAT-TEST-001/` folder (retro missing). +- `expected.json`: `{"exitCode":0,"emitted":true,"stale":[{"id":"FEAT-TEST-001","reason":"missing"}]}` + +**remind-stale-retro** - existing retro older than the threshold is reported as stale. +- `workspace/`: config; index with only the FEAT-TEST-001 in-progress row (first three lines of the index above); file `workspace/.specs/FEAT-TEST-001/05-retro.md` containing `# Retro`. +- `setup.json`: + +```json +{ "touch": [ { "path": ".specs/FEAT-TEST-001/05-retro.md", "ageMinutes": 120 } ] } +``` + +- `expected.json`: `{"exitCode":0,"emitted":true,"stale":[{"id":"FEAT-TEST-001","reason":"stale"}]}` + +**silent-done-only** - nothing in progress, hook stays silent. +- `workspace/`: config; index-header-only (from Task 1). +- `expected.json`: `{"exitCode":0,"emitted":false,"stale":[]}` + +**silent-rca-only** - an in-progress RCA alone never triggers a reminder. +- `workspace/`: config; `.specs/index.md`: + +```markdown +| ID | Type | Status | Title | +|---|---|---|---| +| RCA-2026-001 | rca | in-progress | Incident writeup | +``` + +- `expected.json`: `{"exitCode":0,"emitted":false,"stale":[]}` + +**silent-disabled** - enabled=false short-circuits. +- `workspace/`: config with `"enabled": false`; index with the FEAT-TEST-001 in-progress row and no retro file. +- `expected.json`: `{"exitCode":0,"emitted":false,"stale":[]}` + +- [ ] **Step 3: Run and verify green** + +Run: `pwsh -NoProfile -File tests/hooks/run-conformance.ps1` +Expected: 8 + 7 + 5 = `20 passed, 0 failed`, exit 0. Run it TWICE to confirm the stale-mtime case is deterministic. + +- [ ] **Step 4: Commit** + +```bash +git add tests/hooks/fixtures/subagent-retro/ +git commit -m "Add subagent-retro conformance fixtures" +``` + +--- + +### Task 4: Self-test proves divergence detection + +**Files:** +- Modify: none (the `-SelfTest` branch already shipped inside `run-conformance.ps1` in Task 1; this task VERIFIES it and fixes it if broken). + +**Interfaces:** +- Consumes: `-SelfTest` switch; fixture `spec-gate/block-code-no-spec`. + +- [ ] **Step 1: Run the self-test** + +Run: `pwsh -NoProfile -File tests/hooks/run-conformance.ps1 -SelfTest` +Expected output ends with `[OK] self-test: divergence in one implementation was detected`, exit 0. + +- [ ] **Step 2: Negative check of the self-test itself** + +Temporarily run the plain suite again (`pwsh -NoProfile -File tests/hooks/run-conformance.ps1`) and confirm it still exits 0 - i.e. the self-test's stub did not leak state into normal runs. + +- [ ] **Step 3: Commit (only if fixes were needed)** + +```bash +git add tests/hooks/run-conformance.ps1 +git commit -m "Fix conformance self-test divergence detection" +``` + +--- + +### Task 5: CI wiring + CHANGELOG + docs + +**Files:** +- Modify: `.github/workflows/ci.yml` (after the "Docs-consistency self-test (PowerShell)" step, before the round-trip steps) +- Modify: `CHANGELOG.md` (one line under `## [Unreleased]`) +- Modify: `CONTRIBUTING.md` (short paragraph near the smoke-test/validator description) + +**Interfaces:** +- Consumes: `tests/hooks/run-conformance.ps1` from Tasks 1-4. + +- [ ] **Step 1: Add the CI steps** + +Insert into `.github/workflows/ci.yml` after the `Docs-consistency self-test (PowerShell)` step: + +```yaml + # --- Cross-impl hook conformance: pipe each golden fixture into BOTH + # implementations (bash + pwsh); normalized decisions must match the + # golden and each other. Runs under pwsh on every OS: ubuntu/macos get + # bash natively + pwsh preinstalled, windows gets pwsh natively + Git + # Bash. A divergence in only one impl fails with a three-way diff ---- + - name: Hook conformance (bash vs PowerShell) + shell: pwsh + run: ./tests/hooks/run-conformance.ps1 + + - name: Hook conformance self-test (divergence detection) + shell: pwsh + run: ./tests/hooks/run-conformance.ps1 -SelfTest +``` + +No `if:` condition - all three matrix OSes run both implementations (that is the point of the suite). + +- [ ] **Step 2: CHANGELOG entry** + +Read `CHANGELOG.md`, find `## [Unreleased]`, and add under its `### Added` (create the subsection if absent, matching the file's existing style): + +```markdown +- Cross-implementation hook conformance suite (`tests/hooks/`): golden fixtures are piped into + both the bash and PowerShell implementation of every hook and the normalized decisions must + match; wired into CI on all matrix platforms with a self-test proving divergence detection (E4). +``` + +- [ ] **Step 3: CONTRIBUTING paragraph** + +In `CONTRIBUTING.md`, after the paragraph describing `scripts/selftest-docs.{ps1,sh}` (around line 87), add: + +```markdown +`tests/hooks/run-conformance.ps1` (single cross-platform pwsh script by design - it must run BOTH +hook implementations in one process, so a bash twin would itself be a drift risk) pipes every +golden fixture under `tests/hooks/fixtures/` into the bash and PowerShell implementation of each +hook and fails if their normalized decisions diverge from each other or from the golden. Add a +fixture case whenever you add hook behavior; `-SelfTest` proves the harness still detects +divergence. +``` + +- [ ] **Step 4: Run the full local validation battery** + +Run, in order, and require all green: + +```bash +bash scripts/validate.sh +bash scripts/smoke-hooks.sh +bash scripts/selftest-docs.sh +pwsh -NoProfile -File tests/hooks/run-conformance.ps1 +pwsh -NoProfile -File tests/hooks/run-conformance.ps1 -SelfTest +``` + +Also: `pwsh -NoProfile -File scripts/validate.ps1` (Windows-native run of the validator). +Expected: every command exits 0. If `validate` flags the new CONTRIBUTING text as an undeclared inventory claim, either register a docClaims entry or reword to avoid the claim pattern - do not weaken the validator. + +- [ ] **Step 5: Commit** + +```bash +git add .github/workflows/ci.yml CHANGELOG.md CONTRIBUTING.md +git commit -m "Wire hook conformance suite into CI" +``` + +--- + +## Verification (whole feature) + +1. `pwsh -NoProfile -File tests/hooks/run-conformance.ps1` -> 20 passed, exit 0. +2. `pwsh -NoProfile -File tests/hooks/run-conformance.ps1 -SelfTest` -> detection [OK], exit 0. +3. Acceptance criterion from SW-5 ("a behavioral divergence in only one impl fails the suite with a clear diff"): demonstrated by the self-test AND manually - edit a scratch copy of one bash hook to flip a decision, run the suite, observe the three-way diff, restore. +4. `git push` and confirm the GitHub Actions matrix (ubuntu, windows, macos) runs the two new steps green. diff --git a/docs/superpowers/plans/2026-07-21-sw6-gate-hardening.md b/docs/superpowers/plans/2026-07-21-sw6-gate-hardening.md new file mode 100644 index 0000000..6a02857 --- /dev/null +++ b/docs/superpowers/plans/2026-07-21-sw6-gate-hardening.md @@ -0,0 +1,1014 @@ +# SW-6 Gate Hardening (/sd:verify + Scenario-ID Traceability) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development +> (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use +> checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Turn the spec close-out transition from a prose gate into a hook-checkable one: a new +`/sd:verify ` command proves criterion -> task -> test traceability and writes a +`06-verify.md` pass artifact; the spec-gate hook refuses to let an `index.md` row transition to +`done` unless that artifact records `result: pass`; scenario IDs (SC-N) and criterion IDs (AC-N) +in the feature template plus a `Covers` task field make the traceability mechanical. + +**Architecture:** Three layers. (1) Contract: SC-N / AC-N ID tokens in +`templates/specs/feature.template.md`, a new `Covers` field in the atomic-task format, authoring +rules in `sd-spec-templates`, and coverage checks in `sd-reviewer`. (2) Verification: a new +pure-file-ops command `commands/verify.md` (modeled on `/sd:spec validate`) with stable `VF0xx` +rule IDs that maps AC/SC -> tasks -> tests, runs `commands.test` from project-config, and always +writes `.specs//06-verify.md` with frontmatter `result: pass|fail`. (3) Enforcement: a new +Rule 0 in both spec-gate hooks that inspects the Edit/Write/MultiEdit payload targeting +`spec.indexFile`, detects rows newly transitioning to `done`, and blocks unless each such spec +has a passing artifact - a verified close-out is allowed through the protected-path rule; every +other direct `index.md` edit keeps today's always-block behavior. + +**Tech Stack:** Markdown prompt files (commands/skills/agents/templates), PowerShell 5.1+ and +bash hook scripts, jq, bespoke golden-fixture conformance runner +(`tests/hooks/run-conformance.ps1`), `scripts/validate.ps1|.sh` doc-claims checker. + +## Global Constraints + +- **Pure ASCII in all `.ps1` files.** Verify with + `grep -nP "[^\x00-\x7F]" hooks/powershell/*.ps1 install/*.ps1` - any output means REJECT. +- **Hooks ship in pairs.** Every behavior change to `hooks/powershell/spec-gate.ps1` requires the + matching change to `hooks/bash/spec-gate.sh`. Do NOT paper over divergence - the conformance + runner compares normalized bash vs pwsh output byte-for-byte, including reason strings. +- **Hooks are defensive.** Every failure path exits 0. Parse failures in the new rule fall + through to the existing protected-path block (safe-restrictive), never crash. +- **Block reason strings must be byte-identical across both hook implementations.** The exact + strings are specified in the "Normalized strings" section below - copy them verbatim. +- **Stack-agnostic.** `/sd:verify` runs tests via `commands.test` from + `.claude/project-config.json`, never a hardcoded tool. +- **Model fields are aliases only** (`sonnet`, `haiku`, `opus`, `inherit`) - not touched here, + but do not introduce full model IDs anywhere. +- **Style:** Markdown ATX headers, no trailing colons in headers, fenced code blocks with + language hints, 100-char soft wrap. Bash: `#!/usr/bin/env bash`, snake_case. PowerShell: + PascalCase functions, `$camelCase` variables. +- **Commits:** imperative mood, 50-char subject (repo style: "Add ...", "Fix ..." - no + conventional-commit prefixes). Work on branch `feat/sw6-gate-hardening` cut from the current + branch. +- **CHANGELOG:** one `### Added` bullet under `## [Unreleased]` tagged `(SW-6)` (Task 5). +- The `verify` command is a **utility**, not a workflow command: it does NOT join + `workflowCommands` in `specwright.manifest.json`, and `docs/architecture.md`'s "5 workflow + commands" stays 5. + +## Normalized strings (copy verbatim - both hooks, fixtures, and docs depend on these) + +- Verify artifact filename: `06-verify.md` inside `//`. +- Pass marker: a line in `06-verify.md` matching regex `^result:[[:space:]]*pass[[:space:]]*$` + case-insensitively (frontmatter line `result: pass`). +- New config flag: `hooks.specGate.verifyGate` (boolean, default `true` when absent; only a + literal JSON `false` disables). +- Hook block reason for an unverified done-transition (`` = ordinal-sorted, deduped, + `", "`-joined IDs; `` = `spec.dir` from config, default `.specs`): + + ```text + spec-gate: index row(s) [] -> done but no passing /sd:verify artifact. Run /sd:verify ; close-out is allowed only after //06-verify.md records 'result: pass'. + ``` + +- The existing protected-path reason string is unchanged: + `spec-gate: '' is listed under paths.protected in .claude/project-config.json. Update via /sd:refactor or an ADR; never edit directly.` + +## File Structure + +```text +commands/verify.md CREATE the /sd:verify command +commands/feature.md MODIFY Phase 6 close-out runs /sd:verify +commands/spec.md MODIFY status prose, artifact table, SL055 +commands/setup.md MODIFY command count 11 -> 12 +templates/specs/feature.template.md MODIFY SC-N scenario + AC-N criterion IDs +templates/project-config.template.json MODIFY hooks.specGate.verifyGate flag +skills/sd-spec-templates/SKILL.md MODIFY SC/AC authoring rules +skills/sd-atomic-task-format/SKILL.md MODIFY new Covers field +agents/spec-architect.md MODIFY fill Covers when authoring tasks +agents/reviewer.md MODIFY holistic scenario-coverage check +hooks/powershell/spec-gate.ps1 MODIFY Rule 0 verify gate +hooks/bash/spec-gate.sh MODIFY Rule 0 verify gate (paired) +tests/hooks/fixtures/spec-gate/ CREATE 6 new fixture cases +README.md, CLAUDE.md, CONTRIBUTING.md, +install/README.md, docs/architecture.md, +docs/usage.md, specwright.manifest.json MODIFY counts 11 -> 12, /sd:verify rows +CHANGELOG.md MODIFY [Unreleased] Added bullet (SW-6) +examples/spec-lint-fixture/clean/.specs/... MODIFY demonstrate SC/AC + 06-verify.md +``` + +--- + +### Task 1: Traceability contract - template, skills, agents + +**Files:** +- Modify: `templates/specs/feature.template.md` +- Modify: `skills/sd-spec-templates/SKILL.md` +- Modify: `skills/sd-atomic-task-format/SKILL.md` +- Modify: `agents/spec-architect.md` +- Modify: `agents/reviewer.md` + +**Interfaces:** +- Produces: scenario heading format `### SC-: `; criterion checkbox format + `- [ ] AC-: `; task field `- **Covers**: ` placed + directly after `- **Acceptance**`. Tasks 2 (verify command), 3 (workflows) and 6 (examples) + parse exactly these shapes. +- Consumes: nothing. + +- [ ] **Step 1: Update scenario headings and success criteria in the feature template** + +In `templates/specs/feature.template.md` replace the three scenario headings (lines 24, 30, 36) +and the success-criteria list (lines 46-51) so the `## What` and `## Success criteria` sections +read: + +```markdown +## What + + + +### SC-1: <> + +- **Given** <> +- **When** <> +- **Then** <> + +### SC-2: <> + +- **Given** <> +- **When** <> +- **Then** <> + +### SC-3: <> + +- **Given** <> +- **When** <> +- **Then** <> + +## Success criteria + + + +- [ ] AC-1: <> +- [ ] AC-2: <> +- [ ] AC-3: <> +- [ ] AC-4: <> +- [ ] AC-5: Unit + integration tests cover all scenarios above +- [ ] AC-6: No new constitution exceptions +``` + +Leave every other section of the template untouched. The `<<...>>` author-fill tokens stay - +only the headings/prefixes around them change. + +- [ ] **Step 2: Add SC/AC authoring rules to sd-spec-templates** + +In `skills/sd-spec-templates/SKILL.md`, in the section covering the feature template (it lists +scenario and success-criteria rules around lines 41-42), add these rules as list items: + +```markdown +- Scenario headings use stable IDs: `### SC-: `. IDs are sequential from SC-1 and are + never renumbered or reused after a scenario is deleted - downstream `Covers` fields and + `/sd:verify` reports reference them. +- Success criteria use stable IDs: `- [ ] AC-: `. Same stability rule as SC IDs. +- Every SC and AC ID must be covered by at least one task's `Covers` field in `02-tasks.md` + before `/sd:verify` can pass (see sd-atomic-task-format). +``` + +- [ ] **Step 3: Add the Covers field to sd-atomic-task-format** + +In `skills/sd-atomic-task-format/SKILL.md`: + +1. In the canonical task block (lines 10-23), insert one line directly after + `- **Acceptance**: `: + +```markdown +- **Covers**: +``` + +2. Change the block heading `## Task block (9 required fields + Pattern refs)` to + `## Task block (10 required fields + Pattern refs)` and the sentence + `The first 9 fields are **required**, not optional.` to + `The first 10 fields are **required**, not optional.` + +3. In the `## Field rules` section add, after the `### Acceptance` subsection: + +```markdown +### Covers + +Comma-separated scenario (SC-) and success-criterion (AC-) IDs from `00-spec.md` that +this task implements or proves. `none` is allowed only for pure wiring/polish tasks that +advance no criterion directly. Every ID referenced must exist in the spec; every SC and AC in +the spec must be covered by at least one task - `/sd:verify` fails the spec otherwise. Specs +authored before this field existed (no SC/AC IDs) are handled by `/sd:verify`'s generic +checks; treat a missing field as `Covers: none` when reading legacy `02-tasks.md` files. +``` + +- [ ] **Step 4: Teach the spec architect to fill Covers** + +In `agents/spec-architect.md`, in the task-authoring section (the part describing `02-tasks.md` +authoring, lines 63-76), add one instruction bullet: + +```markdown +- Fill `Covers` on every task: list the SC-/AC-IDs from `00-spec.md` the task implements or + proves. Before finishing, cross-check that every SC and AC ID in the spec appears in at + least one task's `Covers` - an uncovered criterion means the task list is incomplete, not + that the criterion is optional. +``` + +- [ ] **Step 5: Add the coverage check to the reviewer's holistic checklist** + +In `agents/reviewer.md`, in the `holistic` task-type checklist (lines 59-66), add one item: + +```markdown +- [ ] Scenario/criterion coverage: every SC- and AC- ID in `00-spec.md` appears in at + least one task's `Covers` field in `02-tasks.md`, and each covering task's `Test` exists. + Report an uncovered ID as a ๐Ÿ”ด BLOCK finding citing the spec line. +``` + +- [ ] **Step 6: Verify the shapes are consistent** + +Run: + +```bash +grep -n "SC-1" templates/specs/feature.template.md && \ +grep -n "AC-1" templates/specs/feature.template.md && \ +grep -n "Covers" skills/sd-atomic-task-format/SKILL.md agents/spec-architect.md agents/reviewer.md skills/sd-spec-templates/SKILL.md +``` + +Expected: at least one hit per file; the task-block line reads exactly +`- **Covers**: `. + +- [ ] **Step 7: Commit** + +```bash +git add templates/specs/feature.template.md skills/sd-spec-templates/SKILL.md \ + skills/sd-atomic-task-format/SKILL.md agents/spec-architect.md agents/reviewer.md +git commit -m "Add SC/AC traceability IDs and Covers task field" +``` + +--- + +### Task 2: The /sd:verify command + +**Files:** +- Create: `commands/verify.md` + +**Interfaces:** +- Consumes: SC/AC/Covers shapes from Task 1; `spec.dir`, `spec.indexFile`, `commands.test` from + `.claude/project-config.json`; skills `sd-severity-taxonomy` and `sd-evidence-citation` read + from disk at runtime (same pattern as `/sd:spec validate`, see `commands/spec.md:283-287`). +- Produces: the artifact contract `.specs//06-verify.md` with frontmatter `result: pass` or + `result: fail` - Tasks 3 and 4 depend on exactly this filename and marker line. + +- [ ] **Step 1: Read the conventions files** + +Read `commands/spec.md` in full (frontmatter shape, Phase 0 bootstrap, the `validate` +subcommand's rule-ID table and reporting format) and `commands/release.md` (single-transaction +command shape). The new command must feel like a sibling of these two. + +- [ ] **Step 2: Create commands/verify.md** + +Create `commands/verify.md` with exactly this content: + +````markdown +--- +description: Verify criterion -> task -> test traceability for a spec and write the 06-verify.md close-out gate artifact +argument-hint: +--- + +# /sd:verify - traceability verification gate + +Pure verification command - no subagent, no code changes, no edits outside the spec's own +folder. Proves that every success criterion and scenario in `00-spec.md` is implemented by at +least one task in `02-tasks.md` and observable through at least one existing test, then runs +the project test suite and writes `//06-verify.md` recording the verdict. + +The spec-gate hook blocks an `index.md` row transitioning to `done` unless this artifact +records `result: pass`. Re-run the command after fixing findings; it overwrites the artifact. + +## State machine + +| Condition | State | Behavior | +|---|---|---| +| Spec folder missing | not-found | STOP with VF001 | +| `02-tasks.md` missing | not-planned | STOP with VF002 | +| Otherwise | verifiable | Run all applicable checks, write artifact | + +Any status may be verified (verification before `in-progress` is allowed and useful), but the +artifact only matters to the hook at the `in-progress -> done` transition. + +## Phase 0 - Bootstrap (always) + +1. Read `.claude/project-config.json` -> `spec.dir`, `spec.indexFile`, `commands.test`. + Missing config -> STOP: "No project config found - run /sd:setup first." +2. Resolve `` against `/`: accept a full ID (`FEAT-1042`) or unique suffix. + Ambiguous or missing -> STOP listing candidates. +3. Read from disk (commands cannot load skills via frontmatter): + - `~/.claude/skills/sd/sd-severity-taxonomy/SKILL.md` + - `~/.claude/skills/sd/sd-evidence-citation/SKILL.md` + +## Checks + +Stable rule IDs (report every finding as `VF0xx`, severity per sd-severity-taxonomy, citing +`file:line` relative to project root). Generic rules apply to all spec types; traceability +rules apply when the corresponding section exists in `00-spec.md`. + +| ID | Applies | Check | Severity | +|---|---|---|---| +| VF001 | all | Spec folder and `00-spec.md` exist | BLOCK | +| VF002 | all | `02-tasks.md` exists | BLOCK | +| VF003 | all | Spec frontmatter `id` matches the folder name | BLOCK | +| VF010 | spec has `SC-:` scenario headings | Every SC ID is listed in >=1 task's `Covers` | BLOCK | +| VF011 | spec has `AC-:` criteria | Every AC ID is listed in >=1 task's `Covers` | BLOCK | +| VF012 | tasks have `Covers` | Every ID referenced in a `Covers` exists in `00-spec.md` | BLOCK | +| VF013 | feature specs | `## Success criteria` checkboxes carry `AC-:` prefixes | WARN | +| VF020 | all | Every task whose `Covers` != none has a `Test` field that is not `none`/empty | BLOCK | +| VF021 | all | Every file path named in a `Test` field exists (use Glob; a `Test` naming a suite/pattern instead of a path is checked by VF022 only) | BLOCK | +| VF022 | all | `commands.test` from project-config runs and exits green | BLOCK | +| VF023 | `commands.test` empty/null | Cannot run tests - report and continue | WARN | +| VF030 | all | Every `## Success criteria` checkbox in `00-spec.md` is checked (`- [x]`) | BLOCK | + +Parsing shapes (exact): + +- Scenario IDs: headings matching `^### SC-([0-9]+):` in `00-spec.md`. +- Criterion IDs: lines matching `^- \[[ xX]\] AC-([0-9]+):` in `00-spec.md`. +- Covers: task lines matching `^- \*\*Covers\*\*: (.+)$` in `02-tasks.md`; split on commas; + `none` means no IDs. A task block with no `Covers` line is treated as `Covers: none` + (legacy compatibility). +- Test files: from each `- **Test**: ...` value, extract tokens that look like relative paths + (contain `/` or a file extension); check each with Glob. + +VF022 execution: run `commands.test` via Bash from the project root. Capture the exit code. +Do not guess a test command when `commands.test` is empty - that is VF023 (stack-agnostic +rule: never hardcode `dotnet test`, `npm test`, etc.). + +## Artifact + +ALWAYS write `//06-verify.md` (overwrite an existing one) - on pass AND on fail: + +```markdown +--- +spec: +result: +date: +failures: +--- + +# Verification report - + +## Traceability + +| ID | Kind | Covered by | Test(s) | Status | +|---|---|---|---|---| +| SC-1 | scenario | T01, T03 | tests/... | PASS | +| AC-1 | criterion | T02 | tests/... | PASS | + +## Test run + +- Command: `` +- Exit code: + +## Findings + + +``` + +`result: pass` if and only if there are zero BLOCK-severity findings. WARN findings (VF013, +VF023) do not fail the run but must appear under Findings. + +## Output + +Print to the user: the traceability table, the findings list, the artifact path, and one of: + +- `[OK] verified - result: pass recorded in //06-verify.md` +- `[FAIL] verification failed ( BLOCK findings) - result: fail recorded. Close-out is + blocked until /sd:verify passes.` + +## Hard constraints + +- Never edit any file except `//06-verify.md`. +- Never invoke a subagent. +- Never mark a criterion covered without a concrete task ID + existing test citation. +- Findings without a `file:line` citation are invalid (sd-evidence-citation). +```` + +- [ ] **Step 3: Sanity-check the file** + +Run: + +```bash +grep -c "VF0" commands/verify.md && head -5 commands/verify.md +``` + +Expected: >= 13 rule-ID mentions; frontmatter starts with `---` and has `description:` + +`argument-hint:` only (commands do not carry `skills:` - see `commands/spec.md:1-4`). + +- [ ] **Step 4: Commit** + +```bash +git add commands/verify.md +git commit -m "Add /sd:verify traceability gate command" +``` + +--- + +### Task 3: Workflow integration - feature close-out and spec registry + +**Files:** +- Modify: `commands/feature.md` (Phase 6, lines 177-187) +- Modify: `commands/spec.md` (status section ~lines 78-122, artifact list ~line 71, validate + rule table ~lines 246-273) + +**Interfaces:** +- Consumes: `/sd:verify` and the `06-verify.md` / `result: pass` contract from Task 2. +- Produces: prose gates that Tasks 4's hook enforcement backs mechanically; validate rule + `SL055` (WARN) for done-specs without a passing artifact. + +- [ ] **Step 1: Insert the verify step into feature close-out** + +In `commands/feature.md` Phase 6 (currently a 4-item list at lines 179-187), insert a new step +1 and renumber the rest: + +```markdown +## Phase 6 - Close-out + +1. Run `/sd:verify FEAT-`. It must report `result: pass`. + - On FAIL: address the findings (uncovered criterion -> back to Phase 3 to add tasks; + failing tests -> back to Phase 4). Re-run until it passes. Do NOT proceed on fail - the + spec-gate hook will block step 4 without a passing `06-verify.md`. +2. Append to `.specs/FEAT-/05-retro.md`: + - Tasks completed (count + IDs). + - Surprises encountered. + - Deferred follow-ups (with reserved spec IDs, if any). + - Constitution exceptions taken (should be none). + - Cost rough estimate if available. +3. Set frontmatter status=`done` in `00-spec.md`. +4. Update `.specs/index.md`: state -> `done`, completion date. +5. Print a 5-line summary to the user. +``` + +- [ ] **Step 2: Document the gated transition in the spec registry command** + +In `commands/spec.md`: + +1. In the status-transition section (the `in-progress -> done` row of the lifecycle, lines + 78-122), add: + +```markdown +The `in-progress -> done` transition is hook-enforced: spec-gate blocks the `index.md` edit +unless `//06-verify.md` exists and records `result: pass`. Run `/sd:verify ` +first. Disable only via `hooks.specGate.verifyGate: false` in project-config. +``` + +2. In the spec-folder artifact list (line ~71, the enumeration ending `05-retro.md`), extend it + with `06-verify.md` and one line describing it: + `06-verify.md - verification report written by /sd:verify; gates the done transition.` + +3. In the validate rule-ID table (lines 246-273), append one row: + +```markdown +| SL055 | Spec status `done` but `06-verify.md` is missing or records `result: fail` | WARN | +``` + +(WARN, not BLOCK: specs closed before SW-6 have no artifact and must not start failing +validation retroactively.) + +- [ ] **Step 3: Verify cross-references resolve** + +Run: + +```bash +grep -n "sd:verify" commands/feature.md commands/spec.md && grep -n "SL055" commands/spec.md +``` + +Expected: feature.md Phase 6 references `/sd:verify FEAT-`; spec.md mentions the +verifyGate flag and lists SL055 exactly once in the rule table. + +- [ ] **Step 4: Commit** + +```bash +git add commands/feature.md commands/spec.md +git commit -m "Gate feature close-out on /sd:verify pass" +``` + +--- + +### Task 4: Spec-gate hook verify gate (paired ps1 + sh) with conformance fixtures + +**Files:** +- Create: `tests/hooks/fixtures/spec-gate/block-index-done-no-verify/{input.json,expected.json,workspace/...}` +- Create: `tests/hooks/fixtures/spec-gate/allow-index-done-with-verify/{...}` +- Create: `tests/hooks/fixtures/spec-gate/block-index-done-verify-fail/{...}` +- Create: `tests/hooks/fixtures/spec-gate/block-index-nondone-edit/{...}` +- Create: `tests/hooks/fixtures/spec-gate/block-index-done-multiedit-no-verify/{...}` +- Create: `tests/hooks/fixtures/spec-gate/block-index-done-gate-disabled/{...}` +- Modify: `hooks/powershell/spec-gate.ps1` +- Modify: `hooks/bash/spec-gate.sh` +- Modify: `templates/project-config.template.json` (hooks.specGate block, lines 122-126) +- Test: `tests/hooks/run-conformance.ps1` + +**Interfaces:** +- Consumes: `06-verify.md` + `result: pass` contract (Task 2); the exact block reason string + from "Normalized strings" above. +- Produces: `hooks.specGate.verifyGate` config flag; Rule 0 behavior all later docs describe. + +- [ ] **Step 1: Read the harness and one existing fixture pair** + +Read `tests/hooks/run-conformance.ps1` (note `ConvertTo-SpecGateDecision`, lines 172-213, and +how `{{CWD}}` is substituted) plus the existing +`tests/hooks/fixtures/spec-gate/block-protected-path/` and `allow-in-progress-spec/` cases +(input.json + expected.json + workspace layout). New fixtures MUST mirror their exact JSON +shapes - if the shapes below differ from what you find on disk, the on-disk shape wins. + +- [ ] **Step 2: Write the six new fixtures (failing first)** + +Shared workspace content - each case's `workspace/.specs/index.md` (unless noted): + +```markdown +# Spec index + +Auto-updated by /sd:spec status transitions. + +| ID | Type | Status | Created | Title | +|---|---|---|---|---| +| FEAT-001 | feature | in-progress | 2026-07-01 | Demo feature | +``` + +1. `block-index-done-no-verify/input.json` (no `06-verify.md` in workspace): + +```json +{ + "tool_name": "Edit", + "cwd": "{{CWD}}", + "tool_input": { + "file_path": "{{CWD}}/.specs/index.md", + "old_string": "| FEAT-001 | feature | in-progress | 2026-07-01 | Demo feature |", + "new_string": "| FEAT-001 | feature | done | 2026-07-01 | Demo feature |" + } +} +``` + +`expected.json` -> block with the verify-gate reason (from Normalized strings, with +`` = `FEAT-001`, `` = `.specs`). + +2. `allow-index-done-with-verify/` - same input; workspace adds + `workspace/.specs/FEAT-001/06-verify.md`: + +```markdown +--- +spec: FEAT-001 +result: pass +date: 2026-07-20 +failures: 0 +--- + +# Verification report - FEAT-001 +``` + +`expected.json` -> allow (exit 0, no decision output). + +3. `block-index-done-verify-fail/` - same as case 2 but the artifact line is `result: fail` + -> expected block with the same verify-gate reason. + +4. `block-index-nondone-edit/` - input edits the index WITHOUT any done transition: + +```json +{ + "tool_name": "Edit", + "cwd": "{{CWD}}", + "tool_input": { + "file_path": "{{CWD}}/.specs/index.md", + "old_string": "| FEAT-001 | feature | in-progress | 2026-07-01 | Demo feature |", + "new_string": "| FEAT-001 | feature | in-progress | 2026-07-01 | Renamed demo feature |" + } +} +``` + +`expected.json` -> block with the UNCHANGED protected-path reason for `.specs/index.md` +(regression guard: fall-through to Rule 1 must still fire). + +5. `block-index-done-multiedit-no-verify/` - MultiEdit payload, no artifact: + +```json +{ + "tool_name": "MultiEdit", + "cwd": "{{CWD}}", + "tool_input": { + "file_path": "{{CWD}}/.specs/index.md", + "edits": [ + { + "old_string": "| FEAT-001 | feature | in-progress | 2026-07-01 | Demo feature |", + "new_string": "| FEAT-001 | feature | done | 2026-07-01 | Demo feature |" + } + ] + } +} +``` + +`expected.json` -> block with the verify-gate reason. + +6. `block-index-done-gate-disabled/` - same input as case 1 PLUS a passing artifact in the + workspace PLUS `workspace/.claude/project-config.json`. IMPORTANT: a config file replaces + the hook's built-in defaults wholesale, so it must restate the protected list: + +```json +{ + "spec": { "dir": ".specs", "indexFile": ".specs/index.md" }, + "paths": { "protected": [".specs/constitution.md", ".specs/index.md", "LICENSE"] }, + "hooks": { "specGate": { "enabled": true, "mode": "warn", "verifyGate": false } } +} +``` + +`expected.json` -> block with the protected-path reason (gate off = today's behavior, even +with a passing artifact). + +- [ ] **Step 3: Run the conformance suite - expect the new cases to FAIL** + +Run: `pwsh -NoProfile -File tests/hooks/run-conformance.ps1` +Expected: all pre-existing cases PASS; the six new cases FAIL (hook does not implement the +rule yet). If a new *block* case fails because the reason string differs rather than because +no block was emitted, fix the fixture only if the on-disk expected.json schema was wrong. + +- [ ] **Step 4: Implement Rule 0 in spec-gate.ps1** + +In `hooks/powershell/spec-gate.ps1` add two functions after `Get-InProgressSpecs` +(line 262): + +```powershell +function Get-DoneTransitionIds { + param( + [object]$HookInput, + [string]$IndexPath + ) + # IDs that the pending edit marks as done but that the on-disk index does + # not yet record as done. Fragments are the tool-specific NEW content. + $fragments = New-Object System.Collections.Generic.List[string] + try { + $tool = $HookInput.tool_name + if ($tool -eq 'Edit') { + if ($HookInput.tool_input.new_string) { + $fragments.Add([string]$HookInput.tool_input.new_string) | Out-Null + } + } elseif ($tool -eq 'Write') { + if ($HookInput.tool_input.content) { + $fragments.Add([string]$HookInput.tool_input.content) | Out-Null + } + } elseif ($tool -eq 'MultiEdit') { + foreach ($e in @($HookInput.tool_input.edits)) { + if ($e.new_string) { $fragments.Add([string]$e.new_string) | Out-Null } + } + } + } catch { } + + $alreadyDone = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::Ordinal) + if (Test-Path -LiteralPath $IndexPath) { + try { + foreach ($line in (Get-Content -LiteralPath $IndexPath -Encoding UTF8 -ErrorAction Stop)) { + if ($line -match '\|\s*done\s*\|' -and $line -match '(FEAT|BUG|REF|PERF|RCA)-[A-Za-z0-9_\-]+') { + [void]$alreadyDone.Add($Matches[0]) + } + } + } catch { } + } + + $result = New-Object System.Collections.Generic.List[string] + foreach ($frag in $fragments) { + foreach ($line in ($frag -split "`n")) { + if ($line -match '\|\s*done\s*\|' -and $line -match '(FEAT|BUG|REF|PERF|RCA)-[A-Za-z0-9_\-]+') { + $id = $Matches[0] + if (-not $alreadyDone.Contains($id) -and -not $result.Contains($id)) { + $result.Add($id) | Out-Null + } + } + } + } + return ,$result +} + +function Test-VerifyArtifactPass { + param( + [string]$Cwd, + [string]$SpecDir, + [string]$SpecId + ) + $artifact = Join-Path $Cwd (Join-Path $SpecDir (Join-Path $SpecId '06-verify.md')) + if (-not (Test-Path -LiteralPath $artifact)) { return $false } + try { + $content = Get-Content -LiteralPath $artifact -Raw -Encoding UTF8 -ErrorAction Stop + } catch { + return $false + } + return ($content -match '(?im)^result:\s*pass\s*$') +} +``` + +Then in the main block, insert Rule 0 AFTER `$rel` is computed (line 307) and BEFORE the +`# Rule 1: protected paths` comment (line 309): + +```powershell +# Rule 0: verify gate on the spec index. A row transitioning to done requires +# a passing /sd:verify artifact; a verified close-out is allowed through the +# protected-path rule. Any other direct index edit falls through to Rule 1. +$verifyGateOn = $true +try { if ($config.hooks.specGate.verifyGate -eq $false) { $verifyGateOn = $false } } catch { } + +$indexRel = '.specs/index.md' +try { if ($config.spec.indexFile) { $indexRel = ([string]$config.spec.indexFile).Replace('\','/') } } catch { } +$specDir = '.specs' +try { if ($config.spec.dir) { $specDir = [string]$config.spec.dir } } catch { } + +if ($verifyGateOn -and [string]::Equals($rel, $indexRel, [System.StringComparison]::OrdinalIgnoreCase)) { + $indexAbs = Join-Path $cwd $indexRel + $doneIds = Get-DoneTransitionIds -HookInput $hookInput -IndexPath $indexAbs + if ($doneIds.Count -gt 0) { + $missing = New-Object System.Collections.Generic.List[string] + foreach ($id in $doneIds) { + if (-not (Test-VerifyArtifactPass -Cwd $cwd -SpecDir $specDir -SpecId $id)) { + $missing.Add($id) | Out-Null + } + } + if ($missing.Count -gt 0) { + $ids = (@($missing) | Sort-Object) -join ', ' + Write-BlockDecision "spec-gate: index row(s) [$ids] -> done but no passing /sd:verify artifact. Run /sd:verify ; close-out is allowed only after $specDir//06-verify.md records 'result: pass'." + exit 0 + } + # Every transitioning spec has a passing artifact - allow the close-out. + exit 0 + } +} +``` + +- [ ] **Step 5: Implement the identical rule in spec-gate.sh** + +In `hooks/bash/spec-gate.sh`, insert between the `emit_block` helper (ends line 206) and the +`# --- Rule 1` comment (line 208): + +```bash +# --- Rule 0: verify gate on the spec index ------------------------------------ +# A row transitioning to done requires a passing /sd:verify artifact; a +# verified close-out is allowed through the protected-path rule. Any other +# direct index edit falls through to Rule 1. Mirrors spec-gate.ps1 Rule 0. + +verify_gate="$(printf '%s' "${config_json}" | jq -r 'if .hooks.specGate.verifyGate == false then "false" else "true" end' 2>/dev/null)" +spec_dir="$(printf '%s' "${config_json}" | jq -r '.spec.dir // ".specs"' 2>/dev/null)" + +rel_lower="$(to_lower "${rel}")" +index_rel_norm="${index_rel//\\//}" +index_rel_lower="$(to_lower "${index_rel_norm}")" + +if [[ "${verify_gate}" == "true" && "${rel_lower}" == "${index_rel_lower}" ]]; then + fragments="" + case "${tool_name}" in + Edit) fragments="$(printf '%s' "${input}" | jq -r '.tool_input.new_string // empty' 2>/dev/null)" ;; + Write) fragments="$(printf '%s' "${input}" | jq -r '.tool_input.content // empty' 2>/dev/null)" ;; + MultiEdit) fragments="$(printf '%s' "${input}" | jq -r '[.tool_input.edits[]?.new_string // empty] | join("\n")' 2>/dev/null)" ;; + esac + + if [[ -n "${fragments}" ]]; then + # IDs marked done in the pending edit's new content. + pending_done="$(printf '%s' "${fragments}" \ + | grep -E '\|[[:space:]]*done[[:space:]]*\|' 2>/dev/null \ + | grep -o -E '(FEAT|BUG|REF|PERF|RCA)-[A-Za-z0-9_-]+' 2>/dev/null \ + | tr -d '\r' | LC_ALL=C sort -u)" + # IDs the on-disk index already records as done (not a transition). + already_done="" + if [[ -f "${index_path}" ]]; then + already_done="$(grep -E '\|[[:space:]]*done[[:space:]]*\|' "${index_path}" 2>/dev/null \ + | grep -o -E '(FEAT|BUG|REF|PERF|RCA)-[A-Za-z0-9_-]+' 2>/dev/null \ + | tr -d '\r' | LC_ALL=C sort -u)" + fi + + transition_ids="" + while IFS= read -r id; do + [[ -z "${id}" ]] && continue + if [[ -n "${already_done}" ]] && printf '%s\n' "${already_done}" | grep -qx "${id}"; then + continue + fi + transition_ids="${transition_ids}${id}"$'\n' + done <<< "${pending_done}" + + if [[ -n "${transition_ids}" ]]; then + missing="" + while IFS= read -r id; do + [[ -z "${id}" ]] && continue + artifact="${cwd}/${spec_dir}/${id}/06-verify.md" + if [[ ! -f "${artifact}" ]] \ + || ! grep -q -i -E '^result:[[:space:]]*pass[[:space:]]*$' "${artifact}" 2>/dev/null; then + if [[ -z "${missing}" ]]; then + missing="${id}" + else + missing="${missing}, ${id}" + fi + fi + done <<< "${transition_ids}" + + if [[ -n "${missing}" ]]; then + emit_block "spec-gate: index row(s) [${missing}] -> done but no passing /sd:verify artifact. Run /sd:verify ; close-out is allowed only after ${spec_dir}//06-verify.md records 'result: pass'." + exit 0 + fi + # Every transitioning spec has a passing artifact - allow the close-out. + exit 0 + fi + fi +fi +``` + +NOTE: Rule 1 (line 211) already computes `rel_lower="$(to_lower "${rel}")"`. After inserting +Rule 0 (which now computes it first), the duplicate assignment in Rule 1 is harmless - leave +it, matching the minimal-diff principle. `missing` is built in `LC_ALL=C sort -u` order, which +equals the ps1 `Sort-Object` ordinal order for these all-ASCII-uppercase IDs. + +- [ ] **Step 6: Add the verifyGate flag to the config template** + +In `templates/project-config.template.json`, in the `hooks.specGate` object (lines 122-126), +add after `"mode"`: + +```json +"verifyGate": true, +"_verifyGate_use": "true: an index.md row may transition to done only when //06-verify.md records 'result: pass' (written by /sd:verify). false: index.md stays fully protected as before SW-6." +``` + +Match the surrounding `_use`-style documentation-key convention exactly as found in the file. + +- [ ] **Step 7: Syntax + ASCII checks** + +Run: + +```bash +bash -n hooks/bash/spec-gate.sh && grep -nP "[^\x00-\x7F]" hooks/powershell/*.ps1 install/*.ps1; echo "exit=$?" +``` + +Expected: `bash -n` silent; the grep finds nothing (exit=1 from grep means no matches - that +is the PASS condition). + +- [ ] **Step 8: Run the conformance suite - expect all green** + +Run: `pwsh -NoProfile -File tests/hooks/run-conformance.ps1` +Expected: every case PASSES, including all pre-existing spec-gate cases (especially +`block-protected-path` and the traversal variants) and all six new ones. Also run +`pwsh -NoProfile -File tests/hooks/run-conformance.ps1 -SelfTest` - expected: self-test still +detects seeded divergence. + +- [ ] **Step 9: Manual smoke (bash path)** + +Run from a scratch dir with the fixture workspace shape (no artifact): + +```bash +echo '{"tool_name":"Edit","cwd":"'$PWD'","tool_input":{"file_path":"'$PWD'/.specs/index.md","old_string":"| FEAT-001 | feature | in-progress | 2026-07-01 | Demo feature |","new_string":"| FEAT-001 | feature | done | 2026-07-01 | Demo feature |"}}' | bash hooks/bash/spec-gate.sh +``` + +Expected: one-line JSON with `"decision":"block"` and the verify-gate reason naming FEAT-001. + +- [ ] **Step 10: Commit** + +```bash +git add hooks/powershell/spec-gate.ps1 hooks/bash/spec-gate.sh \ + templates/project-config.template.json tests/hooks/fixtures/spec-gate/ +git commit -m "Enforce /sd:verify pass on index done transition" +``` + +--- + +### Task 5: Documentation, counts, and CHANGELOG + +**Files:** +- Modify: `README.md`, `CLAUDE.md`, `CONTRIBUTING.md`, `install/README.md`, + `docs/architecture.md`, `docs/usage.md`, `commands/setup.md`, `specwright.manifest.json`, + `CHANGELOG.md` + +**Interfaces:** +- Consumes: the command list now containing `verify.md` (12 commands). +- Produces: docs consistent with `scripts/validate` Check 7 (docClaims). + +- [ ] **Step 1: Update every count and command list from 11 to 12** + +Exact known locations (verify each with grep before editing; line numbers may have drifted): + +- `README.md:4` - tagline word "Eleven" -> "Twelve". +- `README.md:29` - `**11 slash commands**` -> `**12 slash commands**`; append `/sd:verify` to + the name list. +- `README.md:~100` - command table: add row + `| /sd:verify | Verify criterion -> task -> test traceability; writes the close-out gate artifact | (none) |` + matching the existing table's column set. +- `CLAUDE.md:21` - `# expect 11 .md files` -> `# expect 12 .md files`. +- `CLAUDE.md:47` - `11 slash commands` -> `12 slash commands`; add `/sd:verify` to the + parenthesized list. +- `CONTRIBUTING.md:44` - `commands/ # 11 slash commands` -> 12. +- `install/README.md:7`, `:49` (count + name list), `:84`, `:92` - 11 -> 12; add `/sd:verify`. +- `docs/architecture.md:13` - `11 workflow definitions` -> 12; `:116-122` routing tree - add + `/sd:verify -> (none - pure file-ops)`; `:325` - `lists all 11 commands` -> 12. Leave the + "5 workflow commands" statement at `:70` at 5. +- `commands/setup.md:325` - `(11 workflow commands)` -> `(12 workflow commands)` (or the + file's current phrasing with 12; keep the docClaims phrase shape). +- `specwright.manifest.json` docClaims entries (~lines 123-170): wherever a claim phrase or + expected value embeds `11` for the command count, update to `12`. Do not touch + `workflowCommands` (stays the 5 pipeline commands). + +- [ ] **Step 2: Add the usage docs section** + +In `docs/usage.md`, following the `### /sd:release` / `### /sd:adr` precedent (lines ~234, +~265), add: + +```markdown +### /sd:verify + +Proves criterion -> task -> test traceability for one spec and writes +`.specs//06-verify.md` with `result: pass|fail`. The spec-gate hook blocks the spec's +`index.md` row from transitioning to `done` without a passing artifact +(`hooks.specGate.verifyGate`, default on). + + /sd:verify FEAT-1042 + +Run it at close-out (Phase 6 of /sd:feature runs it for you) or any time earlier as a +progress check. A FAIL lists VF0xx findings with file:line citations. +``` + +Also add a row to the "When to use" table (~line 306): +`| Prove a spec is really done (criteria covered, tests pass) | /sd:verify |` + +- [ ] **Step 3: CHANGELOG entry** + +Under `## [Unreleased]` / `### Added` in `CHANGELOG.md`, add as the first bullet: + +```markdown +- `/sd:verify ` traceability gate: SC-/AC-IDs in the feature template, a `Covers` + task field, a `06-verify.md` pass artifact, and spec-gate hook enforcement that blocks an + `index.md` row transitioning to `done` without a passing artifact + (`hooks.specGate.verifyGate`). (SW-6) +``` + +- [ ] **Step 4: Run the docs validator** + +Run: `pwsh -NoProfile -File scripts/validate.ps1` +Expected: PASS, including Check 7 (doc claims vs manifest). If any claim fails, the failure +message names the file and phrase - fix that spot, do not weaken the check. + +- [ ] **Step 5: Commit** + +```bash +git add README.md CLAUDE.md CONTRIBUTING.md install/README.md docs/architecture.md \ + docs/usage.md commands/setup.md specwright.manifest.json CHANGELOG.md +git commit -m "Document /sd:verify and bump command count to 12" +``` + +--- + +### Task 6: Example fixture refresh + +**Files:** +- Modify: `examples/spec-lint-fixture/clean/.specs/FEAT-CLEAN-001/00-spec.md` +- Modify: `examples/spec-lint-fixture/clean/.specs/FEAT-CLEAN-001/02-tasks.md` (if present) +- Create: `examples/spec-lint-fixture/clean/.specs/FEAT-CLEAN-001/06-verify.md` + +**Interfaces:** +- Consumes: SC/AC/Covers shapes (Task 1), artifact format (Task 2). +- Produces: a canonical filled example of the new traceability shapes. + +- [ ] **Step 1: Add IDs to the clean fixture spec** + +In `examples/spec-lint-fixture/clean/.specs/FEAT-CLEAN-001/00-spec.md`, rename each +`### Scenario : ` heading to `### SC-: ` and prefix each success-criteria +checkbox with `AC-: ` (sequential from 1), preserving all existing text and checked-state. + +- [ ] **Step 2: Add Covers lines to the fixture tasks** + +If the fixture has a `02-tasks.md`: add a `- **Covers**: ...` line after each task's +`- **Acceptance**:` line, distributing the SC/AC IDs so every ID is covered by at least one +task. If the fixture has no `02-tasks.md`, skip this step and note it in the commit body. + +- [ ] **Step 3: Add a passing verify artifact** + +Create `examples/spec-lint-fixture/clean/.specs/FEAT-CLEAN-001/06-verify.md` using the Task 2 +artifact format with `result: pass`, `failures: 0`, a traceability table consistent with the +IDs from steps 1-2, and Findings `none`. Use `date: 2026-07-21`. + +- [ ] **Step 4: Check the fixture is still "clean"** + +Read `examples/spec-lint-fixture/README.md` and confirm the clean fixture's promises still +hold (the linter there is prompt-driven, run by hand - the check here is consistency: IDs +sequential, every ID covered, artifact result matches). Confirm broken fixtures were NOT +touched. Then run `pwsh -NoProfile -File scripts/validate.ps1` again - expected PASS +(manifest excludes broken fixtures from doc scans; the clean fixture must not trip anything). + +- [ ] **Step 5: Commit** + +```bash +git add examples/spec-lint-fixture/clean/ +git commit -m "Show SC/AC traceability in clean example fixture" +``` + +--- + +## Verification (whole feature) + +1. `pwsh -NoProfile -File tests/hooks/run-conformance.ps1` - all fixtures green on both hook + implementations; `-SelfTest` still detects seeded divergence. +2. `pwsh -NoProfile -File scripts/validate.ps1` - all checks pass (doc claims now say 12). +3. `bash -n hooks/bash/spec-gate.sh` silent; + `grep -nP "[^\x00-\x7F]" hooks/powershell/*.ps1 install/*.ps1` finds nothing. +4. Sandbox install round-trip: + `.\install\install.ps1 -BasePath C:\temp\sd-test` then + `Get-ChildItem C:\temp\sd-test\commands\sd\` - expect **12** .md files including + `verify.md`; `.\install\uninstall.ps1 -BasePath C:\temp\sd-test -Force`; remove the dir. +5. Jira SW-6 acceptance walk-through: in a scratch project, author a feature spec with an AC + that no task covers -> `/sd:verify` reports VF011 FAIL and writes `result: fail`; the + spec-gate hook (echo-pipe smoke as in Task 4 Step 9) blocks the `done` edit; add the + covering task + passing artifact -> hook allows the edit. +6. CHANGELOG has the `(SW-6)` bullet under `[Unreleased]`. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index fd6d2b3..cd6dd48 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -9,6 +9,7 @@ Common issues and fixes. Skim the table of contents first; the fix you need is u - [Workflow issues](#workflow-issues) - [Spec issues](#spec-issues) - [Spec-gate blocking unexpectedly](#spec-gate-blocking-unexpectedly) +- [Spec metrics log](#spec-metrics-log) - [MCP issues](#mcp-issues) - [Resetting](#resetting) @@ -251,6 +252,28 @@ Set `hooks.specGate.mode` to `"off"` in `.claude/project-config.json`. Don't for --- +## Spec metrics log + +### `.specs/_metrics/events.jsonl` keeps growing + +**Cause**: `spec-gate` and `subagent-retro` each append one line per gate decision, `.specs/index.md` lifecycle transition, or subagent-stop check. Each line is small (roughly 120 bytes). The log is bounded by `hooks.metrics.maxSizeKb` (default `1024` = ~1 MB): when the live file reaches the cap, the next write rolls it to `events.jsonl.1` and starts fresh, keeping at most one previous generation. If you see the *live* file far past 1 MB, either `maxSizeKb` is set to `0` (rotation disabled), or every roll is failing silently - most likely a read-only `_metrics/` directory or the file being held open, both of which degrade to "keep appending" by design. + +**Fix**: no action needed for normal growth - it rotates itself. To change the cap, set `hooks.metrics.maxSizeKb` (in KB) in `.claude/project-config.json`; set it to `0` to disable rotation entirely. To stop all writes instead: +```json +"hooks": { + "metrics": { "enabled": false } +} +``` +Existing lines are left untouched; only future writes stop. Note that the consumer of the metrics log, `/sd:status`, reads only the live `events.jsonl` - `events.jsonl.1` is a grace buffer and a generation may be discarded on the next roll, so do not rely on `.1` for a complete history. + +### Is it safe to commit or share `.specs/_metrics/events.jsonl`? + +**Yes, by design.** Every line is metadata only: a timestamp, a spec ID, a lifecycle phase, an event kind and decision, and (for code-edit gates) a lowercased file extension. It never contains a file path, a file name, or any code content - see `docs/architecture.md`'s event log schema for the exact field list. + +Whether to actually commit it is still your call, not the engine's. If you'd rather keep it purely local, add `.specs/_metrics/` to the project's `.gitignore` yourself - specwright does not add this entry automatically. + +--- + ## MCP issues ### Atlassian: "Failed to fetch ticket" @@ -274,7 +297,7 @@ Set `hooks.specGate.mode` to `"off"` in `.claude/project-config.json`. Don't for **Fix**: trigger a re-index from the GitNexus client. While indexing, code-explorer falls back to grep with a noted caveat. -### MSSQL: "Cannot execute UPDATE / DELETE / INSERT" +### Database: "Cannot execute UPDATE / DELETE / INSERT" **Cause**: this is the intended behavior. The debugger has read-only access by constitution. diff --git a/docs/usage.md b/docs/usage.md index ca9f126..6ab127f 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -52,7 +52,7 @@ Spec-driven feature workflow. | 0 - Bootstrap | main thread | - | | 1 - Spec | `sd-spec-architect` | โ›” Gate 1 (spec approval) | | 2 - Impact | `sd-code-explorer` | - | -| 3 - Plan + tasks | `sd-spec-architect` | โ›” Gate 2 (plan approval) | +| 3 - Plan + tasks | `sd-spec-architect` | โ›” Gate 2 (plan approval; complexity triage) | | 4 - Execute | `sd-implementer` per task + main thread self-check | - | | 5 - Integration + batch review | main thread + `sd-reviewer` (holistic, once) | โ›” Gate 3 (integration + review) | | 6 - Close-out | main thread | - | @@ -61,6 +61,8 @@ Spec-driven feature workflow. Phase 2 records the codebase's precedents and conventions (nearest similar implementations, naming patterns, existing utilities) alongside the impact map. Phase 3 tasks then carry `Pattern refs` - `file:line` citations of precedent code the implementer must read before writing, so new code mirrors the existing structure. +The architect writes a spec-level `complexity` estimate (`S` | `M` | `L`) at Phase 1. Gate 2 then measures the actual plan: under the decompose thresholds (> 8 tasks, > 2 production layers excluding Tests/Config, > 8 impacted files, or an unresolved Open question) it is the normal plan approval with **zero added friction**; over them it becomes a HARD **Gate Complexity** that refuses one oversized plan and forces a split into medium child specs (`FEAT--`, linked to the parent umbrella). A create-time `L` estimate also escalates the impact and planning models a tier (aliases only). Still 3 hard gates - complexity triage is a second face of Gate 2, not a fourth gate. + Example: ``` /sd:feature INV-2501 @@ -289,6 +291,21 @@ Examples: --- +### /sd:verify + +Proves criterion -> task -> test traceability for one spec and writes +`.specs//06-verify.md` with `result: pass|fail`. The spec-gate hook blocks a FEAT +(feature-spec) `index.md` row from transitioning to `done` without a passing artifact +(`hooks.specGate.verifyGate`, default on). Other spec types (bug, refactor, perf, rca) close +out through the unconditional protected-path rule, same as before this gate existed. + + /sd:verify FEAT-1042 + +Run it at close-out (Phase 6 of /sd:feature runs it for you) or any time earlier as a +progress check. A FAIL lists VF0xx findings with file:line citations. + +--- + ## Common patterns ### Picking the right workflow @@ -304,6 +321,7 @@ Examples: | "Review this change for compliance" | `/sd:review` | | "Manage / browse the spec registry" | `/sd:spec` | | "Cut a release / generate release notes from completed work" | `/sd:release` | +| "Prove a spec is really done (criteria covered, tests pass)" | `/sd:verify` | ### Resuming a workflow diff --git a/docs/walkthrough.md b/docs/walkthrough.md index 7ad940a..891804a 100644 --- a/docs/walkthrough.md +++ b/docs/walkthrough.md @@ -128,6 +128,7 @@ type: feature status: draft jira: INV-2501 created: 2026-01-14 +linked_specs: [] --- # Add low-stock alert webhook for inventory threshold breaches @@ -175,11 +176,11 @@ manual poll and reduces mean time-to-restock by an estimated 4 hours. - ยง3 Quality: >=80% coverage on changed lines; integration test for the threshold-breach -> webhook-fire path. -## Linked specs -- Depends on: none -- Related to: none ``` +(Cross-references live in the `linked_specs` frontmatter field, written by `/sd:spec link` - +not in a body section.) + Then Gate 1: ``` diff --git a/examples/README.md b/examples/README.md index 72938c9..808000f 100644 --- a/examples/README.md +++ b/examples/README.md @@ -15,6 +15,14 @@ It covers: --- +## What is here now + +- [`spec-lint-fixture/`](spec-lint-fixture/) - a clean `.specs/` tree and a seeded-broken one for + exercising `/sd:spec validate`. Backs the SW-4 acceptance criterion. Run by hand, not in CI - + the linter is a prompt, so no script can execute it; the fixture README explains the trade-off. + +--- + ## What this folder will hold over time Ideas under consideration, no committed timeline (see [`../ROADMAP.md`](../ROADMAP.md) for what diff --git a/examples/spec-lint-fixture/README.md b/examples/spec-lint-fixture/README.md new file mode 100644 index 0000000..4d5950c --- /dev/null +++ b/examples/spec-lint-fixture/README.md @@ -0,0 +1,120 @@ +# Spec-lint fixture + +Two `.specs/` trees for exercising `/sd:spec validate`, backing the SW-4 acceptance criterion: +*a seeded broken spec surfaces each violation at the right severity; a clean tree returns +all-PASS.* + +| Tree | Expectation | +|---|---| +| [`clean/`](clean/) | Every spec PASSes. `_No findings._` under all four severity sections. | +| [`broken/`](broken/) | Every finding in the table below, at the stated severity, and nothing else. | + +Rule IDs (`SL0xx`) are defined in the rule table in `commands/spec.md`, under `## validate`. + +--- + +## How to run it + +`/sd:spec validate` is a **prompt**, not executable code, so no script can run it and CI cannot +gate on it. Read that limitation before trusting a green build: see "What this does not do" below. + +``` +cd examples/spec-lint-fixture/clean # then, in a Claude Code session rooted here: +/sd:spec validate --all # expect: every spec PASS, no findings + +cd ../broken +/sd:spec validate --all # expect: exactly the findings in the table below +``` + +The fixture ships its own `.claude/project-config.json` so `validate` can resolve `spec.dir` and +`spec.lifecycle` without a full `/sd:setup`. Placeholder checks read the type's template from +`~/.claude/templates/sd/specs/`, so the engine must be installed for `SL010` / `SL011` / `SL012` +to be exercised; without it those checks raise `SL013` instead. + +--- + +## Why `clean/` is the interesting half + +`clean/PERF-CLEAN-002` is at `approved` with its `<>` baseline token **unfilled**, +and it must PASS. Under the pre-SW-4 rule ("status >= `approved` -> no `<>` tokens +remaining") this correct spec FAILED, while `broken/PERF-BROKEN-002` โ€” the same spec with a +baseline invented from memory โ€” PASSED. The two perf specs are a matched pair: any change that +makes one behave like the other has reintroduced the bug SW-4 seam 1 fixed. + +--- + +## Expected findings in `broken/` + +Each `00-spec.md` carries `` comments naming its own violations, so a finding +can be traced to an intentional seed rather than an accident. + +| Spec | Rule | Severity | Seeded violation | +|---|---|---|---| +| `BUG-BROKEN-001` | `SL003` | BLOCK | `id: BUG-BROKEN-999` in a folder named `BUG-BROKEN-001` | +| `BUG-BROKEN-001` | `SL030` | BLOCK | Index row says `approved`, frontmatter says `draft` | +| `PERF-BROKEN-002` | `SL011` | BLOCK | `PHASE-2` baseline filled from memory at `approved` | +| `REF-BROKEN-003` | `SL002` | BLOCK | Required field `smell` missing | +| `REF-BROKEN-003` | `SL020` | BLOCK | `in-progress` refactor with no `01-plan.md` / `02-tasks.md` | +| `REF-BROKEN-003` | `SL042` | BLOCK | Frontmatter `in-progress`; retro log ends at `approved` | +| `FEAT-BROKEN-004` | `SL050` | BLOCK | `related-to: FEAT-NOPE-999` resolves to nothing | +| `FEAT-BROKEN-004` | `SL051` | BLOCK | `depends-on: RCA-BROKEN-005` has no inverse | +| `FEAT-BROKEN-004` | `SL033` | BLOCK | Listed on two index rows | +| `RCA-BROKEN-005` | `SL031` | BLOCK | Folder exists, no index row (orphan) | +| `FEAT-BROKEN-007` | `SL006` | BLOCK | `linked_specs: none` is a scalar, not a list | +| `FEAT-BROKEN-007` | `SL010` | BLOCK | Author-fill `<<...>>` tokens survive at `approved` | +| `BUG-BROKEN-008` | `SL051` | BLOCK | Three links, none with an inverse | +| `BUG-BROKEN-008` | `SL052` | BLOCK | `duplicate-of: BUG-BROKEN-008` is a self-link | +| `BUG-BROKEN-008` | `SL053` | WARN | `blocked-by` stored - an alias `link` normalizes away | +| `BUG-BROKEN-008` | `SL054` | WARN | `related-to: FEAT-BROKEN-004` listed twice | +| `REF-BROKEN-009` | `SL040` | BLOCK | Retro logs `draft -> done`, not an edge in the machine | +| `REF-BROKEN-009` | `SL020` | BLOCK | `done` refactor with no `01-plan.md` / `02-tasks.md` | +| `REF-BROKEN-009` | `SL012` | WARN | `PHASE-2` / `PHASE-3` tokens unfilled at `done` | +| `BUG-BROKEN-010` | `SL004` | BLOCK | `type: feature` in a folder whose `BUG` prefix means `bug` | +| `FEAT-BROKEN-011` | `SL005` | BLOCK | `status: reviewing` is not in `spec.lifecycle` | +| `FEAT-BROKEN-011` | `SL043` | BLOCK | Status is not `draft` and there is no retro log | +| `PERF-BROKEN-012` | `SL021` | BLOCK | `done` with a `05-retro.md` that has only its header | +| `REF-BROKEN-013` | `SL041` | BLOCK | Retro jumps `approved` -> an entry opening at `in-progress` | +| `FEAT-BROKEN-014` | `SL044` | WARN | `archived -> in-progress` logged with an empty reason | +| _(tree-wide)_ | `SL032` | BLOCK | `BUG-GHOST-006` row in `index.md` has no folder | + +`FEAT-BROKEN-011` is the one spec that seeds two rules on purpose. An illegal `status` cannot +have a legal retro log - no edge in the state machine ends at `reviewing` - so `SL005` always +drags `SL043` with it, and adding a retro to silence `SL043` would raise `SL040` instead. + +### Rules these seeds hold apart + +Four of the new seeds exist as much to keep neighbouring rules **from** firing as to make their +own rule fire. Each `00-spec.md` explains its own boundary; the summary: + +| Seed | Must fire | Must stay silent, and why | +|---|---|---| +| `BUG-BROKEN-010` | `SL004` | `SL002` / `SL011` - it carries both types' required fields and the bug template's phase tokens, so neither type resolution yields a second finding | +| `PERF-BROKEN-012` | `SL021` | `SL043` (the retro file exists) and `SL042` (no last entry to disagree with) | +| `REF-BROKEN-013` | `SL041` | `SL040` (both edges are legal), `SL042` (last entry matches frontmatter), `SL043` (a retro exists) | +| `FEAT-BROKEN-014` | `SL044` at **WARN** | `SL040` / `SL041` / `SL042` - the chain is contiguous, legal, and ends where frontmatter says | + +--- + +## What this does not do + +**It is not automated.** `/sd:spec validate` is a prompt executed by a model, so `scripts/` +cannot run it the way `selftest-docs.{ps1,sh}` runs Check 7 of `scripts/validate.{ps1,sh}`. +Automating it in CI would mean reimplementing the linter as an executable script โ€” a second copy +of the rules, which is precisely the drift that SW-1 and SW-3 exist to prevent. Until that +trade-off is decided, this fixture makes the acceptance criterion **reproducible**, not +**enforced**. + +**Rule coverage is partial: 24 of the 26 rules are seeded.** Not seeded, and why: + +| Rule | Why not seeded | +|---|---| +| `SL001` | Unparseable frontmatter would break the fixture for every other reader/tool. | +| `SL013` | Needs an unreadable template, i.e. a broken engine install - not reproducible from a checked-in tree. | + +Both remaining gaps are structural rather than unwritten work: each needs the fixture itself, or +the engine install underneath it, to be broken in a way a checked-in tree cannot express. Closing +them means a harness that corrupts a throwaway copy - the shape `scripts/selftest-docs.{ps1,sh}` +already uses for Check 7 - not another seeded spec. + +A linter run over `broken/` that reports a rule from this second table has found a real bug in the +fixture, not in the spec tree. diff --git a/examples/spec-lint-fixture/broken/.claude/project-config.json b/examples/spec-lint-fixture/broken/.claude/project-config.json new file mode 100644 index 0000000..7ebcf20 --- /dev/null +++ b/examples/spec-lint-fixture/broken/.claude/project-config.json @@ -0,0 +1,25 @@ +{ + "version": "1.0.0", + + "project": { + "name": "spec-lint-fixture-broken", + "description": "Fixture: a .specs/ tree with seeded violations, one per lint rule", + "owner": "specwright", + "repo": "https://github.com/Developzone/specwright" + }, + + "spec": { + "dir": ".specs", + "indexFile": ".specs/index.md", + "constitutionFile": ".specs/constitution.md", + "prefixes": { + "feature": "FEAT", + "bug": "BUG", + "refactor": "REF", + "perf": "PERF", + "rca": "RCA" + }, + "lifecycle": ["draft", "approved", "in-progress", "done", "archived"], + "archiveAfterDays": 90 + } +} diff --git a/examples/spec-lint-fixture/broken/.specs/BUG-BROKEN-001/00-spec.md b/examples/spec-lint-fixture/broken/.specs/BUG-BROKEN-001/00-spec.md new file mode 100644 index 0000000..28c6a3d --- /dev/null +++ b/examples/spec-lint-fixture/broken/.specs/BUG-BROKEN-001/00-spec.md @@ -0,0 +1,52 @@ +--- +id: BUG-BROKEN-999 +type: bug +severity: P2 +status: draft +jira: none +created: 2026-07-01 +linked_specs: [] +--- + +# Cart total ignores discount + + + + +## Symptom + +Applying a percentage discount leaves the cart total unchanged. + +## Expected + +The cart total reflects the discount. + +## Reproduction + +1. Add an item, apply a 10% discount code. +2. Observe the total is unchanged. + +## Affected + +- **Users / scope**: all tenants +- **Workaround available**: no + +## Root cause + + + +**Status**: TBD - filled by Phase 3 investigation. + +<> + +**Why this is root cause, not a symptom**: <> + +## Fix approach + +**Status**: TBD - filled after root cause confirmed. + +- <> +- <> diff --git a/examples/spec-lint-fixture/broken/.specs/BUG-BROKEN-008/00-spec.md b/examples/spec-lint-fixture/broken/.specs/BUG-BROKEN-008/00-spec.md new file mode 100644 index 0000000..08846cf --- /dev/null +++ b/examples/spec-lint-fixture/broken/.specs/BUG-BROKEN-008/00-spec.md @@ -0,0 +1,58 @@ +--- +id: BUG-BROKEN-008 +type: bug +severity: P3 +status: draft +jira: none +created: 2026-07-07 +linked_specs: + - blocked-by: BUG-BROKEN-001 + - related-to: FEAT-BROKEN-004 + - related-to: FEAT-BROKEN-004 + - duplicate-of: BUG-BROKEN-008 +--- + +# Session expires early + + + + + + +## Symptom + +Sessions end after ~5 minutes instead of the configured 30. + +## Expected + +Sessions last 30 minutes. + +## Reproduction + +1. Log in, idle for 6 minutes. +2. Observe forced re-authentication. + +## Affected + +- **Users / scope**: all tenants +- **Workaround available**: yes - re-login + +## Root cause + + + +**Status**: TBD - filled by Phase 3 investigation. + +<> + +**Why this is root cause, not a symptom**: <> + +## Fix approach + +**Status**: TBD - filled after root cause confirmed. + +- <> +- <> diff --git a/examples/spec-lint-fixture/broken/.specs/BUG-BROKEN-010/00-spec.md b/examples/spec-lint-fixture/broken/.specs/BUG-BROKEN-010/00-spec.md new file mode 100644 index 0000000..e67fba1 --- /dev/null +++ b/examples/spec-lint-fixture/broken/.specs/BUG-BROKEN-010/00-spec.md @@ -0,0 +1,65 @@ +--- +id: BUG-BROKEN-010 +type: feature +severity: P2 +status: draft +jira: none +created: 2026-07-09 +linked_specs: [] +--- + +# Timestamps render in server timezone + + + + + +## Symptom + +Audit rows show timestamps in the server's local timezone rather than the viewer's. + +**First reported**: 2026-07-09 by support +**Frequency**: every request +**Environment**: prod + +## Expected + +Timestamps render in the viewer's timezone. + +## Reproduction + +1. Set the account timezone to something other than UTC. +2. Open the audit log. +3. Observe timestamps offset by the server's UTC delta. + +## Affected + +- **Users / scope**: all tenants outside UTC +- **First introduced**: before known history +- **Workaround available**: no + +## Root cause + +**Status**: TBD - filled by Phase 3 investigation. + +<> + +**Why this is root cause, not a symptom**: <> + +## Fix approach + +**Status**: TBD - filled after root cause confirmed. + +- <> +- <> + +## Regression test checklist + +- [ ] Failing test added that reproduces the bug (Phase 4 Gate 4) +- [ ] Failing test now passes with fix applied diff --git a/examples/spec-lint-fixture/broken/.specs/FEAT-BROKEN-004/00-spec.md b/examples/spec-lint-fixture/broken/.specs/FEAT-BROKEN-004/00-spec.md new file mode 100644 index 0000000..c30dff4 --- /dev/null +++ b/examples/spec-lint-fixture/broken/.specs/FEAT-BROKEN-004/00-spec.md @@ -0,0 +1,45 @@ +--- +id: FEAT-BROKEN-004 +type: feature +status: draft +jira: none +created: 2026-07-04 +linked_specs: + - related-to: FEAT-NOPE-999 + - depends-on: RCA-BROKEN-005 +--- + +# Add CSV export + + + + + +## Why + +Operators want the report data in a spreadsheet. + +## What + +### Scenario 1: Export current report + +- **Given** an operator viewing a report +- **When** they click Export CSV +- **Then** a CSV of the current rows downloads + +## Success criteria + +- [ ] Export returns a CSV matching the on-screen rows + +## Out of scope + +- Scheduled exports. + +## Open questions + +- None. + +## Constitution check + +- **Result**: compliant diff --git a/examples/spec-lint-fixture/broken/.specs/FEAT-BROKEN-007/00-spec.md b/examples/spec-lint-fixture/broken/.specs/FEAT-BROKEN-007/00-spec.md new file mode 100644 index 0000000..fd5c942 --- /dev/null +++ b/examples/spec-lint-fixture/broken/.specs/FEAT-BROKEN-007/00-spec.md @@ -0,0 +1,45 @@ +--- +id: FEAT-BROKEN-007 +type: feature +status: approved +jira: none +created: 2026-07-06 +linked_specs: none +--- + +# Add audit log + + + + +## Why + +<> + +## What + +### Scenario 1: <> + +- **Given** <> +- **When** <> +- **Then** <> + +## Success criteria + +- [ ] <> + +## Out of scope + +- <> + +## Open questions + +- None. + +## Constitution check + +- **Result**: compliant diff --git a/examples/spec-lint-fixture/broken/.specs/FEAT-BROKEN-007/05-retro.md b/examples/spec-lint-fixture/broken/.specs/FEAT-BROKEN-007/05-retro.md new file mode 100644 index 0000000..f722cdb --- /dev/null +++ b/examples/spec-lint-fixture/broken/.specs/FEAT-BROKEN-007/05-retro.md @@ -0,0 +1,5 @@ +# Retro log: FEAT-BROKEN-007 + +Append-only. Never edit prior entries. + +- [2026-07-06T10:00:00Z] Status: draft -> approved. Reason: manual transition. diff --git a/examples/spec-lint-fixture/broken/.specs/FEAT-BROKEN-011/00-spec.md b/examples/spec-lint-fixture/broken/.specs/FEAT-BROKEN-011/00-spec.md new file mode 100644 index 0000000..7096158 --- /dev/null +++ b/examples/spec-lint-fixture/broken/.specs/FEAT-BROKEN-011/00-spec.md @@ -0,0 +1,73 @@ +--- +id: FEAT-BROKEN-011 +type: feature +status: reviewing +jira: none +created: 2026-07-10 +linked_specs: [] +--- + +# Add saved search filters + + + + + +## Why + +Analysts re-enter the same four filter combinations every morning. Saving a filter set removes +roughly 10 minutes of repeated clicking per analyst per day. + +## What + +### Scenario 1: Save a filter set + +- **Given** an analyst has applied filters to the search view +- **When** they choose "Save this search" and name it +- **Then** the named filter set appears in their sidebar + +### Scenario 2: Name collides with an existing saved search + +- **Given** a saved search named "Overdue" already exists +- **When** the analyst saves another search with the same name +- **Then** the save is rejected with a message naming the conflict + +### Scenario 3: Saved search references a deleted field + +- **Given** a saved search filters on a field that was since removed +- **When** the analyst opens it +- **Then** the search loads with that clause dropped and a warning shown + +## Success criteria + +- [ ] POST /api/searches returns 201 with the saved search ID +- [ ] Duplicate names within one account return 409 +- [ ] A saved search referencing a removed field loads without error +- [ ] Unit + integration tests cover all scenarios above + +## Out of scope + +- Sharing saved searches between accounts. +- Scheduled email digests built on a saved search. + +## Open questions + +- None outstanding. + +## Constitution check + +- **ยง1.1 Layer rules**: persistence stays behind the repository interface. +- **ยง2.3 Error handling**: reuses `DuplicateSavedSearchException`. +- **ยง3 Quality bars**: 80% line coverage on new files, integration test per scenario. +- **Risk of violation**: none diff --git a/examples/spec-lint-fixture/broken/.specs/FEAT-BROKEN-014/00-spec.md b/examples/spec-lint-fixture/broken/.specs/FEAT-BROKEN-014/00-spec.md new file mode 100644 index 0000000..91b63cb --- /dev/null +++ b/examples/spec-lint-fixture/broken/.specs/FEAT-BROKEN-014/00-spec.md @@ -0,0 +1,71 @@ +--- +id: FEAT-BROKEN-014 +type: feature +status: in-progress +jira: none +created: 2026-07-13 +linked_specs: [] +--- + +# Add bulk tag assignment + + + + +## Why + +Support agents re-tag roughly 200 tickets a week one at a time. Bulk assignment removes an +estimated 3 hours of manual work per agent per week. + +## What + +### Scenario 1: Tag a selection + +- **Given** an agent has selected 40 tickets +- **When** they apply the tag "escalated" +- **Then** all 40 tickets carry the tag and one audit entry records the batch + +### Scenario 2: Partial permission + +- **Given** the selection includes tickets the agent cannot edit +- **When** they apply a tag +- **Then** the editable tickets are tagged and the response names the skipped ones + +### Scenario 3: Tag does not exist + +- **Given** the supplied tag name matches no existing tag +- **When** the agent applies it +- **Then** the request is rejected with 404 and nothing is tagged + +## Success criteria + +- [ ] POST /api/tickets/bulk-tag returns 200 with per-ticket results +- [ ] A partially-permitted batch tags what it can and reports the rest +- [ ] An unknown tag name returns 404 and applies nothing +- [ ] Unit + integration tests cover all scenarios above + +## Out of scope + +- Bulk tag removal - separate spec. +- Tag creation from within the bulk flow. + +## Open questions + +- None outstanding. + +## Constitution check + +- **ยง1.1 Layer rules**: batching stays in the application layer. +- **ยง2.3 Error handling**: reuses `TagNotFoundException`. +- **ยง3 Quality bars**: 80% line coverage, one integration test per scenario. +- **Risk of violation**: none diff --git a/examples/spec-lint-fixture/broken/.specs/FEAT-BROKEN-014/01-plan.md b/examples/spec-lint-fixture/broken/.specs/FEAT-BROKEN-014/01-plan.md new file mode 100644 index 0000000..8c7126e --- /dev/null +++ b/examples/spec-lint-fixture/broken/.specs/FEAT-BROKEN-014/01-plan.md @@ -0,0 +1,16 @@ +# Plan: FEAT-BROKEN-014 + +Present so that the `in-progress` status does not also raise SL020. The content is not what this +fixture exercises - only its existence is. + +## Approach + +Add a batch endpoint that resolves the tag once, then applies it per ticket inside a single +transaction, collecting per-ticket outcomes rather than failing the whole batch. + +## Sequence + +1. Add the bulk-tag request/response contract. +2. Resolve and validate the tag before iterating. +3. Apply per ticket, collecting permission skips. +4. Write one audit entry per batch. diff --git a/examples/spec-lint-fixture/broken/.specs/FEAT-BROKEN-014/02-tasks.md b/examples/spec-lint-fixture/broken/.specs/FEAT-BROKEN-014/02-tasks.md new file mode 100644 index 0000000..eec750b --- /dev/null +++ b/examples/spec-lint-fixture/broken/.specs/FEAT-BROKEN-014/02-tasks.md @@ -0,0 +1,8 @@ +# Tasks: FEAT-BROKEN-014 + +Present so that the `in-progress` status does not also raise SL020. + +- [x] T1: Add the bulk-tag request and response contract. +- [x] T2: Resolve and validate the tag ahead of iteration. +- [ ] T3: Apply the tag per ticket, collecting permission skips. +- [ ] T4: Write one audit entry per batch. diff --git a/examples/spec-lint-fixture/broken/.specs/FEAT-BROKEN-014/05-retro.md b/examples/spec-lint-fixture/broken/.specs/FEAT-BROKEN-014/05-retro.md new file mode 100644 index 0000000..139131b --- /dev/null +++ b/examples/spec-lint-fixture/broken/.specs/FEAT-BROKEN-014/05-retro.md @@ -0,0 +1,9 @@ +# Retro log: FEAT-BROKEN-014 + +Append-only. Never edit prior entries. + +- [2026-07-13T09:00:00Z] Status: draft -> approved. Reason: scenarios agreed with support lead. +- [2026-07-13T14:00:00Z] Status: approved -> in-progress. Reason: plan and tasks accepted. +- [2026-07-15T17:00:00Z] Status: in-progress -> done. Reason: all success criteria met. +- [2026-07-16T10:00:00Z] Status: done -> archived. Reason: shipped in 2.9.0. +- [2026-07-18T11:00:00Z] Status: archived -> in-progress. Reason: diff --git a/examples/spec-lint-fixture/broken/.specs/PERF-BROKEN-002/00-spec.md b/examples/spec-lint-fixture/broken/.specs/PERF-BROKEN-002/00-spec.md new file mode 100644 index 0000000..bf89569 --- /dev/null +++ b/examples/spec-lint-fixture/broken/.specs/PERF-BROKEN-002/00-spec.md @@ -0,0 +1,62 @@ +--- +id: PERF-BROKEN-002 +type: perf +status: approved +target_metric: latency-p95 +created: 2026-07-02 +linked_specs: [] +--- + +# Cut p95 latency on GET /api/report + + + +## Target + +| Field | Value | +|---|---| +| **Metric** | p95 latency on GET /api/report | +| **Current observed** | ~1.4s (roughly, from memory of last quarter) | +| **Goal (SLA)** | p95 < 300ms under 20 RPS load | +| **Environment** | staging, single-instance, 2 vCPU / 4 GB RAM | +| **Load profile** | 20 RPS sustained, 40 concurrent users | +| **Workload type** | read-heavy | + +**Why this target**: Report latency blocks the morning ops review. + +## Measurement methodology + +k6 script at tests/perf/report.js, 3 warm-up rounds, 5 minute run, 3 reps, warm cache. + +## Constraints + +- Report contents must not change. + +## Out of scope + +- Database server upgrade or sharding - infra concerns. + +## Hypothesis tree + +**Status**: TBD - filled by Phase 3 hotspot analysis. + +<> + +## Results log + +| # | Date | Change | p50 | p95 | p99 | req/s | CPU | Memory | Decision | +|---|---|---|---|---|---|---|---|---|---| +| - | - | - | - | - | - | - | - | - | - | + +## Trade-offs accepted + +**Status**: TBD - filled at close-out. + +- <> + +## Constitution check + +- **Result**: compliant diff --git a/examples/spec-lint-fixture/broken/.specs/PERF-BROKEN-002/05-retro.md b/examples/spec-lint-fixture/broken/.specs/PERF-BROKEN-002/05-retro.md new file mode 100644 index 0000000..6db061e --- /dev/null +++ b/examples/spec-lint-fixture/broken/.specs/PERF-BROKEN-002/05-retro.md @@ -0,0 +1,5 @@ +# Retro log: PERF-BROKEN-002 + +Append-only. Never edit prior entries. + +- [2026-07-02T09:15:00Z] Status: draft -> approved. Reason: target agreed at Gate 1. diff --git a/examples/spec-lint-fixture/broken/.specs/PERF-BROKEN-012/00-spec.md b/examples/spec-lint-fixture/broken/.specs/PERF-BROKEN-012/00-spec.md new file mode 100644 index 0000000..0ace832 --- /dev/null +++ b/examples/spec-lint-fixture/broken/.specs/PERF-BROKEN-012/00-spec.md @@ -0,0 +1,90 @@ +--- +id: PERF-BROKEN-012 +type: perf +status: done +target_metric: memory +created: 2026-07-11 +linked_specs: [] +--- + +# Cut peak memory on the nightly export job + + + + +## Target + +| Field | Value | +|---|---| +| **Metric** | peak RSS during the nightly export job | +| **Current observed** | 3.1 GB peak RSS, median of 3 runs | +| **Goal (SLA)** | peak RSS under 1.5 GB | +| **Environment** | staging, single worker, 2 vCPU / 4 GB RAM | +| **Load profile** | full nightly export, ~2.4M rows | +| **Workload type** | read-heavy | + +**Why this target**: the worker is OOM-killed roughly twice a week at the current peak, and each +kill costs a full re-run of the export. + +## Measurement methodology + +- **Tool**: /usr/bin/time -v plus in-process GC counters +- **Test script**: tests/perf/nightly-export.sh +- **Warm-up**: none - the job is cold by definition +- **Duration**: one full export per run +- **Repetitions**: 3 runs, median reported +- **Database state**: staging snapshot, ~2.4M rows +- **Cache state**: cold +- **Artifacts saved to**: `04-artifacts/baseline-20260711-0200.json` + +## Constraints + +- **Correctness**: the exported file must be byte-identical to the pre-change output. +- **Public API**: preserved +- **Resource budget**: no new dependencies +- **Behavior change**: none + +## Out of scope + +- Moving the export off the application worker. + +## Hypothesis tree + +**Status**: Confirmed at Phase 3. + +1. The row set is materialized into a list before serialization + (`src/Export/NightlyExporter.cs:64`) - expected to account for most of the peak. +2. The CSV writer buffers the entire payload before flushing + (`src/Export/CsvWriter.cs:31`) - secondary contributor. + +## Results log + +| # | Date | Change | p50 | p95 | p99 | req/s | CPU | Memory | Decision | +|---|---|---|---|---|---|---|---|---|---| +| 0 | 2026-07-11 | baseline | - | - | - | - | 61% | 3.1 GB | - | +| 1 | 2026-07-11 | stream rows instead of materializing | - | - | - | - | 63% | 1.4 GB | kept | + +## Trade-offs accepted + +**Status**: Filled at close-out. + +- Streaming the row set means the exporter can no longer report a total row count up front, so + the progress log now reports rows written rather than percent complete. + +## Constitution check + +- **ยง1 Layer rules**: streaming stays inside the export layer. +- **ยง3 Quality bars**: baseline measured at Gate 2. +- **ยง6 Forbidden patterns**: none introduced. +- **Result**: compliant diff --git a/examples/spec-lint-fixture/broken/.specs/PERF-BROKEN-012/05-retro.md b/examples/spec-lint-fixture/broken/.specs/PERF-BROKEN-012/05-retro.md new file mode 100644 index 0000000..42dfdd7 --- /dev/null +++ b/examples/spec-lint-fixture/broken/.specs/PERF-BROKEN-012/05-retro.md @@ -0,0 +1,3 @@ +# Retro log: PERF-BROKEN-012 + +Append-only. Never edit prior entries. diff --git a/examples/spec-lint-fixture/broken/.specs/RCA-BROKEN-005/00-spec.md b/examples/spec-lint-fixture/broken/.specs/RCA-BROKEN-005/00-spec.md new file mode 100644 index 0000000..0612da5 --- /dev/null +++ b/examples/spec-lint-fixture/broken/.specs/RCA-BROKEN-005/00-spec.md @@ -0,0 +1,55 @@ +--- +id: RCA-BROKEN-005 +type: rca +status: draft +severity: P1 +incident_started: 2026-07-05 02:10 UTC +incident_resolved: 2026-07-05 03:40 UTC +created: 2026-07-05 +linked_specs: [] +--- + +# RCA: Report API 500s on 2026-07-05 + + + + +## Timeline (UTC) + +| Time (UTC) | Event | Source | +|---|---|---| +| 02:10 | Error rate on /api/report jumps to 22% | Datadog alert | +| 03:40 | Rollback completes, error rate normal | Deploy log | + +## Symptoms + +Report API returned 500 for roughly 22% of requests. + +## Affected scope + +All tenants using the reporting dashboard. + +## Recent changes + +| Time | Change | Author | Reason | +|---|---|---|---| +| 01:55 | Deploy v2.4.7 | ci | scheduled release | + +## Hypothesis tree + +**Status**: TBD - filled by Phase 2 enumeration. + +<> + +### Verification results (Phase 3) + +- <>: <> - <> +- <>: <> - <> + +## Root cause + +**Status**: TBD - filled when Gate 3 (Root cause confirmed) passes. + +<> diff --git a/examples/spec-lint-fixture/broken/.specs/REF-BROKEN-003/00-spec.md b/examples/spec-lint-fixture/broken/.specs/REF-BROKEN-003/00-spec.md new file mode 100644 index 0000000..232c41e --- /dev/null +++ b/examples/spec-lint-fixture/broken/.specs/REF-BROKEN-003/00-spec.md @@ -0,0 +1,54 @@ +--- +id: REF-BROKEN-003 +type: refactor +status: in-progress +created: 2026-07-03 +linked_specs: [] +--- + +# Split StockHandler + + + + + +## Smell / Driver + +StockHandler.cs has grown to 480 lines with 7 distinct concerns mixed. + +**Smell category**: extract-class + +## Current state + +- **Primary file**: src/Stock/StockHandler.cs (480 lines) +- **Structure**: single class with 7 public methods +- **Test coverage**: currently 84% +- **Used by**: <> + +## Target state + +- **Shape**: Split into StockDeductionService + StockQueryService +- **Public API**: preserved + +## Invariants - MUST preserve + +- [ ] All existing tests pass without modification + +## Impact surface + +**Status**: TBD - filled by Phase 2 impact mapping. + +<> + +## Test coverage prerequisite + +- **Threshold**: >= 80% line coverage on files in "Current state" +- **Current measured**: <> +- **Gap**: <> +- **Plan to close gap** (if any): <> + +## Out of scope + +- Behavior changes. diff --git a/examples/spec-lint-fixture/broken/.specs/REF-BROKEN-003/05-retro.md b/examples/spec-lint-fixture/broken/.specs/REF-BROKEN-003/05-retro.md new file mode 100644 index 0000000..e90fa15 --- /dev/null +++ b/examples/spec-lint-fixture/broken/.specs/REF-BROKEN-003/05-retro.md @@ -0,0 +1,5 @@ +# Retro log: REF-BROKEN-003 + +Append-only. Never edit prior entries. + +- [2026-07-03T08:30:00Z] Status: draft -> approved. Reason: spec agreed at Gate 1. diff --git a/examples/spec-lint-fixture/broken/.specs/REF-BROKEN-009/00-spec.md b/examples/spec-lint-fixture/broken/.specs/REF-BROKEN-009/00-spec.md new file mode 100644 index 0000000..5129976 --- /dev/null +++ b/examples/spec-lint-fixture/broken/.specs/REF-BROKEN-009/00-spec.md @@ -0,0 +1,51 @@ +--- +id: REF-BROKEN-009 +type: refactor +smell: inline +status: done +created: 2026-07-08 +linked_specs: [] +--- + +# Inline config reader + + + + + +## Smell / Driver + +ConfigReader is a one-line indirection used in a single place. + +**Smell category**: inline + +## Current state + +- **Primary file**: src/Config/ConfigReader.cs (12 lines) +- **Test coverage**: currently 91% +- **Used by**: <> + +## Target state + +- **Shape**: inlined at the single call site +- **Public API**: preserved + +## Invariants - MUST preserve + +- [ ] All existing tests pass without modification + +## Impact surface + +<> + +## Test coverage prerequisite + +- **Threshold**: >= 80% line coverage on files in "Current state" +- **Current measured**: <> +- **Gap**: <> +- **Plan to close gap** (if any): <> + +## Out of scope + +- Behavior changes. diff --git a/examples/spec-lint-fixture/broken/.specs/REF-BROKEN-009/05-retro.md b/examples/spec-lint-fixture/broken/.specs/REF-BROKEN-009/05-retro.md new file mode 100644 index 0000000..f6e6eef --- /dev/null +++ b/examples/spec-lint-fixture/broken/.specs/REF-BROKEN-009/05-retro.md @@ -0,0 +1,5 @@ +# Retro log: REF-BROKEN-009 + +Append-only. Never edit prior entries. + +- [2026-07-08T09:00:00Z] Status: draft -> done. Reason: seemed small enough to skip the middle. diff --git a/examples/spec-lint-fixture/broken/.specs/REF-BROKEN-013/00-spec.md b/examples/spec-lint-fixture/broken/.specs/REF-BROKEN-013/00-spec.md new file mode 100644 index 0000000..3030a14 --- /dev/null +++ b/examples/spec-lint-fixture/broken/.specs/REF-BROKEN-013/00-spec.md @@ -0,0 +1,81 @@ +--- +id: REF-BROKEN-013 +type: refactor +smell: extract-class +status: done +created: 2026-07-12 +linked_specs: [] +--- + +# Split InvoiceHandler + + + + +## Smell / Driver + +InvoiceHandler.cs has grown to 430 lines mixing invoice creation, tax calculation and PDF +rendering. Testing the tax path in isolation is impossible without constructing a renderer. + +**Smell category**: extract-class + +## Current state + +- **Primary file**: src/Billing/InvoiceHandler.cs (430 lines) +- **Structure**: single class, 6 public methods, 14 private helpers +- **Test coverage**: currently 84% +- **Used by**: `src/Api/InvoiceController.cs:22`, `src/Jobs/MonthlyBillingJob.cs:57` + +## Target state + +- **Shape**: split into InvoiceCreationService, TaxCalculationService and InvoiceRenderer +- **Files affected**: src/Billing/InvoiceCreationService.cs (new), + src/Billing/TaxCalculationService.cs (new), src/Billing/InvoiceRenderer.cs (new) +- **Public API**: preserved +- **Test approach**: each new service unit-tested in isolation + +### Public API changes (if any) + +- None - this is a behavior-preserving refactor. + +## Invariants - MUST preserve + +- [x] POST /api/invoices returns the same status codes and response shape for every case in + tests/contract/invoice.cases.json +- [x] All existing tests pass without modification + +## Impact surface + +**Status**: Filled by Phase 2 impact mapping. + +- Direct callers: `src/Api/InvoiceController.cs:22`, `src/Jobs/MonthlyBillingJob.cs:57` +- DI registrations: `src/Startup/BillingModule.cs:18` +- Test files: `tests/Billing/InvoiceHandlerTests.cs:1` + +## Test coverage prerequisite + +- **Threshold**: >= 80% line coverage on files in "Current state" +- **Current measured**: 84% measured on src/Billing/InvoiceHandler.cs +- **Gap**: none +- **Plan to close gap** (if any): none required + +## Out of scope + +- Renaming public DTOs. +- Any performance work. + +## Constitution check + +- **ยง1.1 Layer rules**: all three services stay in the billing layer. +- **ยง1.2 Pattern rules**: reinforces one-responsibility-per-service. +- **ยง6 Forbidden patterns**: none introduced. +- **Result**: compliant diff --git a/examples/spec-lint-fixture/broken/.specs/REF-BROKEN-013/01-plan.md b/examples/spec-lint-fixture/broken/.specs/REF-BROKEN-013/01-plan.md new file mode 100644 index 0000000..11f2b7e --- /dev/null +++ b/examples/spec-lint-fixture/broken/.specs/REF-BROKEN-013/01-plan.md @@ -0,0 +1,15 @@ +# Plan: REF-BROKEN-013 + +Present so that the `done` status does not also raise SL020. The content is not what this +fixture exercises - only its existence is. + +## Approach + +Extract the tax and rendering concerns out of InvoiceHandler one at a time, keeping the public +entry point delegating, so each step is independently revertible. + +## Sequence + +1. Extract TaxCalculationService, leave InvoiceHandler delegating to it. +2. Extract InvoiceRenderer, same shape. +3. Rename the remainder to InvoiceCreationService and update DI registration. diff --git a/examples/spec-lint-fixture/broken/.specs/REF-BROKEN-013/02-tasks.md b/examples/spec-lint-fixture/broken/.specs/REF-BROKEN-013/02-tasks.md new file mode 100644 index 0000000..cfff87d --- /dev/null +++ b/examples/spec-lint-fixture/broken/.specs/REF-BROKEN-013/02-tasks.md @@ -0,0 +1,7 @@ +# Tasks: REF-BROKEN-013 + +Present so that the `done` status does not also raise SL020. + +- [x] T1: Extract TaxCalculationService from InvoiceHandler. +- [x] T2: Extract InvoiceRenderer from InvoiceHandler. +- [x] T3: Rename the remainder to InvoiceCreationService and update DI registration. diff --git a/examples/spec-lint-fixture/broken/.specs/REF-BROKEN-013/05-retro.md b/examples/spec-lint-fixture/broken/.specs/REF-BROKEN-013/05-retro.md new file mode 100644 index 0000000..fb65f57 --- /dev/null +++ b/examples/spec-lint-fixture/broken/.specs/REF-BROKEN-013/05-retro.md @@ -0,0 +1,6 @@ +# Retro log: REF-BROKEN-013 + +Append-only. Never edit prior entries. + +- [2026-07-12T08:00:00Z] Status: draft -> approved. Reason: smell and invariants agreed. +- [2026-07-13T16:30:00Z] Status: in-progress -> done. Reason: all invariants green. diff --git a/examples/spec-lint-fixture/broken/.specs/index.md b/examples/spec-lint-fixture/broken/.specs/index.md new file mode 100644 index 0000000..e6fea04 --- /dev/null +++ b/examples/spec-lint-fixture/broken/.specs/index.md @@ -0,0 +1,20 @@ +# Spec index + +Active specs (auto-updated by /sd:spec status transitions): + +| ID | Type | Status | Created | Title | +|---|---|---|---|---| +| BUG-BROKEN-001 | bug | approved | 2026-07-01 | Cart total ignores discount | +| PERF-BROKEN-002 | perf | approved | 2026-07-02 | Cut p95 latency on GET /api/report | +| REF-BROKEN-003 | refactor | in-progress | 2026-07-03 | Split StockHandler | +| FEAT-BROKEN-004 | feature | draft | 2026-07-04 | Add CSV export | +| FEAT-BROKEN-004 | feature | draft | 2026-07-04 | Add CSV export | +| BUG-GHOST-006 | bug | draft | 2026-07-05 | Ghost row - no folder exists | +| FEAT-BROKEN-007 | feature | approved | 2026-07-06 | Add audit log | +| BUG-BROKEN-008 | bug | draft | 2026-07-07 | Session expires early | +| REF-BROKEN-009 | refactor | done | 2026-07-08 | Inline config reader | +| BUG-BROKEN-010 | feature | draft | 2026-07-09 | Timestamps render in server timezone | +| FEAT-BROKEN-011 | feature | reviewing | 2026-07-10 | Add saved search filters | +| PERF-BROKEN-012 | perf | done | 2026-07-11 | Cut peak memory on the nightly export job | +| REF-BROKEN-013 | refactor | done | 2026-07-12 | Split InvoiceHandler | +| FEAT-BROKEN-014 | feature | in-progress | 2026-07-13 | Add bulk tag assignment | diff --git a/examples/spec-lint-fixture/clean/.claude/project-config.json b/examples/spec-lint-fixture/clean/.claude/project-config.json new file mode 100644 index 0000000..5fe5be6 --- /dev/null +++ b/examples/spec-lint-fixture/clean/.claude/project-config.json @@ -0,0 +1,25 @@ +{ + "version": "1.0.0", + + "project": { + "name": "spec-lint-fixture-clean", + "description": "Fixture: a .specs/ tree that /sd:spec validate must report as all-PASS", + "owner": "specwright", + "repo": "https://github.com/Developzone/specwright" + }, + + "spec": { + "dir": ".specs", + "indexFile": ".specs/index.md", + "constitutionFile": ".specs/constitution.md", + "prefixes": { + "feature": "FEAT", + "bug": "BUG", + "refactor": "REF", + "perf": "PERF", + "rca": "RCA" + }, + "lifecycle": ["draft", "approved", "in-progress", "done", "archived"], + "archiveAfterDays": 90 + } +} diff --git a/examples/spec-lint-fixture/clean/.specs/BUG-CLEAN-003/00-spec.md b/examples/spec-lint-fixture/clean/.specs/BUG-CLEAN-003/00-spec.md new file mode 100644 index 0000000..1ffee67 --- /dev/null +++ b/examples/spec-lint-fixture/clean/.specs/BUG-CLEAN-003/00-spec.md @@ -0,0 +1,61 @@ +--- +id: BUG-CLEAN-003 +type: bug +severity: P1 +status: done +jira: none +created: 2026-07-01 +linked_specs: + - blocks: FEAT-CLEAN-001 +--- + +# Stock deduction double-counts on retry + +## Symptom + +A retried deduction request subtracts stock twice. + +**First reported**: 2026-07-01 by ops +**Frequency**: intermittent (~3%) +**Environment**: prod + +## Expected + +A retried request is idempotent and deducts once. + +## Reproduction + +1. POST /api/stock/deduct with idempotency key K. +2. Force a timeout, then replay the same request with key K. +3. Observe stock reduced by 2x the requested quantity. + +## Affected + +- **Users / scope**: all tenants +- **First introduced**: v2.3.0 +- **Workaround available**: no + +## Root cause + +**Status**: Confirmed at Gate 3. + +The idempotency key is checked after the deduction is written, not before, so a replay inside the +write window applies twice (`src/Stock/StockDeductionService.cs:118`). + +**Why this is root cause, not a symptom**: the double-write disappears when the key check is +moved ahead of the write, verified by the failing test added in Phase 4. + +## Fix approach + +**Status**: Confirmed after root cause. + +- Move the idempotency check ahead of the write in `src/Stock/StockDeductionService.cs`. + +**Scope discipline check**: +- [x] Fix touches only files implicated by root cause +- [x] No "while I'm here" cleanups + +## Regression test checklist + +- [x] Failing test added that reproduces the bug (Phase 4 Gate 4) +- [x] Failing test now passes with fix applied diff --git a/examples/spec-lint-fixture/clean/.specs/BUG-CLEAN-003/05-retro.md b/examples/spec-lint-fixture/clean/.specs/BUG-CLEAN-003/05-retro.md new file mode 100644 index 0000000..978d293 --- /dev/null +++ b/examples/spec-lint-fixture/clean/.specs/BUG-CLEAN-003/05-retro.md @@ -0,0 +1,8 @@ +# Retro log: BUG-CLEAN-003 + +Append-only. Never edit prior entries. + +- [2026-07-01T10:05:00Z] Status: draft -> approved. Reason: symptom captured and reproduced. +- [2026-07-01T11:20:00Z] Status: approved -> in-progress. Reason: root cause confirmed at Gate 3. +- [2026-07-02T09:40:00Z] Status: in-progress -> done. Reason: regression suite green. +- [2026-07-02T09:41:00Z] Link: blocks BUG-CLEAN-003 -> FEAT-CLEAN-001. diff --git a/examples/spec-lint-fixture/clean/.specs/BUG-CLEAN-003/06-verify.md b/examples/spec-lint-fixture/clean/.specs/BUG-CLEAN-003/06-verify.md new file mode 100644 index 0000000..5084646 --- /dev/null +++ b/examples/spec-lint-fixture/clean/.specs/BUG-CLEAN-003/06-verify.md @@ -0,0 +1,34 @@ +--- +spec: BUG-CLEAN-003 +result: pass +date: 2026-07-21 +failures: 0 +--- + +# Verification report - BUG-CLEAN-003 + +## Traceability + +_No SC- / AC- IDs in this spec._ `BUG-CLEAN-003` uses the bug-report shape (Symptom, +Root cause, Fix approach, Regression test checklist) rather than the feature template's +Scenario/Success-criteria sections, so VF010/VF011 (SC/AC coverage) do not apply. The +Regression test checklist stands in as the criterion this artifact verifies: + +| ID | Kind | Covered by | Test(s) | Status | +|---|---|---|---|---| +| regression-checklist | checklist | T01 | tests/stock/deduction-idempotency.test.ts | PASS | + +## Test run + +- Command: `npm run test:stock` +- Exit code: 0 + +## Findings + +none + + diff --git a/examples/spec-lint-fixture/clean/.specs/FEAT-CLEAN-001/00-spec.md b/examples/spec-lint-fixture/clean/.specs/FEAT-CLEAN-001/00-spec.md new file mode 100644 index 0000000..069ec52 --- /dev/null +++ b/examples/spec-lint-fixture/clean/.specs/FEAT-CLEAN-001/00-spec.md @@ -0,0 +1,43 @@ +--- +id: FEAT-CLEAN-001 +type: feature +status: draft +jira: none +created: 2026-07-01 +linked_specs: + - depends-on: BUG-CLEAN-003 +--- + +# Add webhook retry backoff + +## Why + +Webhook deliveries that fail transiently are dropped, so operators miss low-stock events. + +## What + +### SC-1: Transient failure retries + +- **Given** a subscribed webhook endpoint returning 503 +- **When** a low-stock event fires +- **Then** delivery is retried 3x with exponential backoff + +## Success criteria + +- [ ] AC-1: Failed webhook retries 3x with exponential backoff +- [ ] AC-2: Unit + integration tests cover all scenarios above + +## Out of scope + +- Email or SMS notifications - webhooks only in this iteration + +## Open questions + +- None. + +## Constitution check + +- **Result**: compliant + + diff --git a/examples/spec-lint-fixture/clean/.specs/FEAT-CLEAN-001/06-verify.md b/examples/spec-lint-fixture/clean/.specs/FEAT-CLEAN-001/06-verify.md new file mode 100644 index 0000000..4554ccd --- /dev/null +++ b/examples/spec-lint-fixture/clean/.specs/FEAT-CLEAN-001/06-verify.md @@ -0,0 +1,33 @@ +--- +spec: FEAT-CLEAN-001 +result: pass +date: 2026-07-21 +failures: 0 +--- + +# Verification report - FEAT-CLEAN-001 + +## Traceability + +| ID | Kind | Covered by | Test(s) | Status | +|---|---|---|---|---| +| SC-1 | scenario | T01 | tests/webhooks/retry-backoff.test.ts | PASS | +| AC-1 | criterion | T01 | tests/webhooks/retry-backoff.test.ts | PASS | +| AC-2 | criterion | T02 | tests/webhooks/retry-backoff.test.ts | PASS | + +## Test run + +- Command: `npm run test:webhooks` +- Exit code: 0 + +## Findings + +none + + + diff --git a/examples/spec-lint-fixture/clean/.specs/PERF-CLEAN-002/00-spec.md b/examples/spec-lint-fixture/clean/.specs/PERF-CLEAN-002/00-spec.md new file mode 100644 index 0000000..c65317d --- /dev/null +++ b/examples/spec-lint-fixture/clean/.specs/PERF-CLEAN-002/00-spec.md @@ -0,0 +1,61 @@ +--- +id: PERF-CLEAN-002 +type: perf +status: approved +target_metric: latency-p95 +created: 2026-07-02 +linked_specs: [] +--- + +# Cut p95 latency on GET /api/search + +## Target + +| Field | Value | +|---|---| +| **Metric** | p95 latency on GET /api/search | +| **Current observed** | <> | +| **Goal (SLA)** | p95 < 200ms under 50 RPS load | +| **Environment** | staging, single-instance, 2 vCPU / 4 GB RAM | +| **Load profile** | 50 RPS sustained, 100 concurrent users | +| **Workload type** | read-heavy | + +**Why this target**: Search latency directly impacts conversion; drop-off climbs sharply past 1s. + +## Measurement methodology + +k6 script at tests/perf/search.js, 3 warm-up rounds, 5 minute run, 3 reps, warm cache. + +## Constraints + +- Result ordering must not change. + +## Out of scope + +- Database server upgrade or sharding - infra concerns. + +## Hypothesis tree + + + +**Status**: TBD - filled by Phase 3 hotspot analysis. + +<> + +## Results log + +| # | Date | Change | p50 | p95 | p99 | req/s | CPU | Memory | Decision | +|---|---|---|---|---|---|---|---|---|---| +| - | - | - | - | - | - | - | - | - | - | + +## Trade-offs accepted + + + +**Status**: TBD - filled at close-out. + +- <> + +## Constitution check + +- **Result**: compliant diff --git a/examples/spec-lint-fixture/clean/.specs/PERF-CLEAN-002/05-retro.md b/examples/spec-lint-fixture/clean/.specs/PERF-CLEAN-002/05-retro.md new file mode 100644 index 0000000..f812527 --- /dev/null +++ b/examples/spec-lint-fixture/clean/.specs/PERF-CLEAN-002/05-retro.md @@ -0,0 +1,5 @@ +# Retro log: PERF-CLEAN-002 + +Append-only. Never edit prior entries. + +- [2026-07-02T09:15:00Z] Status: draft -> approved. Reason: target and methodology agreed at Gate 1. diff --git a/examples/spec-lint-fixture/clean/.specs/index.md b/examples/spec-lint-fixture/clean/.specs/index.md new file mode 100644 index 0000000..48ff2dd --- /dev/null +++ b/examples/spec-lint-fixture/clean/.specs/index.md @@ -0,0 +1,9 @@ +# Spec index + +Active specs (auto-updated by /sd:spec status transitions): + +| ID | Type | Status | Created | Title | +|---|---|---|---|---| +| FEAT-CLEAN-001 | feature | draft | 2026-07-01 | Add webhook retry backoff | +| PERF-CLEAN-002 | perf | approved | 2026-07-02 | Cut p95 latency on GET /api/search | +| BUG-CLEAN-003 | bug | done | 2026-07-01 | Stock deduction double-counts on retry | diff --git a/hooks/bash/prompt-router.sh b/hooks/bash/prompt-router.sh index 6f1f3bf..77ff952 100644 --- a/hooks/bash/prompt-router.sh +++ b/hooks/bash/prompt-router.sh @@ -35,6 +35,11 @@ fi # --- load config (defaults if missing) --------------------------------------- +# An empty object is a safe fallback HERE only because every value this hook +# reads has a `//` default below (and match_keywords has $default_list), and +# those defaults are the same values as $defaults in prompt-router.ps1. Any new +# read must keep that property or the fallback has to become a full default +# document, as it is in spec-gate.sh. config_path="${cwd}/.claude/project-config.json" config_json="{}" if [[ -f "${config_path}" ]]; then @@ -43,8 +48,9 @@ if [[ -f "${config_path}" ]]; then fi fi -# Hook enabled? -enabled="$(printf '%s' "${config_json}" | jq -r '.hooks.userPromptRouter.enabled // true' 2>/dev/null)" +# Hook enabled? The jq alternative operator treats an explicit `false` as +# absent, so compare directly against `false` instead of relying on it here. +enabled="$(printf '%s' "${config_json}" | jq -r 'if .hooks.userPromptRouter.enabled == false then "false" else "true" end' 2>/dev/null)" if [[ "${enabled}" == "false" ]]; then exit 0 fi diff --git a/hooks/bash/spec-gate.sh b/hooks/bash/spec-gate.sh index f822242..919e361 100644 --- a/hooks/bash/spec-gate.sh +++ b/hooks/bash/spec-gate.sh @@ -44,13 +44,21 @@ fi # --- load config -------------------------------------------------------------- +# A project with no .claude/project-config.json (the normal state before +# /sd:setup has run) must still get the built-in protected paths, so the +# fallback is a full default document rather than an empty object. This must +# stay byte-identical in meaning to $defaults in spec-gate.ps1. +default_config='{"spec":{"dir":".specs","indexFile":".specs/index.md"},"paths":{"protected":[".specs/constitution.md",".specs/index.md","LICENSE"]},"hooks":{"specGate":{"enabled":true,"mode":"warn"},"metrics":{"enabled":true,"path":".specs/_metrics/events.jsonl","maxSizeKb":1024}}}' + config_path="${cwd}/.claude/project-config.json" -config_json="{}" +config_json="${default_config}" if [[ -f "${config_path}" ]] && jq -e . "${config_path}" >/dev/null 2>&1; then config_json="$(cat "${config_path}")" fi -enabled="$(printf '%s' "${config_json}" | jq -r '.hooks.specGate.enabled // true' 2>/dev/null)" +# `//` treats explicit `false` as absent, so compare directly against +# `false` instead of relying on the alternative operator here. +enabled="$(printf '%s' "${config_json}" | jq -r 'if .hooks.specGate.enabled == false then "false" else "true" end' 2>/dev/null)" if [[ "${enabled}" == "false" ]]; then exit 0 fi @@ -63,24 +71,122 @@ fi index_rel="$(printf '%s' "${config_json}" | jq -r '.spec.indexFile // ".specs/index.md"' 2>/dev/null)" index_path="${cwd}/${index_rel}" -# --- normalize to relative path ---------------------------------------------- +# --- path helpers ------------------------------------------------------------- + +# All path comparisons in this hook are case-INSENSITIVE, matching +# spec-gate.ps1's OrdinalIgnoreCase. Windows and macOS filesystems are +# case-insensitive by default, so a case-sensitive gate is bypassable there by +# simply retyping the path in a different case - unacceptable for a rule whose +# whole job is to protect specific files. +to_lower() { + printf '%s' "$1" | tr '[:upper:]' '[:lower:]' +} + +# Collapses '.' and '..' segments in a forward-slash path using pure string +# processing - no filesystem access, no `realpath`/`readlink -f`/`cd`. This +# mirrors [System.IO.Path]::GetFullPath's string-only resolution in +# spec-gate.ps1, which is what let a path like "src/../.specs/constitution.md" +# reach a protected file while presenting a relative form that never matched +# any entry in paths.protected under the un-collapsed bash comparison. +# +# A ROOTED path (Unix leading '/' or a Windows drive prefix like 'C:/') clamps +# a '..' at its own root instead of walking above it, matching GetFullPath's +# drive/root clamp. A path with no such root has nothing to clamp against, so +# an unresolved leading '..' is kept rather than discarded - it must not be +# allowed to silently walk above its own top. +collapse_dot_segments() { + local path="$1" + local root_prefix="" body="${path}" + if [[ "${path}" == /* ]]; then + root_prefix="/" + body="${path#/}" + elif [[ "${path}" == ?:* ]]; then + root_prefix="${path:0:2}/" + body="${path:2}" + body="${body#/}" + fi + + local rooted=0 + [[ -n "${root_prefix}" ]] && rooted=1 + + local acc="" seg rest="${body}" + while [[ -n "${rest}" ]]; do + seg="${rest%%/*}" + if [[ "${rest}" == */* ]]; then + rest="${rest#*/}" + else + rest="" + fi + case "${seg}" in + ''|'.') + continue + ;; + '..') + if [[ -n "${acc}" ]]; then + local last="${acc##*/}" + if [[ "${last}" == '..' ]]; then + # Already-stacked leading '..' (unrooted overflow) - keep stacking. + acc="${acc}/.." + else + local trimmed="${acc%/*}" + if [[ "${trimmed}" == "${acc}" ]]; then + # acc was a single segment with no slash - pop to empty. + acc="" + else + acc="${trimmed}" + fi + fi + elif [[ ${rooted} -eq 1 ]]; then + # Cannot go above the root - drop it, matching GetFullPath's clamp. + : + else + # No root to clamp against - keep the unresolved '..'. + acc=".." + fi + ;; + *) + if [[ -z "${acc}" ]]; then + acc="${seg}" + else + acc="${acc}/${seg}" + fi + ;; + esac + done + + printf '%s%s' "${root_prefix}" "${acc}" +} normalize_rel() { local fp="$1" base="$2" - # If fp is already relative, just normalize separators. + # If fp is already relative, collapse dot segments but there is no known + # root to compare against a cwd prefix, so return the collapsed path as-is. if [[ "${fp}" != /* && "${fp}" != ?:* ]]; then - printf '%s' "${fp//\\//}" + printf '%s' "$(collapse_dot_segments "${fp//\\//}")" return fi - # Strip prefix if it begins with base. - local fp_norm="${fp//\\//}" + # Collapse '.'/'..' BEFORE the prefix strip, so a path that traverses + # through a directory and back (e.g. cwd/src/../.specs/x) is compared + # against base in its fully-resolved form, not its literal typed form. + local fp_raw="${fp//\\//}" local base_norm="${base//\\//}" - if [[ "${fp_norm}" == "${base_norm}"* ]]; then - local rel="${fp_norm#${base_norm}}" + local fp_norm base_collapsed + fp_norm="$(collapse_dot_segments "${fp_raw}")" + base_collapsed="$(collapse_dot_segments "${base_norm}")" + local fp_lower base_lower + fp_lower="$(to_lower "${fp_norm}")" + base_lower="$(to_lower "${base_collapsed}")" + if [[ "${fp_lower}" == "${base_lower}"* ]]; then + # Cut by the base's LENGTH, not by pattern, so the surviving remainder + # keeps its original case for the user-facing reason string. + local rel="${fp_norm:${#base_collapsed}}" rel="${rel#/}" printf '%s' "${rel}" else - printf '%s' "${fp_norm}" + # Resolving fp lands outside base entirely (e.g. enough leading '..' to + # escape the workspace) - fall back to the raw, un-collapsed path, same + # as spec-gate.ps1's ConvertTo-RelativePath fallback branch. + printf '%s' "${fp_raw}" fi } @@ -99,13 +205,317 @@ emit_block() { '{decision:"block",reason:$r,hookSpecificOutput:{permissionDecision:"deny",reason:$r}}' } +# --- metrics: shared event writer --------------------------------------------- +# Append-only, metadata-only event log for the retro loop (SW-10). Every +# failure path here is a silent no-op - metrics must NEVER surface as a hook +# error or change a gate decision. Every call site invokes this AFTER the +# decision is already computed (and, for a block, already emitted) - never +# from inside the decision path itself. + +# A metrics path is not an arbitrary-write primitive: reject anything rooted +# (leading '/' or a drive-letter prefix) or that escapes cwd via '..' rather +# than ever writing outside the workspace. Reuses the same rootedness test and +# dot-segment collapse used for the gate's own path safety above, so the two +# checks cannot silently diverge. Mirrors Test-MetricsPathSafe in +# spec-gate.ps1. +metrics_path_is_safe() { + local p="$1" + [[ -z "${p}" ]] && return 1 + if [[ "${p}" == /* || "${p}" == ?:* ]]; then + return 1 + fi + local collapsed + collapsed="$(collapse_dot_segments "${p//\\//}")" + if [[ "${collapsed}" == ".." || "${collapsed}" == "../"* ]]; then + return 1 + fi + return 0 +} + +# $1 = a complete JSON object literal string containing every field EXCEPT +# ts, already in fixed key order (spec_id, phase, event, ...). ts is prepended +# here so each call site only has to build the part specific to its own event +# kind. jq's object-add operator appends keys from the right operand that are +# not already present in the left, in their own original order - since ts is +# never present in the body, this reliably yields ts first followed by the +# body's keys in the order the caller wrote them. +write_metric_line() { + local body="$1" + + local metrics_enabled + # `//` treats an explicit `false` as absent, so compare directly against + # `false` (type-strict: only a literal JSON boolean false disables this). + metrics_enabled="$(printf '%s' "${config_json}" | jq -r 'if .hooks.metrics.enabled == false then "false" else "true" end' 2>/dev/null)" + [[ "${metrics_enabled}" == "false" ]] && return 0 + + local metrics_path + metrics_path="$(printf '%s' "${config_json}" | jq -r '.hooks.metrics.path // ".specs/_metrics/events.jsonl"' 2>/dev/null)" + metrics_path="${metrics_path//\\//}" + metrics_path_is_safe "${metrics_path}" || return 0 + + local full_path="${cwd}/${metrics_path}" + mkdir -p "$(dirname "${full_path}")" 2>/dev/null || return 0 + + local ts + ts="$(date -u +'%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || date -u)" + + local line + line="$(printf '%s' "${body}" | jq -c --arg ts "${ts}" '{ts:$ts} + .' 2>/dev/null)" + [[ -z "${line}" ]] && return 0 + + # --- rotation (SW-15) ----------------------------------------------------- + # Bounded log: before appending, if the live file already meets or exceeds + # the byte cap, roll it to `.1` (single generation, overwriting any prior + # roll). maxSizeKb defaults to 1024 when the key is ABSENT, so a + # project-config.json written before SW-15 - and this hook's own `{}` + # no-config fallback - still gets a bounded log with no edit; an explicit 0 + # or negative disables rotation (the opt-out) and any non-number is invalid + # and also disables it (SW-22 scar - never let a bad type silently flip + # behavior). Best-effort - every failure path (a file the other hook holds + # open on Windows, a read-only dir) is a silent no-op that falls through to + # the append below: rotation must NEVER stop the append (silent data loss + # reads as "metrics working", which the ticket flags as worse than growth) + # nor surface as a hook error. jq floors maxSizeKb*1024 to an integer so + # bash never does float math; `wc -c` is the byte count Write-MetricEvent + # also measures, and the absent->1024 / bad-type->off rules match its guard, + # so PS and bash trip at the same boundary. + local max_bytes + max_bytes="$(printf '%s' "${config_json}" | jq -r '(if (.hooks.metrics | type) == "object" and (.hooks.metrics | has("maxSizeKb")) then .hooks.metrics.maxSizeKb else 1024 end) as $k | if ($k | type) == "number" and $k > 0 then ($k * 1024 | floor) else "" end' 2>/dev/null)" + if [[ -n "${max_bytes}" && -f "${full_path}" ]]; then + local cur_bytes + cur_bytes="$(wc -c < "${full_path}" 2>/dev/null | tr -d '[:space:]')" + if [[ "${cur_bytes}" =~ ^[0-9]+$ && "${cur_bytes}" -ge "${max_bytes}" ]]; then + mv -f "${full_path}" "${full_path}.1" 2>/dev/null || true + fi + fi + + printf '%s\n' "${line}" >> "${full_path}" 2>/dev/null || true + return 0 +} + +# gate metric: $1=spec_id $2=phase $3=gate $4=decision $5=ext (optional) +emit_gate_metric() { + local spec_id="$1" phase="$2" gate="$3" decision="$4" ext="${5:-}" + local body + if [[ -n "${ext}" ]]; then + body="$(jq -nc --arg spec_id "${spec_id}" --arg phase "${phase}" --arg gate "${gate}" --arg decision "${decision}" --arg ext "${ext}" \ + '{spec_id:$spec_id,phase:$phase,event:"gate",gate:$gate,decision:$decision,ext:$ext}' 2>/dev/null)" + else + body="$(jq -nc --arg spec_id "${spec_id}" --arg phase "${phase}" --arg gate "${gate}" --arg decision "${decision}" \ + '{spec_id:$spec_id,phase:$phase,event:"gate",gate:$gate,decision:$decision}' 2>/dev/null)" + fi + [[ -z "${body}" ]] && return 0 + write_metric_line "${body}" +} + +# spec_transition metric: $1=spec_id $2=phase $3=from $4=decision +emit_transition_metric() { + local spec_id="$1" phase="$2" from="$3" decision="$4" + local body + body="$(jq -nc --arg spec_id "${spec_id}" --arg phase "${phase}" --arg from "${from}" --arg decision "${decision}" \ + '{spec_id:$spec_id,phase:$phase,event:"spec_transition",from:$from,decision:$decision}' 2>/dev/null)" + [[ -z "${body}" ]] && return 0 + write_metric_line "${body}" +} + +# --- spec_transition: general lifecycle scan (read-only, all 5 prefixes x any status) --- +# Deliberately a SEPARATE scan from the FEAT-/done-only `pending_done` +# extraction inside Rule 0 below: that one backs a live gate decision (see its +# Rule 0 scope comment) and must not be refactored into this one, which only +# feeds the observational spec_transition metric. Row shape is +# "| ID | Type | Status | Title |" - split on '|' and read columns 2 and 4 +# rather than a loose substring match, so a Title that happens to mention +# another id/status word cannot be misread as that row's own id or status. +extract_id_status_pairs() { + awk -F'|' ' + NF >= 5 { + id = $2; gsub(/^[ \t]+|[ \t]+$/, "", id) + status = $4; gsub(/^[ \t]+|[ \t]+$/, "", status) + if (id ~ /^(FEAT|BUG|REF|PERF|RCA)-[A-Za-z0-9_-]+$/ && status ~ /^(draft|approved|in-progress|done|archived)$/) { + print id "\t" status + } + } + ' +} + +# Populates the parallel arrays transition_id[] / transition_phase[] / +# transition_from[] for the CURRENT pending edit. No-op (arrays left empty) +# unless this edit targets index.md - checked by the caller via rel/index_rel +# before calling, same scoping as Rule 0. +declare -a transition_id=() +declare -a transition_phase=() +declare -a transition_from=() + +collect_spec_transitions() { + local old_pairs="" new_pairs="" + if [[ -f "${index_path}" ]]; then + old_pairs="$(extract_id_status_pairs < "${index_path}")" + fi + case "${tool_name}" in + Edit) new_pairs="$(printf '%s' "${input}" | jq -r '.tool_input.new_string // empty' 2>/dev/null | extract_id_status_pairs)" ;; + Write) new_pairs="$(printf '%s' "${input}" | jq -r '.tool_input.content // empty' 2>/dev/null | extract_id_status_pairs)" ;; + MultiEdit) new_pairs="$(printf '%s' "${input}" | jq -r '[.tool_input.edits[]?.new_string // empty] | join("\n")' 2>/dev/null | extract_id_status_pairs)" ;; + esac + [[ -z "${new_pairs}" ]] && return 0 + + local seen="|" + while IFS=$'\t' read -r id newstatus; do + [[ -z "${id}" ]] && continue + case "${seen}" in + *"|${id}|"*) continue ;; + esac + seen="${seen}${id}|" + + local oldstatus="-" + if [[ -n "${old_pairs}" ]]; then + local found + found="$(printf '%s\n' "${old_pairs}" | awk -F'\t' -v want="${id}" '$1==want {print $2; exit}')" + [[ -n "${found}" ]] && oldstatus="${found}" + fi + + if [[ "${oldstatus}" != "${newstatus}" ]]; then + transition_id+=("${id}") + transition_phase+=("${newstatus}") + transition_from+=("${oldstatus}") + fi + done <<< "${new_pairs}" +} + +# Emits one spec_transition event per entry in transition_id[] with the given +# overall decision - the decision is for the WHOLE edit (there is only one per +# hook invocation), not per-id, so every entry shares it. +emit_transition_metrics() { + local decision="$1" + for i in "${!transition_id[@]}"; do + emit_transition_metric "${transition_id[$i]}" "${transition_phase[$i]}" "${transition_from[$i]}" "${decision}" + done +} + +# --- Rule 0: verify gate on the spec index ------------------------------------ +# A row transitioning to done requires a passing /sd:verify artifact; a +# verified close-out is allowed through the protected-path rule. Any other +# direct index edit falls through to Rule 1. Mirrors spec-gate.ps1 Rule 0. +# +# Scope: FEAT- rows only. Bug/refactor/perf/rca workflows do not produce +# 02-tasks.md and never run /sd:verify, so gating them here would hard-STOP +# their close-out at VF002 with no way through. Non-FEAT rows fall through to +# the unconditional Rule 1 protected-path block, exactly as before this gate +# existed - until their workflows integrate /sd:verify (follow-up spec). +# +# Bundled-edit limitation: when every newly-done FEAT row in the pending edit +# has a passing artifact, the WHOLE edit is allowed - including any unrelated +# row changes bundled into the same Write/Edit/MultiEdit. This hook inspects +# only the done-transition lines, not a full diff, so a bundled edit could in +# principle piggyback an unrelated change. Accepted limitation (hook-scale +# diff inspection is out of scope); the /sd:spec registry commands are the +# semantic guard for anything this coarse check cannot see. + +verify_gate="$(printf '%s' "${config_json}" | jq -r 'if .hooks.specGate.verifyGate == false then "false" else "true" end' 2>/dev/null)" +spec_dir="$(printf '%s' "${config_json}" | jq -r '.spec.dir // ".specs"' 2>/dev/null)" + +rel_lower="$(to_lower "${rel}")" +index_rel_norm="${index_rel//\\//}" +index_rel_lower="$(to_lower "${index_rel_norm}")" + +# spec_transition metric: read-only, general lifecycle scan of THIS index.md +# edit. Populated unconditionally of verify_gate (it never influences the +# gate decision, only records whatever decision is ultimately reached below); +# left empty whenever this edit is not to the index file. +if [[ "${rel_lower}" == "${index_rel_lower}" ]]; then + collect_spec_transitions +fi + +if [[ "${verify_gate}" == "true" && "${rel_lower}" == "${index_rel_lower}" ]]; then + fragments="" + case "${tool_name}" in + Edit) fragments="$(printf '%s' "${input}" | jq -r '.tool_input.new_string // empty' 2>/dev/null)" ;; + Write) fragments="$(printf '%s' "${input}" | jq -r '.tool_input.content // empty' 2>/dev/null)" ;; + MultiEdit) fragments="$(printf '%s' "${input}" | jq -r '[.tool_input.edits[]?.new_string // empty] | join("\n")' 2>/dev/null)" ;; + esac + + if [[ -n "${fragments}" ]]; then + # IDs marked done in the pending edit's new content. Extracts only the + # FIRST id per line (matches pwsh's `-match` + $Matches[0] semantics) - + # a `grep -o` here would emit every id on the line, including one that + # is merely mentioned in a title (e.g. "Follow-up to BUG-002"), which + # would wrongly fold an unrelated spec into the transition set. + # FEAT- only (see Rule 0 scope comment above): DELIBERATELY narrower + # than Rule 3's in-progress scan below. + pending_done="$(printf '%s' "${fragments}" \ + | grep -E '\|[[:space:]]*done[[:space:]]*\|' 2>/dev/null \ + | awk 'match($0, /FEAT-[A-Za-z0-9_-]+/) { print substr($0, RSTART, RLENGTH) }' \ + | tr -d '\r' | LC_ALL=C sort -u)" + # IDs the on-disk index already records as done (not a transition). + # Same first-match-per-line extraction as above. + already_done="" + if [[ -f "${index_path}" ]]; then + already_done="$(grep -E '\|[[:space:]]*done[[:space:]]*\|' "${index_path}" 2>/dev/null \ + | awk 'match($0, /FEAT-[A-Za-z0-9_-]+/) { print substr($0, RSTART, RLENGTH) }' \ + | tr -d '\r' | LC_ALL=C sort -u)" + fi + + transition_ids="" + while IFS= read -r id; do + [[ -z "${id}" ]] && continue + if [[ -n "${already_done}" ]] && printf '%s\n' "${already_done}" | grep -qx "${id}"; then + continue + fi + transition_ids="${transition_ids}${id}"$'\n' + done <<< "${pending_done}" + + if [[ -n "${transition_ids}" ]]; then + missing="" + while IFS= read -r id; do + [[ -z "${id}" ]] && continue + artifact="${cwd}/${spec_dir}/${id}/06-verify.md" + if [[ ! -f "${artifact}" ]] \ + || ! grep -q -i -E '^result:[[:space:]]*pass[[:space:]]*$' "${artifact}" 2>/dev/null; then + if [[ -z "${missing}" ]]; then + missing="${id}" + else + missing="${missing}, ${id}" + fi + fi + done <<< "${transition_ids}" + + if [[ -n "${missing}" ]]; then + emit_block "spec-gate: index row(s) [${missing}] -> done but no passing /sd:verify artifact. Run /sd:verify ; close-out is allowed only after ${spec_dir}//06-verify.md records 'result: pass'." + # Metrics are emitted AFTER the block decision above is + # already written to stdout - never inside the decision path. + while IFS= read -r id; do + [[ -z "${id}" ]] && continue + id_decision="allow" + if printf '%s\n' "${missing}" | tr ',' '\n' | sed 's/^ *//;s/ *$//' | grep -qx "${id}"; then + id_decision="block" + fi + emit_gate_metric "${id}" "done" "verify" "${id_decision}" + done <<< "${transition_ids}" + emit_transition_metrics "block" + exit 0 + fi + # Every transitioning spec has a passing artifact - allow the close-out. + while IFS= read -r id; do + [[ -z "${id}" ]] && continue + emit_gate_metric "${id}" "done" "verify" "allow" + done <<< "${transition_ids}" + emit_transition_metrics "allow" + exit 0 + fi + fi +fi + # --- Rule 1: protected paths -> block ---------------------------------------- is_protected=0 +rel_lower="$(to_lower "${rel}")" while IFS= read -r p; do + # Some jq builds (e.g. Windows jq.exe) emit CRLF when a filter yields + # multiple values, as this array iteration does; strip a trailing CR so + # the exact-match comparison below isn't corrupted. + p="${p%$'\r'}" [[ -z "${p}" ]] && continue - p_norm="${p//\\//}" - if [[ "${rel}" == "${p_norm}" ]]; then + p_norm="$(to_lower "${p//\\//}")" + if [[ "${rel_lower}" == "${p_norm}" ]]; then is_protected=1 break fi @@ -113,6 +523,8 @@ done < <(printf '%s' "${config_json}" | jq -r '.paths.protected // [] | .[]' 2>/ if [[ ${is_protected} -eq 1 ]]; then emit_block "spec-gate: '${rel}' is listed under paths.protected in .claude/project-config.json. Update via /sd:refactor or an ADR; never edit directly." + emit_gate_metric "-" "-" "protected" "block" + emit_transition_metrics "block" exit 0 fi @@ -120,15 +532,19 @@ fi is_allowed=0 for d in .specs/ .claude/ tests/ test/ docs/ spec/; do - if [[ "${rel}" == "${d}"* ]]; then + if [[ "${rel_lower}" == "${d}"* ]]; then is_allowed=1 break fi done basename_only="$(basename "${rel}")" -case "${basename_only}" in - README|README.*|CHANGELOG|CHANGELOG.*|CONTRIBUTING|CONTRIBUTING.*|LICENSE|LICENSE.*|NOTICE|NOTICE.*|AUTHORS|AUTHORS.*) +# Only EXTENSION-LESS project files are allow-listed by name; anything with an +# extension is decided by the extension rules below. A name like README.old.py +# must not be allow-listed just because it starts with README - it is a Python +# file, and the gate exists to catch code edits. +case "$(to_lower "${basename_only}")" in + readme|changelog|contributing|license|notice|authors) is_allowed=1 ;; esac @@ -146,6 +562,7 @@ if [[ ${is_allowed} -eq 0 ]]; then fi if [[ ${is_allowed} -eq 1 ]]; then + emit_transition_metrics "allow" exit 0 fi @@ -166,12 +583,22 @@ if [[ ${is_code} -eq 0 ]]; then exit 0 fi +# Metric ext value MUST include the leading dot (e.g. ".ps1") to match +# spec-gate.ps1's [System.IO.Path]::GetExtension output - ext_lower above has +# the dot already stripped for the extension-list case match. +metric_ext=".${ext_lower}" + # Check for in-progress spec. Both markers must appear on the SAME line -# (mirrors prompt-router.sh and spec-gate.ps1's same-line semantics). +# (mirrors prompt-router.sh and spec-gate.ps1's same-line semantics). Also +# captures the first matching id (same lines, same pattern) for the +# code-edit allow metric below - this does not change the has_in_progress +# decision, only records which spec let the edit through. has_in_progress=0 +first_in_progress="" if [[ -f "${index_path}" ]]; then - if grep -E 'in-progress' "${index_path}" 2>/dev/null \ - | grep -q -E '(FEAT|BUG|REF|PERF|RCA)-[A-Za-z0-9_-]+'; then + first_in_progress="$(grep -E 'in-progress' "${index_path}" 2>/dev/null \ + | awk 'match($0, /(FEAT|BUG|REF|PERF|RCA)-[A-Za-z0-9_-]+/) { print substr($0, RSTART, RLENGTH); exit }')" + if [[ -n "${first_in_progress}" ]]; then has_in_progress=1 fi fi @@ -180,11 +607,18 @@ if [[ ${has_in_progress} -eq 0 ]]; then msg="spec-gate: editing code file '${rel}' but no in-progress spec is recorded in .specs/index.md. Run /sd:feature, /sd:bug, /sd:refactor, or /sd:perf first to create a spec, or set hooks.specGate.mode='off' in .claude/project-config.json to disable." if [[ "${mode}" == "block" ]]; then emit_block "${msg}" + emit_gate_metric "-" "-" "code-edit" "block" "${metric_ext}" exit 0 else echo "[WARN] ${msg}" 1>&2 + emit_gate_metric "-" "-" "code-edit" "warn" "${metric_ext}" exit 0 fi +else + # An in-progress spec exists - the edit is allowed. Recording the allow + # (not just the block/warn paths) is the point: the ratio of allow to + # warn/block is what the retro loop measures. + emit_gate_metric "${first_in_progress}" "in-progress" "code-edit" "allow" "${metric_ext}" fi exit 0 diff --git a/hooks/bash/subagent-retro.sh b/hooks/bash/subagent-retro.sh index 0596aa0..0a03b71 100644 --- a/hooks/bash/subagent-retro.sh +++ b/hooks/bash/subagent-retro.sh @@ -33,13 +33,19 @@ safe_id="$(printf '%s' "${session_id}" | tr -c 'A-Za-z0-9_-' '_')" # --- load config -------------------------------------------------------------- +# An empty object is a safe fallback HERE only because every value this hook +# reads has a `//` default below, and those defaults are the same values as +# $defaults in subagent-retro.ps1. Any new read must keep that property or the +# fallback has to become a full default document, as it is in spec-gate.sh. config_path="${cwd}/.claude/project-config.json" config_json="{}" if [[ -f "${config_path}" ]] && jq -e . "${config_path}" >/dev/null 2>&1; then config_json="$(cat "${config_path}")" fi -enabled="$(printf '%s' "${config_json}" | jq -r '.hooks.subagentRetro.enabled // true' 2>/dev/null)" +# The jq alternative operator treats an explicit `false` as absent, so +# compare directly against `false` instead of relying on it here. +enabled="$(printf '%s' "${config_json}" | jq -r 'if .hooks.subagentRetro.enabled == false then "false" else "true" end' 2>/dev/null)" if [[ "${enabled}" == "false" ]]; then exit 0 fi @@ -47,6 +53,14 @@ fi stale_minutes="$(printf '%s' "${config_json}" | jq -r '.hooks.subagentRetro.retroStaleMinutes // 30' 2>/dev/null)" debounce_minutes="$(printf '%s' "${config_json}" | jq -r '.hooks.subagentRetro.debounceMinutes // 10' 2>/dev/null)" +# Lesson injection (SW-19). Same `== false` comparison as the enabled flag above: +# the jq alternative operator treats an explicit `false` as absent. +inject_lessons="$(printf '%s' "${config_json}" | jq -r 'if .hooks.subagentRetro.injectLessons == false then "false" else "true" end' 2>/dev/null)" +max_lessons="$(printf '%s' "${config_json}" | jq -r '.hooks.subagentRetro.maxLessons // 3' 2>/dev/null)" +if [[ ! "${max_lessons}" =~ ^[0-9]+$ ]]; then + max_lessons=3 +fi + spec_dir_rel="$(printf '%s' "${config_json}" | jq -r '.spec.dir // ".specs"' 2>/dev/null)" index_rel="$(printf '%s' "${config_json}" | jq -r '.spec.indexFile // ".specs/index.md"' 2>/dev/null)" @@ -55,6 +69,210 @@ index_path="${cwd}/${index_rel}" state_dir="${cwd}/.claude/.hookstate" state_path="${state_dir}/subagent-retro-${safe_id}.json" +lessons_path="${spec_dir}/_lessons/lessons.md" + +# --- session state ------------------------------------------------------------ +# +# Read once, written once. The state file is an ON-DISK CONTRACT shared with +# subagent-retro.ps1 (see write_state below) and now carries two keys: +# lastReminderUtc - debounce stamp for the stale-retro reminder. +# shownLessons - lesson lines already surfaced in THIS session. +# Both writers must preserve the key they are not updating, or a reminder would +# wipe the session's lesson history and every lesson would repeat. + +state_last_iso="" +declare -a shown_lessons=() +if [[ -f "${state_path}" ]]; then + # Windows jq.exe emits CRLF, so every value read here can carry a trailing + # CR. On a lesson line that CR makes the already-shown comparison below fail + # against the identical line read from lessons.md, and every lesson repeats + # forever; on the timestamp it corrupts the date parse. Same hazard the + # prompt-router already documents for join("\n") output - strip it at the + # boundary, once, rather than at each use. + state_last_iso="$(jq -r '.lastReminderUtc // empty' "${state_path}" 2>/dev/null || true)" + state_last_iso="${state_last_iso%$'\r'}" + while IFS= read -r shown_line; do + shown_line="${shown_line%$'\r'}" + [[ -n "${shown_line}" ]] && shown_lessons+=("${shown_line}") + done < <(jq -r '.shownLessons[]? // empty' "${state_path}" 2>/dev/null || true) +fi + +write_state() { + mkdir -p "${state_dir}" 2>/dev/null || true + local shown_json + shown_json="$(printf '%s\n' "${shown_lessons[@]:-}" \ + | jq -R . 2>/dev/null | jq -sc 'map(select(length > 0))' 2>/dev/null)" || shown_json='' + [[ -z "${shown_json}" ]] && shown_json='[]' + jq -nc --arg t "${state_last_iso}" --argjson s "${shown_json}" \ + 'if $t == "" then {shownLessons:$s} else {lastReminderUtc:$t, shownLessons:$s} end' \ + > "${state_path}" 2>/dev/null || true +} + +# --- metrics: shared event writer ----------------------------------------- +# Duplicated verbatim from spec-gate.sh (hooks are standalone scripts with no +# shared library - keeping the two copies textually identical makes any +# future drift between them greppable). Append-only, metadata-only event log +# for the retro loop (SW-10). Every failure path here is a silent no-op - +# metrics must NEVER surface as a hook error. Emitted regardless of debounce; +# see the emit_subagent_stop_metric call site below. + +# A metrics path is not an arbitrary-write primitive: reject anything rooted +# (leading '/' or a drive-letter prefix) or that escapes cwd via '..' rather +# than ever writing outside the workspace. Reuses the same rootedness test and +# dot-segment collapse used for the gate's own path safety above, so the two +# checks cannot silently diverge. Mirrors Test-MetricsPathSafe in +# spec-gate.ps1. +metrics_path_is_safe() { + local p="$1" + [[ -z "${p}" ]] && return 1 + if [[ "${p}" == /* || "${p}" == ?:* ]]; then + return 1 + fi + local collapsed + collapsed="$(collapse_dot_segments "${p//\\//}")" + if [[ "${collapsed}" == ".." || "${collapsed}" == "../"* ]]; then + return 1 + fi + return 0 +} + +# $1 = a complete JSON object literal string containing every field EXCEPT +# ts, already in fixed key order (spec_id, phase, event, ...). ts is prepended +# here so each call site only has to build the part specific to its own event +# kind. jq's object-add operator appends keys from the right operand that are +# not already present in the left, in their own original order - since ts is +# never present in the body, this reliably yields ts first followed by the +# body's keys in the order the caller wrote them. +write_metric_line() { + local body="$1" + + local metrics_enabled + # `//` treats an explicit `false` as absent, so compare directly against + # `false` (type-strict: only a literal JSON boolean false disables this). + metrics_enabled="$(printf '%s' "${config_json}" | jq -r 'if .hooks.metrics.enabled == false then "false" else "true" end' 2>/dev/null)" + [[ "${metrics_enabled}" == "false" ]] && return 0 + + local metrics_path + metrics_path="$(printf '%s' "${config_json}" | jq -r '.hooks.metrics.path // ".specs/_metrics/events.jsonl"' 2>/dev/null)" + metrics_path="${metrics_path//\\//}" + metrics_path_is_safe "${metrics_path}" || return 0 + + local full_path="${cwd}/${metrics_path}" + mkdir -p "$(dirname "${full_path}")" 2>/dev/null || return 0 + + local ts + ts="$(date -u +'%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || date -u)" + + local line + line="$(printf '%s' "${body}" | jq -c --arg ts "${ts}" '{ts:$ts} + .' 2>/dev/null)" + [[ -z "${line}" ]] && return 0 + + # --- rotation (SW-15) ----------------------------------------------------- + # Bounded log: before appending, if the live file already meets or exceeds + # the byte cap, roll it to `.1` (single generation, overwriting any prior + # roll). maxSizeKb defaults to 1024 when the key is ABSENT, so a + # project-config.json written before SW-15 - and this hook's own `{}` + # no-config fallback - still gets a bounded log with no edit; an explicit 0 + # or negative disables rotation (the opt-out) and any non-number is invalid + # and also disables it (SW-22 scar - never let a bad type silently flip + # behavior). Best-effort - every failure path (a file the other hook holds + # open on Windows, a read-only dir) is a silent no-op that falls through to + # the append below: rotation must NEVER stop the append (silent data loss + # reads as "metrics working", which the ticket flags as worse than growth) + # nor surface as a hook error. jq floors maxSizeKb*1024 to an integer so + # bash never does float math; `wc -c` is the byte count Write-MetricEvent + # also measures, and the absent->1024 / bad-type->off rules match its guard, + # so PS and bash trip at the same boundary. + local max_bytes + max_bytes="$(printf '%s' "${config_json}" | jq -r '(if (.hooks.metrics | type) == "object" and (.hooks.metrics | has("maxSizeKb")) then .hooks.metrics.maxSizeKb else 1024 end) as $k | if ($k | type) == "number" and $k > 0 then ($k * 1024 | floor) else "" end' 2>/dev/null)" + if [[ -n "${max_bytes}" && -f "${full_path}" ]]; then + local cur_bytes + cur_bytes="$(wc -c < "${full_path}" 2>/dev/null | tr -d '[:space:]')" + if [[ "${cur_bytes}" =~ ^[0-9]+$ && "${cur_bytes}" -ge "${max_bytes}" ]]; then + mv -f "${full_path}" "${full_path}.1" 2>/dev/null || true + fi + fi + + printf '%s\n' "${line}" >> "${full_path}" 2>/dev/null || true + return 0 +} + +# Collapses '.' and '..' segments in a forward-slash path using pure string +# processing - no filesystem access, no `realpath`/`readlink -f`/`cd`. Mirrors +# collapse_dot_segments in spec-gate.sh exactly (duplicated verbatim - see the +# metrics writer note above). Only used here by metrics_path_is_safe. +collapse_dot_segments() { + local path="$1" + local root_prefix="" body="${path}" + if [[ "${path}" == /* ]]; then + root_prefix="/" + body="${path#/}" + elif [[ "${path}" == ?:* ]]; then + root_prefix="${path:0:2}/" + body="${path:2}" + body="${body#/}" + fi + + local rooted=0 + [[ -n "${root_prefix}" ]] && rooted=1 + + local acc="" seg rest="${body}" + while [[ -n "${rest}" ]]; do + seg="${rest%%/*}" + if [[ "${rest}" == */* ]]; then + rest="${rest#*/}" + else + rest="" + fi + case "${seg}" in + ''|'.') + continue + ;; + '..') + if [[ -n "${acc}" ]]; then + local last="${acc##*/}" + if [[ "${last}" == '..' ]]; then + # Already-stacked leading '..' (unrooted overflow) - keep stacking. + acc="${acc}/.." + else + local trimmed="${acc%/*}" + if [[ "${trimmed}" == "${acc}" ]]; then + # acc was a single segment with no slash - pop to empty. + acc="" + else + acc="${trimmed}" + fi + fi + elif [[ ${rooted} -eq 1 ]]; then + # Cannot go above the root - drop it, matching GetFullPath's clamp. + : + else + # No root to clamp against - keep the unresolved '..'. + acc=".." + fi + ;; + *) + if [[ -z "${acc}" ]]; then + acc="${seg}" + else + acc="${acc}/${seg}" + fi + ;; + esac + done + + printf '%s%s' "${root_prefix}" "${acc}" +} + +# subagent_stop metric: $1=spec_id $2=phase $3=stale (0 or 1) +emit_subagent_stop_metric() { + local spec_id="$1" phase="$2" stale="$3" + local body + body="$(jq -nc --arg spec_id "${spec_id}" --arg phase "${phase}" --argjson stale "${stale}" \ + '{spec_id:$spec_id,phase:$phase,event:"subagent_stop",stale:$stale}' 2>/dev/null)" + [[ -z "${body}" ]] && return 0 + write_metric_line "${body}" +} # --- portable mtime helper (Linux: -c %Y; macOS/BSD: -f %m) ------------------- @@ -145,10 +363,115 @@ for i in "${!specs[@]}"; do if (( age >= threshold_secs )); then stale_id+=("${sid}") stale_reason+=("stale") - stale_age+=("$(( age / 60 ))") + # Round to the nearest minute rather than truncating, so the reported + # age matches subagent-retro.ps1's [Math]::Round on the same mtime. + stale_age+=("$(( (age + 30) / 60 ))") fi done +# Metrics: one subagent_stop event per in-progress spec, emitted regardless +# of staleness or debounce - debounce below only suppresses the user-facing +# reminder, never this measurement (SW-10). A spec not in stale_id[] +# (including every RCA, which the loop above always skips) reports stale=0. +for i in "${!specs[@]}"; do + sid="${specs[$i]}" + is_stale=0 + for s in "${stale_id[@]:-}"; do + if [[ "${s}" == "${sid}" ]]; then + is_stale=1 + break + fi + done + emit_subagent_stop_metric "${sid}" "in-progress" "${is_stale}" +done + +# --- lesson injection (SW-19) ------------------------------------------------- +# +# PLACEMENT IS LOAD-BEARING. This sits beside the metrics emit above, BEFORE the +# staleness early-exit and BEFORE the debounce window - deliberately, and for the +# same reason the metrics call site does. Moved down to the reminder block, it +# would only ever surface lessons to users who are already behind on their +# retros, which is exactly the population that needs them least. +# +# The one gate it does keep is the in-progress-spec check further up: no spec in +# flight, no output. That gate IS the relevance filter - the workflow type of the +# in-progress spec selects which lessons apply, so there is no ranking, no +# scoring, and therefore no tie-break that could diverge from the PowerShell +# twin. +# +# Repetition is bounded per SESSION, not by a clock. shownLessons in the state +# file records what has already been surfaced, so maxLessons caps how many NEW +# lessons appear at one stop and a session converges to silence once it has said +# everything relevant. A time debounce was rejected: it would suppress a lesson +# the user has never seen purely because a different one was shown recently. + +emit_lessons() { + [[ "${inject_lessons}" == "false" ]] && return 0 + [[ "${max_lessons}" -gt 0 ]] || return 0 + [[ -f "${lessons_path}" ]] || return 0 + + # Scope selector: the workflow types currently in flight, plus 'all'. + local wanted=" all " + local sid stype + for sid in "${specs[@]}"; do + stype="${sid%%-*}" + case "${stype}" in + FEAT) wanted="${wanted}feature " ;; + BUG) wanted="${wanted}bug " ;; + REF) wanted="${wanted}refactor " ;; + PERF) wanted="${wanted}perf " ;; + RCA) wanted="${wanted}rca " ;; + esac + done + + local lesson_re='^- \[([a-z-]+)\] ([a-z]+)/([a-z]+): (.+)$' + local -a picked=() + local line scope already prev + + # File order is the selection order. lessons.md is rendered in a total, + # deterministic order by aggregate-lessons.*, so "the first N that match" is + # itself deterministic - no sorting is done or needed here. + while IFS= read -r line || [[ -n "${line}" ]]; do + line="${line%$'\r'}" + [[ "${line}" == "- ["* ]] || continue + [[ "${line}" =~ ${lesson_re} ]] || continue + + scope="${BASH_REMATCH[3]}" + [[ "${wanted}" == *" ${scope} "* ]] || continue + + already=0 + for prev in "${shown_lessons[@]:-}"; do + if [[ "${prev}" == "${line}" ]]; then already=1; break; fi + done + [[ ${already} -eq 1 ]] && continue + + picked+=("${line}") + [[ ${#picked[@]} -ge ${max_lessons} ]] && break + done < "${lessons_path}" + + [[ ${#picked[@]} -gt 0 ]] || return 0 + + { + echo '' + echo 'Lessons recorded in earlier retros of this project, matching the workflow' + echo 'type of the spec(s) currently in progress:' + for line in "${picked[@]}"; do + echo " ${line}" + done + echo '' + echo 'These are not shown again this session.' + echo '' + } + + for line in "${picked[@]}"; do + shown_lessons+=("${line}") + done + write_state + return 0 +} + +emit_lessons + if [[ ${#stale_id[@]} -eq 0 ]]; then exit 0 fi @@ -156,22 +479,32 @@ fi # --- debounce ----------------------------------------------------------------- debounce_secs=$(( debounce_minutes * 60 )) -if [[ -f "${state_path}" ]]; then - last_iso="$(jq -r '.lastReminderUtc // empty' "${state_path}" 2>/dev/null || true)" - if [[ -n "${last_iso}" ]]; then - # Convert ISO8601 to epoch. GNU date supports -d; BSD date needs -j -f. +# Reads the stamp captured in the single state read near the top rather than +# re-reading the file: emit_lessons may have rewritten it a moment ago, and that +# write never touches lastReminderUtc. +last_iso="${state_last_iso}" +if [[ -n "${last_iso}" ]]; then + # Strip the UTC marker and any fractional seconds. BSD date's -f cannot + # be given trailing unconverted text (it warns on stderr, which would + # break the hook's silence), and a state file written by an older + # subagent-retro.ps1 carries 7 fractional digits. + iso_trimmed="${last_iso%Z}" + iso_trimmed="${iso_trimmed%.*}" + # Convert ISO8601 to epoch. GNU date supports -d; BSD date needs -j -f. + # Both branches must interpret the value as UTC - it is written as UTC + # by both implementations, so a local-time reading would skew the + # debounce window by the machine's offset. + last_epoch="" + if last_epoch="$(date -u -d "${last_iso}" +%s 2>/dev/null)"; then + : + elif last_epoch="$(date -u -j -f '%Y-%m-%dT%H:%M:%S' "${iso_trimmed}" +%s 2>/dev/null)"; then + : + else last_epoch="" - if last_epoch="$(date -d "${last_iso}" +%s 2>/dev/null)"; then - : - elif last_epoch="$(date -j -f '%Y-%m-%dT%H:%M:%S' "${last_iso%.*}" +%s 2>/dev/null)"; then - : - else - last_epoch="" - fi - if [[ -n "${last_epoch}" && "${last_epoch}" =~ ^[0-9]+$ ]]; then - if (( now_epoch - last_epoch < debounce_secs )); then - exit 0 - fi + fi + if [[ -n "${last_epoch}" && "${last_epoch}" =~ ^[0-9]+$ ]]; then + if (( now_epoch - last_epoch < debounce_secs )); then + exit 0 fi fi fi @@ -198,8 +531,12 @@ fi # --- save state -------------------------------------------------------------- -mkdir -p "${state_dir}" 2>/dev/null || true -iso_now="$(date -u +'%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || date -u)" -jq -nc --arg t "${iso_now}" '{lastReminderUtc:$t}' > "${state_path}" 2>/dev/null || true +# State-file shape is an ON-DISK CONTRACT shared with subagent-retro.ps1: a +# session can write it under one implementation and read it under the other, so +# both keys and the whole-second UTC format must stay identical in both. +# write_state re-emits shownLessons alongside the new stamp - dropping it here +# would clear the session's lesson history and make every lesson repeat. +state_last_iso="$(date -u +'%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || date -u)" +write_state exit 0 diff --git a/hooks/powershell/prompt-router.ps1 b/hooks/powershell/prompt-router.ps1 index 3d742cd..b713c46 100644 --- a/hooks/powershell/prompt-router.ps1 +++ b/hooks/powershell/prompt-router.ps1 @@ -70,8 +70,13 @@ function Get-ProjectConfig { $cfgPath = Join-Path $Cwd '.claude/project-config.json' if (-not (Test-Path -LiteralPath $cfgPath)) { return $defaults } + # -ErrorAction Stop is required: the script-wide SilentlyContinue preference + # would otherwise make a malformed config a NON-terminating error, so the + # catch never fires and the function returns $null instead of the defaults. try { - $loaded = Get-Content -LiteralPath $cfgPath -Raw -Encoding UTF8 | ConvertFrom-Json + $loaded = Get-Content -LiteralPath $cfgPath -Raw -Encoding UTF8 -ErrorAction Stop | + ConvertFrom-Json -ErrorAction Stop + if ($null -eq $loaded) { return $defaults } return $loaded } catch { return $defaults @@ -83,7 +88,14 @@ function Test-HookEnabled { try { if ($null -eq $Config.hooks) { return $true } if ($null -eq $Config.hooks.userPromptRouter) { return $true } - return [bool]$Config.hooks.userPromptRouter.enabled + # Type-strict: only a literal JSON boolean false disables the hook. + # [bool]$null is $false, so a userPromptRouter block with an ABSENT + # `enabled` would silently disable the router - diverging from + # prompt-router.sh's `== false`, which leaves it on. -is [bool] matches + # jq (SW-22). + $en = $Config.hooks.userPromptRouter.enabled + if (($en -is [bool]) -and (-not $en)) { return $false } + return $true } catch { return $true } diff --git a/hooks/powershell/spec-gate.ps1 b/hooks/powershell/spec-gate.ps1 index 97fcd41..f8cb587 100644 --- a/hooks/powershell/spec-gate.ps1 +++ b/hooks/powershell/spec-gate.ps1 @@ -53,19 +53,98 @@ function Get-ProjectConfig { } hooks = [pscustomobject]@{ specGate = [pscustomobject]@{ enabled = $true; mode = 'warn' } + metrics = [pscustomobject]@{ enabled = $true; path = '.specs/_metrics/events.jsonl'; maxSizeKb = 1024 } } } $cfgPath = Join-Path $Cwd '.claude/project-config.json' if (-not (Test-Path -LiteralPath $cfgPath)) { return $defaults } + # -ErrorAction Stop is required: the script-wide SilentlyContinue preference + # would otherwise make a malformed config a NON-terminating error, so the + # catch never fires and the function returns $null instead of the defaults - + # silently dropping the built-in protected paths. try { - return (Get-Content -LiteralPath $cfgPath -Raw -Encoding UTF8 | ConvertFrom-Json) + $loaded = Get-Content -LiteralPath $cfgPath -Raw -Encoding UTF8 -ErrorAction Stop | + ConvertFrom-Json -ErrorAction Stop + if ($null -eq $loaded) { return $defaults } + return $loaded } catch { return $defaults } } +function Test-IsRootedPath { + param([string]$Path) + # Mirrors spec-gate.sh's rootedness test ("${fp}" != /* && "${fp}" != ?:*), + # evaluated on the RAW path (before backslash->slash conversion): a leading + # '/' or a drive-letter prefix like 'C:' is rooted, anything else - a plain + # relative path such as 'src/../.specs/constitution.md' - is not. + if ([string]::IsNullOrEmpty($Path)) { return $false } + if ($Path[0] -eq '/') { return $true } + if ($Path.Length -ge 2 -and $Path[1] -eq ':') { return $true } + return $false +} + +function ConvertTo-CollapsedPath { + param([string]$Path) + # Pure string-based collapse of '.' and '..' segments on a forward-slash + # path - no filesystem access, no .NET path resolution. This mirrors + # collapse_dot_segments in spec-gate.sh exactly, including: + # - a rooted path (leading '/' or a drive prefix 'C:/') clamps a '..' at + # its own root instead of walking above it; + # - an unrooted path with nothing to clamp against keeps an unresolved + # leading '..' rather than discarding it; + # - an empty segment (produced by '//' or by a TRAILING separator, e.g. + # ".specs/constitution.md/") is a no-op, same as a '.' segment - this + # is what makes a trailing separator collapse away instead of + # defeating the later exact-match comparison against paths.protected. + $rootPrefix = '' + $body = $Path + $rooted = $false + if ($Path.StartsWith('/')) { + $rootPrefix = '/' + $body = $Path.Substring(1) + $rooted = $true + } elseif ($Path.Length -ge 2 -and $Path[1] -eq ':') { + $rootPrefix = $Path.Substring(0, 2) + '/' + $body = $Path.Substring(2) + if ($body.StartsWith('/')) { $body = $body.Substring(1) } + $rooted = $true + } + + $acc = '' + foreach ($seg in $body -split '/') { + if ($seg -eq '' -or $seg -eq '.') { + continue + } + if ($seg -eq '..') { + if ($acc -ne '') { + $lastSlash = $acc.LastIndexOf('/') + if ($lastSlash -ge 0) { $last = $acc.Substring($lastSlash + 1) } else { $last = $acc } + if ($last -eq '..') { + # Already-stacked leading '..' (unrooted overflow) - keep stacking. + $acc = "$acc/.." + } elseif ($lastSlash -ge 0) { + $acc = $acc.Substring(0, $lastSlash) + } else { + # acc was a single segment with no slash - pop to empty. + $acc = '' + } + } elseif ($rooted) { + # Cannot go above the root - drop it, matching the bash clamp. + } else { + # No root to clamp against - keep the unresolved '..'. + $acc = '..' + } + } else { + if ($acc -eq '') { $acc = $seg } else { $acc = "$acc/$seg" } + } + } + + return "$rootPrefix$acc" +} + function ConvertTo-RelativePath { param( [string]$Cwd, @@ -73,13 +152,42 @@ function ConvertTo-RelativePath { ) if ([string]::IsNullOrWhiteSpace($FilePath)) { return $null } try { - $full = [System.IO.Path]::GetFullPath($FilePath) - $base = [System.IO.Path]::GetFullPath($Cwd) - if ($full.StartsWith($base, [System.StringComparison]::OrdinalIgnoreCase)) { - $rel = $full.Substring($base.Length).TrimStart('\','/') - return $rel.Replace('\','/') + # If FilePath is not rooted (no leading '/' and no drive-letter prefix + # such as 'C:'), it is already relative to Cwd by construction, so + # collapsing its own dot segments directly yields the correct + # relative-to-Cwd path. Joining it onto Cwd and calling + # [System.IO.Path]::GetFullPath would resolve against THIS SCRIPT + # PROCESS's own working directory instead of the hook payload's Cwd - + # that mismatch was the root cause of the + # 'src/../.specs/constitution.md' traversal bypass, since the + # resulting absolute path never started with $base and fell through + # to the raw-path fallback below. This mirrors spec-gate.sh's + # normalize_rel first branch exactly. + if (-not (Test-IsRootedPath $FilePath)) { + return ConvertTo-CollapsedPath -Path ($FilePath.Replace('\','/')) } - return $FilePath.Replace('\','/') + + # Collapse '.'/'..' BEFORE the prefix strip, so a path that traverses + # through a directory and back (e.g. cwd/src/../.specs/x) is compared + # against base in its fully-resolved form, not its literal typed form. + # A trailing separator collapses away here too (see + # ConvertTo-CollapsedPath), which fixes the second bypass: without + # this, [System.IO.Path]::GetExtension on a path ending in '/' or '\' + # returns "" and the path escapes both the protected-path equality + # check and the code-file extension check. + $fpRaw = $FilePath.Replace('\','/') + $baseNorm = $Cwd.Replace('\','/') + $fpNorm = ConvertTo-CollapsedPath -Path $fpRaw + $baseCollapsed = ConvertTo-CollapsedPath -Path $baseNorm + + if ($fpNorm.StartsWith($baseCollapsed, [System.StringComparison]::OrdinalIgnoreCase)) { + $rel = $fpNorm.Substring($baseCollapsed.Length).TrimStart('/') + return $rel + } + # Resolving FilePath lands outside Cwd entirely (e.g. enough leading + # '..' to escape the workspace) - fall back to the raw, un-collapsed + # path, same as spec-gate.sh's normalize_rel fallback branch. + return $fpRaw } catch { return $FilePath.Replace('\','/') } @@ -104,8 +212,13 @@ function Test-IsAllowListed { foreach ($d in $allowDirs) { if ($RelPath.StartsWith($d, [System.StringComparison]::OrdinalIgnoreCase)) { return $true } } + # Only EXTENSION-LESS project files are allow-listed by name; anything with + # an extension is decided by the extension rules below. The old pattern + # accepted one optional extension, which allow-listed README.py outright and + # (having no multi-dot form) split hairs over README.old.py. Neither should + # bypass the gate - they are source files whatever they are called. $name = [System.IO.Path]::GetFileName($RelPath) - if ($name -match '^(README|CHANGELOG|CONTRIBUTING|LICENSE|NOTICE|AUTHORS)(\.[A-Za-z]+)?$') { return $true } + if ($name -match '^(README|CHANGELOG|CONTRIBUTING|LICENSE|NOTICE|AUTHORS)$') { return $true } $ext = [System.IO.Path]::GetExtension($RelPath).ToLowerInvariant() $docExts = @('.md','.markdown','.txt','.rst','.adoc','.json','.yaml','.yml','.toml','.ini','.env','.example') @@ -149,6 +262,276 @@ function Get-InProgressSpecs { return $result } +function Get-DoneTransitionIds { + param( + [object]$HookInput, + [string]$IndexPath + ) + # IDs that the pending edit marks as done but that the on-disk index does + # not yet record as done. Fragments are the tool-specific NEW content. + # FEAT- only (see Rule 0 comment below): the id extraction here is + # DELIBERATELY narrower than Rule 3's in-progress scan. + $fragments = New-Object System.Collections.Generic.List[string] + try { + $tool = $HookInput.tool_name + if ($tool -eq 'Edit') { + if ($HookInput.tool_input.new_string) { + $fragments.Add([string]$HookInput.tool_input.new_string) | Out-Null + } + } elseif ($tool -eq 'Write') { + if ($HookInput.tool_input.content) { + $fragments.Add([string]$HookInput.tool_input.content) | Out-Null + } + } elseif ($tool -eq 'MultiEdit') { + foreach ($e in @($HookInput.tool_input.edits)) { + if ($e.new_string) { $fragments.Add([string]$e.new_string) | Out-Null } + } + } + } catch { } + + $alreadyDone = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::Ordinal) + if (Test-Path -LiteralPath $IndexPath) { + try { + foreach ($line in (Get-Content -LiteralPath $IndexPath -Encoding UTF8 -ErrorAction Stop)) { + if ($line -match '\|\s*done\s*\|' -and $line -match 'FEAT-[A-Za-z0-9_\-]+') { + [void]$alreadyDone.Add($Matches[0]) + } + } + } catch { } + } + + $result = New-Object System.Collections.Generic.List[string] + foreach ($frag in $fragments) { + foreach ($line in ($frag -split "`n")) { + if ($line -match '\|\s*done\s*\|' -and $line -match 'FEAT-[A-Za-z0-9_\-]+') { + $id = $Matches[0] + if (-not $alreadyDone.Contains($id) -and -not $result.Contains($id)) { + $result.Add($id) | Out-Null + } + } + } + } + return ,$result +} + +function Get-SpecStatusTransitions { + param( + [object]$HookInput, + [string]$IndexPath + ) + # Read-only, general-purpose lifecycle scan (all 5 prefixes x all 5 + # statuses) that backs the observational spec_transition metric. This is + # DELIBERATELY a separate function from Get-DoneTransitionIds above - that + # one is FEAT-/done-only and backs the live Rule 0 gate decision (see its + # Rule 0 scope comment). Folding the two together would make a future + # edit to either accidentally change the other's behavior. + $result = New-Object System.Collections.Generic.List[object] + try { + $rowPattern = '\|\s*((?:FEAT|BUG|REF|PERF|RCA)-[A-Za-z0-9_\-]+)\s*\|\s*[^|]*\|\s*(draft|approved|in-progress|done|archived)\s*\|' + + # Statuses recorded on disk BEFORE this pending edit lands - PreToolUse + # runs before the write, so the file still reflects the prior state. + $oldStatus = @{} + if (Test-Path -LiteralPath $IndexPath) { + try { + foreach ($line in (Get-Content -LiteralPath $IndexPath -Encoding UTF8 -ErrorAction Stop)) { + if ($line -match $rowPattern) { $oldStatus[$Matches[1]] = $Matches[2] } + } + } catch { } + } + + $fragments = New-Object System.Collections.Generic.List[string] + $tool = $HookInput.tool_name + if ($tool -eq 'Edit') { + if ($HookInput.tool_input.new_string) { $fragments.Add([string]$HookInput.tool_input.new_string) | Out-Null } + } elseif ($tool -eq 'Write') { + if ($HookInput.tool_input.content) { $fragments.Add([string]$HookInput.tool_input.content) | Out-Null } + } elseif ($tool -eq 'MultiEdit') { + foreach ($e in @($HookInput.tool_input.edits)) { + if ($e.new_string) { $fragments.Add([string]$e.new_string) | Out-Null } + } + } + + $seen = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::Ordinal) + foreach ($frag in $fragments) { + foreach ($line in ($frag -split "`n")) { + if (-not ($line -match $rowPattern)) { continue } + $id = $Matches[1] + $newStatus = $Matches[2] + if ($seen.Contains($id)) { continue } + [void]$seen.Add($id) + $from = if ($oldStatus.ContainsKey($id)) { $oldStatus[$id] } else { '-' } + if ($from -ne $newStatus) { + $result.Add([pscustomobject]@{ Id = $id; Phase = $newStatus; From = $from }) | Out-Null + } + } + } + } catch { } + return ,$result +} + +function Test-MetricsPathSafe { + param([string]$RelPath) + # A metrics path is not an arbitrary-write primitive: reject anything + # rooted (absolute, or a drive-letter path) or that escapes Cwd via '..' + # rather than ever writing outside the workspace. Reuses the same + # rootedness test and dot-segment collapse used for the gate's own path + # safety above, so the two safety checks cannot silently diverge. + if ([string]::IsNullOrWhiteSpace($RelPath)) { return $false } + if (Test-IsRootedPath -Path $RelPath) { return $false } + $collapsed = ConvertTo-CollapsedPath -Path ($RelPath.Replace('\','/')) + if ($collapsed -eq '..' -or $collapsed.StartsWith('../')) { return $false } + return $true +} + +function Write-MetricEvent { + param( + [string]$Cwd, + [object]$Config, + [string]$SpecId, + [string]$Phase, + [string]$EventKind, + [System.Collections.Specialized.OrderedDictionary]$Fields + ) + # Fully wrapped: a metrics failure must NEVER surface as a hook error or + # change a gate decision. Every call site invokes this AFTER the decision + # is already computed (and, for a block, already written to stdout) - + # never from inside the decision path itself. + try { + $enabled = $true + # Type-strict: only a literal JSON boolean false disables metrics - + # copies the verifyGate `-is [bool]` pattern above so a string + # "false" in project-config.json leaves metrics on, matching + # spec-gate.sh's `== false` jq comparison. + if (($Config.hooks.metrics.enabled -is [bool]) -and (-not $Config.hooks.metrics.enabled)) { + $enabled = $false + } + if (-not $enabled) { return } + + $relPath = '.specs/_metrics/events.jsonl' + try { if ($Config.hooks.metrics.path) { $relPath = [string]$Config.hooks.metrics.path } } catch { } + $relPath = $relPath.Replace('\','/') + + if (-not (Test-MetricsPathSafe -RelPath $relPath)) { return } + + $fullPath = Join-Path $Cwd $relPath + $parent = Split-Path -Path $fullPath -Parent + if (-not (Test-Path -LiteralPath $parent)) { + New-Item -ItemType Directory -Path $parent -Force -ErrorAction Stop | Out-Null + } + + # [ordered] (not a plain hashtable) so ConvertTo-Json emits keys in + # the exact insertion order below - a plain hashtable does not + # guarantee enumeration order, which would let the two + # implementations drift apart on key order for the same input. + $ordered = [ordered]@{} + $ordered['ts'] = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + $ordered['spec_id'] = $SpecId + $ordered['phase'] = $Phase + $ordered['event'] = $EventKind + foreach ($key in $Fields.Keys) { $ordered[$key] = $Fields[$key] } + + $line = ([pscustomobject]$ordered) | ConvertTo-Json -Compress + + # --- rotation (SW-15) -------------------------------------------------- + # Bounded log: before appending, if the live file already meets or + # exceeds the byte cap, roll it to '.1' (single generation, overwriting + # any prior roll). maxSizeKb defaults to 1024 when the key is ABSENT, so + # a project-config.json written before SW-15 still gets a bounded log + # with no edit; an explicit 0 or negative disables rotation (the opt-out) + # and any non-number is invalid and also disables it (SW-22 scar - never + # let a bad type silently flip behavior). Best-effort - the Move-Item has + # its own -ErrorAction Stop / catch so a file the other hook holds open + # on Windows, or a read-only dir, is a silent no-op that falls through to + # the append below: rotation must NEVER stop the append (silent data loss + # reads as "metrics working", which the ticket flags as worse than + # growth) nor surface as a hook error. (Get-Item).Length is the raw byte + # count matching bash's `wc -c`, and the absent->1024 / bad-type->off + # rules match spec-gate.sh's jq, so PS and bash trip at the same + # boundary. + try { + $maxKb = 1024 + $m = $Config.hooks.metrics + if (($null -ne $m) -and ($m.PSObject.Properties.Name -contains 'maxSizeKb')) { + $maxKb = $m.maxSizeKb + } + if (($maxKb -is [int] -or $maxKb -is [long] -or $maxKb -is [double]) -and ($maxKb -gt 0)) { + $maxBytes = [long][math]::Floor([double]$maxKb * 1024) + if (Test-Path -LiteralPath $fullPath) { + if ((Get-Item -LiteralPath $fullPath).Length -ge $maxBytes) { + Move-Item -LiteralPath $fullPath -Destination "$fullPath.1" -Force -ErrorAction Stop + } + } + } + } catch { } + + # Add-Content opens and closes the file handle per call and is not + # safe against the concurrent PreToolUse + SubagentStop appends this + # log can see; retry briefly on a transient sharing violation instead + # of failing loudly. + $attempt = 0 + while ($attempt -lt 5) { + try { + # UTF8Encoding($false): NO byte-order-mark. [Encoding]::UTF8 + # writes a BOM preamble on the FIRST write to a new/empty + # file, which spec-gate.sh's plain `>>` append never does - + # that would make the two implementations' first line differ + # by 3 bytes for the exact same input. + [System.IO.File]::AppendAllText($fullPath, "$line`n", (New-Object System.Text.UTF8Encoding($false))) + break + } catch { + $attempt++ + if ($attempt -ge 5) { break } + Start-Sleep -Milliseconds 20 + } + } + } catch { } +} + +function Write-GateMetric { + param( + [string]$Cwd, + [object]$Config, + [string]$SpecId, + [string]$Phase, + [string]$Gate, + [string]$Decision, + [string]$Ext = '' + ) + $fields = [ordered]@{ gate = $Gate; decision = $Decision } + if ($Ext) { $fields['ext'] = $Ext } + Write-MetricEvent -Cwd $Cwd -Config $Config -SpecId $SpecId -Phase $Phase -EventKind 'gate' -Fields $fields +} + +function Write-TransitionMetrics { + param( + [string]$Cwd, + [object]$Config, + [object[]]$Transitions, + [string]$Decision + ) + foreach ($t in $Transitions) { + $fields = [ordered]@{ from = $t.From; decision = $Decision } + Write-MetricEvent -Cwd $Cwd -Config $Config -SpecId $t.Id -Phase $t.Phase -EventKind 'spec_transition' -Fields $fields + } +} + +function Test-VerifyArtifactPass { + param( + [string]$Cwd, + [string]$SpecDir, + [string]$SpecId + ) + $artifact = Join-Path $Cwd (Join-Path $SpecDir (Join-Path $SpecId '06-verify.md')) + if (-not (Test-Path -LiteralPath $artifact)) { return $false } + try { + $content = Get-Content -LiteralPath $artifact -Raw -Encoding UTF8 -ErrorAction Stop + } catch { + return $false + } + return ($content -match '(?im)^result:\s*pass\s*$') +} + function Write-BlockDecision { param([string]$Reason) # Dual-format: new hookSpecificOutput schema + legacy decision field. @@ -179,7 +562,12 @@ $config = Get-ProjectConfig -Cwd $cwd # Hook globally disabled? try { - if ($null -ne $config.hooks -and $null -ne $config.hooks.specGate -and -not $config.hooks.specGate.enabled) { + # Type-strict: only a literal JSON boolean false disables the gate. A plain + # `-not ...enabled` fires on an ABSENT key ($null), silently disabling the + # gate when a hand-trimmed config carries a specGate block with no `enabled` + # - diverging from spec-gate.sh's `== false`, which leaves it on. -is [bool] + # matches jq (SW-22); mirrors the verifyGate/metrics reads below. + if (($config.hooks.specGate.enabled -is [bool]) -and (-not $config.hooks.specGate.enabled)) { exit 0 } } catch { } @@ -194,30 +582,135 @@ if ([string]::IsNullOrWhiteSpace($filePath)) { exit 0 } $rel = ConvertTo-RelativePath -Cwd $cwd -FilePath $filePath if ([string]::IsNullOrWhiteSpace($rel)) { exit 0 } +# Rule 0: verify gate on the spec index. A row transitioning to done requires +# a passing /sd:verify artifact; a verified close-out is allowed through the +# protected-path rule. Any other direct index edit falls through to Rule 1. +# +# Scope: FEAT- rows only. Bug/refactor/perf/rca workflows do not produce +# 02-tasks.md and never run /sd:verify, so gating them here would hard-STOP +# their close-out at VF002 with no way through. Non-FEAT rows fall through to +# the unconditional Rule 1 protected-path block, exactly as before this +# gate existed - until their workflows integrate /sd:verify (follow-up spec). +# +# Bundled-edit limitation: when every newly-done FEAT row in the pending edit +# has a passing artifact, the WHOLE edit is allowed - including any unrelated +# row changes bundled into the same Write/Edit/MultiEdit. This hook inspects +# only the done-transition lines, not a full diff, so a bundled edit could in +# principle piggyback an unrelated change. Accepted limitation (hook-scale +# diff inspection is out of scope); the /sd:spec registry commands are the +# semantic guard for anything this coarse check cannot see. +$verifyGateOn = $true +try { + # Type-strict: only a literal JSON boolean false disables the gate. Plain + # `-eq $false` would also match the JSON STRING "false" (PowerShell coerces + # a string to bool via -eq's LHS type), diverging from jq's `== false` + # in spec-gate.sh, which is type-strict and leaves the gate ON for a + # string value. -is [bool] keeps this branch aligned with jq. + if (($config.hooks.specGate.verifyGate -is [bool]) -and (-not $config.hooks.specGate.verifyGate)) { + $verifyGateOn = $false + } +} catch { } + +$indexRel = '.specs/index.md' +try { if ($config.spec.indexFile) { $indexRel = ([string]$config.spec.indexFile).Replace('\','/') } } catch { } +$specDir = '.specs' +try { if ($config.spec.dir) { $specDir = [string]$config.spec.dir } } catch { } + +# spec_transition metric: read-only, general lifecycle scan of THIS index.md +# edit. Computed unconditionally (independent of $verifyGateOn and of which +# rule ultimately decides the edit) - it never influences the gate decision, +# only records whatever that decision turns out to be at whichever exit below +# is actually reached. Empty (a no-op below) whenever $rel is not the index. +$transitions = @() +if ([string]::Equals($rel, $indexRel, [System.StringComparison]::OrdinalIgnoreCase)) { + $transitions = Get-SpecStatusTransitions -HookInput $hookInput -IndexPath (Join-Path $cwd $indexRel) +} + +if ($verifyGateOn -and [string]::Equals($rel, $indexRel, [System.StringComparison]::OrdinalIgnoreCase)) { + $indexAbs = Join-Path $cwd $indexRel + $doneIds = Get-DoneTransitionIds -HookInput $hookInput -IndexPath $indexAbs + if ($doneIds.Count -gt 0) { + $missing = New-Object System.Collections.Generic.List[string] + foreach ($id in $doneIds) { + if (-not (Test-VerifyArtifactPass -Cwd $cwd -SpecDir $specDir -SpecId $id)) { + $missing.Add($id) | Out-Null + } + } + if ($missing.Count -gt 0) { + # Ordinal sort (PS 5.1-safe), not culture-aware Sort-Object - matches + # `LC_ALL=C sort -u` in spec-gate.sh so both implementations order + # a multi-ID missing list identically regardless of host locale. + $missingArr = @($missing) + [Array]::Sort($missingArr, [System.StringComparer]::Ordinal) + $ids = $missingArr -join ', ' + Write-BlockDecision "spec-gate: index row(s) [$ids] -> done but no passing /sd:verify artifact. Run /sd:verify ; close-out is allowed only after $specDir//06-verify.md records 'result: pass'." + # Metrics are emitted AFTER the block decision above is already + # written to stdout - never inside the decision path itself. + # Ordinal-sort a COPY for the metric loop only, so a bundled + # multi-ID edit emits events in the same order as spec-gate.sh's + # `LC_ALL=C sort -u` transition_ids - this does not touch + # $doneIds itself or Get-DoneTransitionIds' own ordering. + $doneIdsForMetrics = @($doneIds) + [Array]::Sort($doneIdsForMetrics, [System.StringComparer]::Ordinal) + foreach ($id in $doneIdsForMetrics) { + $idDecision = if ($missing.Contains($id)) { 'block' } else { 'allow' } + Write-GateMetric -Cwd $cwd -Config $config -SpecId $id -Phase 'done' -Gate 'verify' -Decision $idDecision + } + Write-TransitionMetrics -Cwd $cwd -Config $config -Transitions $transitions -Decision 'block' + exit 0 + } + # Every transitioning spec has a passing artifact - allow the close-out. + $doneIdsForMetrics = @($doneIds) + [Array]::Sort($doneIdsForMetrics, [System.StringComparer]::Ordinal) + foreach ($id in $doneIdsForMetrics) { + Write-GateMetric -Cwd $cwd -Config $config -SpecId $id -Phase 'done' -Gate 'verify' -Decision 'allow' + } + Write-TransitionMetrics -Cwd $cwd -Config $config -Transitions $transitions -Decision 'allow' + exit 0 + } +} + # Rule 1: protected paths -> always block $protected = @() try { if ($config.paths.protected) { $protected = @($config.paths.protected) } } catch { } if (Test-IsProtected -RelPath $rel -Protected $protected) { Write-BlockDecision "spec-gate: '$rel' is listed under paths.protected in .claude/project-config.json. Update via /sd:refactor or an ADR; never edit directly." + Write-GateMetric -Cwd $cwd -Config $config -SpecId '-' -Phase '-' -Gate 'protected' -Decision 'block' + Write-TransitionMetrics -Cwd $cwd -Config $config -Transitions $transitions -Decision 'block' exit 0 } # Rule 2: allow-listed paths -> always allow -if (Test-IsAllowListed -RelPath $rel) { exit 0 } +if (Test-IsAllowListed -RelPath $rel) { + Write-TransitionMetrics -Cwd $cwd -Config $config -Transitions $transitions -Decision 'allow' + exit 0 +} # Rule 3: code file -> require in-progress spec if (Test-IsCodeFile -RelPath $rel) { + $ext = [System.IO.Path]::GetExtension($rel).ToLowerInvariant() $indexFile = if ($config.spec.indexFile) { Join-Path $cwd $config.spec.indexFile } else { Join-Path $cwd '.specs/index.md' } - $inProgress = Get-InProgressSpecs -IndexPath $indexFile + # @() forces a real array even when exactly one in-progress spec is + # found - PowerShell's pipeline otherwise unwraps a single-element + # List[string] into a bare string, which would make $inProgress[0] + # below silently index a CHARACTER of the id instead of the id itself. + $inProgress = @(Get-InProgressSpecs -IndexPath $indexFile) if ($inProgress.Count -eq 0) { $msg = "spec-gate: editing code file '$rel' but no in-progress spec is recorded in .specs/index.md. Run /sd:feature, /sd:bug, /sd:refactor, or /sd:perf first to create a spec, or set hooks.specGate.mode='off' in .claude/project-config.json to disable." if ($mode -eq 'block') { Write-BlockDecision $msg + Write-GateMetric -Cwd $cwd -Config $config -SpecId '-' -Phase '-' -Gate 'code-edit' -Decision 'block' -Ext $ext exit 0 } else { [Console]::Error.WriteLine("[WARN] $msg") + Write-GateMetric -Cwd $cwd -Config $config -SpecId '-' -Phase '-' -Gate 'code-edit' -Decision 'warn' -Ext $ext exit 0 } + } else { + # An in-progress spec exists - the edit is allowed. Recording the + # allow (not just the block/warn paths) is the point: the ratio of + # allow to warn/block is what the retro loop measures. + Write-GateMetric -Cwd $cwd -Config $config -SpecId $inProgress[0] -Phase 'in-progress' -Gate 'code-edit' -Decision 'allow' -Ext $ext } } diff --git a/hooks/powershell/subagent-retro.ps1 b/hooks/powershell/subagent-retro.ps1 index 86e6125..d5d314d 100644 --- a/hooks/powershell/subagent-retro.ps1 +++ b/hooks/powershell/subagent-retro.ps1 @@ -49,18 +49,226 @@ function Get-ProjectConfig { retroStaleMinutes = 30 debounceMinutes = 10 } + metrics = [pscustomobject]@{ enabled = $true; path = '.specs/_metrics/events.jsonl'; maxSizeKb = 1024 } } } $cfgPath = Join-Path $Cwd '.claude/project-config.json' if (-not (Test-Path -LiteralPath $cfgPath)) { return $defaults } + # -ErrorAction Stop is required: the script-wide SilentlyContinue preference + # would otherwise make a malformed config a NON-terminating error, so the + # catch never fires and the function returns $null instead of the defaults. try { - return (Get-Content -LiteralPath $cfgPath -Raw -Encoding UTF8 | ConvertFrom-Json) + $loaded = Get-Content -LiteralPath $cfgPath -Raw -Encoding UTF8 -ErrorAction Stop | + ConvertFrom-Json -ErrorAction Stop + if ($null -eq $loaded) { return $defaults } + return $loaded } catch { return $defaults } } +function Test-IsRootedPath { + param([string]$Path) + # Mirrors spec-gate.sh's rootedness test ("${fp}" != /* && "${fp}" != ?:*), + # evaluated on the RAW path (before backslash->slash conversion): a leading + # '/' or a drive-letter prefix like 'C:' is rooted, anything else - a plain + # relative path such as 'src/../.specs/constitution.md' - is not. + if ([string]::IsNullOrEmpty($Path)) { return $false } + if ($Path[0] -eq '/') { return $true } + if ($Path.Length -ge 2 -and $Path[1] -eq ':') { return $true } + return $false +} + +function ConvertTo-CollapsedPath { + param([string]$Path) + # Pure string-based collapse of '.' and '..' segments on a forward-slash + # path - no filesystem access, no .NET path resolution. This mirrors + # collapse_dot_segments in spec-gate.sh exactly, including: + # - a rooted path (leading '/' or a drive prefix 'C:/') clamps a '..' at + # its own root instead of walking above it; + # - an unrooted path with nothing to clamp against keeps an unresolved + # leading '..' rather than discarding it; + # - an empty segment (produced by '//' or by a TRAILING separator, e.g. + # ".specs/constitution.md/") is a no-op, same as a '.' segment - this + # is what makes a trailing separator collapse away instead of + # defeating the later exact-match comparison against paths.protected. + $rootPrefix = '' + $body = $Path + $rooted = $false + if ($Path.StartsWith('/')) { + $rootPrefix = '/' + $body = $Path.Substring(1) + $rooted = $true + } elseif ($Path.Length -ge 2 -and $Path[1] -eq ':') { + $rootPrefix = $Path.Substring(0, 2) + '/' + $body = $Path.Substring(2) + if ($body.StartsWith('/')) { $body = $body.Substring(1) } + $rooted = $true + } + + $acc = '' + foreach ($seg in $body -split '/') { + if ($seg -eq '' -or $seg -eq '.') { + continue + } + if ($seg -eq '..') { + if ($acc -ne '') { + $lastSlash = $acc.LastIndexOf('/') + if ($lastSlash -ge 0) { $last = $acc.Substring($lastSlash + 1) } else { $last = $acc } + if ($last -eq '..') { + # Already-stacked leading '..' (unrooted overflow) - keep stacking. + $acc = "$acc/.." + } elseif ($lastSlash -ge 0) { + $acc = $acc.Substring(0, $lastSlash) + } else { + # acc was a single segment with no slash - pop to empty. + $acc = '' + } + } elseif ($rooted) { + # Cannot go above the root - drop it, matching the bash clamp. + } else { + # No root to clamp against - keep the unresolved '..'. + $acc = '..' + } + } else { + if ($acc -eq '') { $acc = $seg } else { $acc = "$acc/$seg" } + } + } + + return "$rootPrefix$acc" +} + +function Test-MetricsPathSafe { + param([string]$RelPath) + # A metrics path is not an arbitrary-write primitive: reject anything + # rooted (absolute, or a drive-letter path) or that escapes Cwd via '..' + # rather than ever writing outside the workspace. Reuses the same + # rootedness test and dot-segment collapse used for the gate's own path + # safety above, so the two safety checks cannot silently diverge. + if ([string]::IsNullOrWhiteSpace($RelPath)) { return $false } + if (Test-IsRootedPath -Path $RelPath) { return $false } + $collapsed = ConvertTo-CollapsedPath -Path ($RelPath.Replace('\','/')) + if ($collapsed -eq '..' -or $collapsed.StartsWith('../')) { return $false } + return $true +} + +function Write-MetricEvent { + param( + [string]$Cwd, + [object]$Config, + [string]$SpecId, + [string]$Phase, + [string]$EventKind, + [System.Collections.Specialized.OrderedDictionary]$Fields + ) + # Fully wrapped: a metrics failure must NEVER surface as a hook error or + # change a gate decision. Every call site invokes this AFTER the decision + # is already computed (and, for a block, already written to stdout) - + # never from inside the decision path itself. + try { + $enabled = $true + # Type-strict: only a literal JSON boolean false disables metrics - + # copies the verifyGate `-is [bool]` pattern above so a string + # "false" in project-config.json leaves metrics on, matching + # spec-gate.sh's `== false` jq comparison. + if (($Config.hooks.metrics.enabled -is [bool]) -and (-not $Config.hooks.metrics.enabled)) { + $enabled = $false + } + if (-not $enabled) { return } + + $relPath = '.specs/_metrics/events.jsonl' + try { if ($Config.hooks.metrics.path) { $relPath = [string]$Config.hooks.metrics.path } } catch { } + $relPath = $relPath.Replace('\','/') + + if (-not (Test-MetricsPathSafe -RelPath $relPath)) { return } + + $fullPath = Join-Path $Cwd $relPath + $parent = Split-Path -Path $fullPath -Parent + if (-not (Test-Path -LiteralPath $parent)) { + New-Item -ItemType Directory -Path $parent -Force -ErrorAction Stop | Out-Null + } + + # [ordered] (not a plain hashtable) so ConvertTo-Json emits keys in + # the exact insertion order below - a plain hashtable does not + # guarantee enumeration order, which would let the two + # implementations drift apart on key order for the same input. + $ordered = [ordered]@{} + $ordered['ts'] = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + $ordered['spec_id'] = $SpecId + $ordered['phase'] = $Phase + $ordered['event'] = $EventKind + foreach ($key in $Fields.Keys) { $ordered[$key] = $Fields[$key] } + + $line = ([pscustomobject]$ordered) | ConvertTo-Json -Compress + + # --- rotation (SW-15) -------------------------------------------------- + # Bounded log: before appending, if the live file already meets or + # exceeds the byte cap, roll it to '.1' (single generation, overwriting + # any prior roll). maxSizeKb defaults to 1024 when the key is ABSENT, so + # a project-config.json written before SW-15 still gets a bounded log + # with no edit; an explicit 0 or negative disables rotation (the opt-out) + # and any non-number is invalid and also disables it (SW-22 scar - never + # let a bad type silently flip behavior). Best-effort - the Move-Item has + # its own -ErrorAction Stop / catch so a file the other hook holds open + # on Windows, or a read-only dir, is a silent no-op that falls through to + # the append below: rotation must NEVER stop the append (silent data loss + # reads as "metrics working", which the ticket flags as worse than + # growth) nor surface as a hook error. (Get-Item).Length is the raw byte + # count matching bash's `wc -c`, and the absent->1024 / bad-type->off + # rules match subagent-retro.sh's jq, so PS and bash trip at the same + # boundary. + try { + $maxKb = 1024 + $m = $Config.hooks.metrics + if (($null -ne $m) -and ($m.PSObject.Properties.Name -contains 'maxSizeKb')) { + $maxKb = $m.maxSizeKb + } + if (($maxKb -is [int] -or $maxKb -is [long] -or $maxKb -is [double]) -and ($maxKb -gt 0)) { + $maxBytes = [long][math]::Floor([double]$maxKb * 1024) + if (Test-Path -LiteralPath $fullPath) { + if ((Get-Item -LiteralPath $fullPath).Length -ge $maxBytes) { + Move-Item -LiteralPath $fullPath -Destination "$fullPath.1" -Force -ErrorAction Stop + } + } + } + } catch { } + + # Add-Content opens and closes the file handle per call and is not + # safe against the concurrent PreToolUse + SubagentStop appends this + # log can see; retry briefly on a transient sharing violation instead + # of failing loudly. + $attempt = 0 + while ($attempt -lt 5) { + try { + # UTF8Encoding($false): NO byte-order-mark. [Encoding]::UTF8 + # writes a BOM preamble on the FIRST write to a new/empty + # file, which subagent-retro.sh's plain `>>` append never + # does - that would make the two implementations' first line + # differ by 3 bytes for the exact same input. + [System.IO.File]::AppendAllText($fullPath, "$line`n", (New-Object System.Text.UTF8Encoding($false))) + break + } catch { + $attempt++ + if ($attempt -ge 5) { break } + Start-Sleep -Milliseconds 20 + } + } + } catch { } +} + +function Write-SubagentStopMetric { + param( + [string]$Cwd, + [object]$Config, + [string]$SpecId, + [string]$Phase, + [int]$Stale + ) + $fields = [ordered]@{ stale = $Stale } + Write-MetricEvent -Cwd $Cwd -Config $Config -SpecId $SpecId -Phase $Phase -EventKind 'subagent_stop' -Fields $fields +} + function Get-IndexSpecs { param([string]$IndexPath) # Returns array of objects: @{Id, Type, Status} @@ -112,24 +320,50 @@ function Get-StaleRetros { return $stale } -function Test-DebounceElapsed { - param( - [string]$StatePath, - [int]$DebounceMinutes - ) - if (-not (Test-Path -LiteralPath $StatePath)) { return $true } +# Read once, written once - mirrors the single state read in subagent-retro.sh. +# The state file is an ON-DISK CONTRACT shared with that twin and carries two +# keys: lastReminderUtc (debounce stamp) and shownLessons (lesson lines already +# surfaced in THIS session). Both writers must preserve the key they are not +# updating, or a reminder would wipe the session's lesson history and every +# lesson would repeat. +function Read-State { + param([string]$StatePath) + + $result = [pscustomobject]@{ + LastIso = '' + Shown = (New-Object 'System.Collections.Generic.List[string]') + } + if (-not (Test-Path -LiteralPath $StatePath)) { return $result } try { $st = Get-Content -LiteralPath $StatePath -Raw -Encoding UTF8 | ConvertFrom-Json # PowerShell 7's ConvertFrom-Json auto-converts an ISO-8601 "...Z" string to a # [datetime] with Kind=Utc; PowerShell 5.1 leaves it as a plain string. Re-Parse-ing # an already-converted [datetime] stringifies it with the local culture (dropping the - # UTC marker), so [datetimeoffset]::Parse silently re-interprets it as local time - - # skewing $age by the machine's UTC offset. Only Parse when it is still a string. - if ($st.lastReminderUtc -is [datetime]) { - $last = $st.lastReminderUtc.ToUniversalTime() - } else { - $last = [datetimeoffset]::Parse([string]$st.lastReminderUtc).UtcDateTime + # UTC marker), so a later Parse silently re-interprets it as local time - skewing the + # debounce by the machine's UTC offset. Normalise back to the on-disk format here so + # everything downstream sees the same plain string the bash twin sees. + if ($null -ne $st.lastReminderUtc) { + if ($st.lastReminderUtc -is [datetime]) { + $result.LastIso = $st.lastReminderUtc.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + } else { + $result.LastIso = [string]$st.lastReminderUtc + } } + if ($null -ne $st.shownLessons) { + foreach ($s in $st.shownLessons) { [void]$result.Shown.Add([string]$s) } + } + } catch { } + return $result +} + +function Test-DebounceElapsed { + param( + [string]$LastIso, + [int]$DebounceMinutes + ) + if ([string]::IsNullOrWhiteSpace($LastIso)) { return $true } + try { + $last = [datetimeoffset]::Parse($LastIso).UtcDateTime $age = (Get-Date).ToUniversalTime() - $last return ($age.TotalMinutes -ge $DebounceMinutes) } catch { @@ -138,7 +372,11 @@ function Test-DebounceElapsed { } function Save-State { - param([string]$StatePath) + param( + [string]$StatePath, + [string]$LastIso, + $Shown + ) try { # -Path, not -LiteralPath: some PowerShell builds reject -LiteralPath combined # with -Parent as an unresolvable parameter set. -Parent does no filesystem @@ -147,11 +385,69 @@ function Save-State { if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } - $obj = [pscustomobject]@{ lastReminderUtc = (Get-Date).ToUniversalTime().ToString('o') } - $obj | ConvertTo-Json -Compress | Set-Content -LiteralPath $StatePath -Encoding UTF8 + # Key order matches the jq object written by subagent-retro.sh. The + # whole-second UTC format must stay identical in both - 'o' was writing + # 7 fractional digits that only the bash side ever had to cope with. + $arr = @() + if ($null -ne $Shown) { $arr = @($Shown) } + if ([string]::IsNullOrEmpty($LastIso)) { + $obj = [pscustomobject]@{ shownLessons = $arr } + } else { + $obj = [pscustomobject]@{ lastReminderUtc = $LastIso; shownLessons = $arr } + } + $obj | ConvertTo-Json -Compress -Depth 3 | Set-Content -LiteralPath $StatePath -Encoding UTF8 } catch { } } +# Lesson selection (SW-19). Returns the lines to surface, in file order. +# +# lessons.md is rendered in a total, deterministic order by aggregate-lessons.*, +# so "the first N that match" is itself deterministic - no sorting is done or +# needed here, and there is no tie-break that could diverge from the bash twin. +# All comparisons are ORDINAL: PowerShell's default -eq is case-insensitive and +# would drop a lesson the bash twin keeps. +function Select-Lessons { + param( + [string]$LessonsPath, + $Specs, + [int]$MaxLessons, + $Shown + ) + $picked = New-Object 'System.Collections.Generic.List[string]' + if ($MaxLessons -le 0) { return $picked } + if (-not (Test-Path -LiteralPath $LessonsPath -PathType Leaf)) { return $picked } + + # Scope selector: the workflow types currently in flight, plus 'all'. + $wanted = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::Ordinal) + [void]$wanted.Add('all') + foreach ($spec in $Specs) { + switch ($spec.Type) { + 'FEAT' { [void]$wanted.Add('feature') } + 'BUG' { [void]$wanted.Add('bug') } + 'REF' { [void]$wanted.Add('refactor') } + 'PERF' { [void]$wanted.Add('perf') } + 'RCA' { [void]$wanted.Add('rca') } + } + } + + $shownSet = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::Ordinal) + if ($null -ne $Shown) { foreach ($s in $Shown) { [void]$shownSet.Add([string]$s) } } + + $lessonRe = '^- \[([a-z-]+)\] ([a-z]+)/([a-z]+): (.+)$' + try { + foreach ($line in (Get-Content -LiteralPath $LessonsPath)) { + if (-not $line.StartsWith('- [')) { continue } + if ($line -cnotmatch $lessonRe) { continue } + if (-not $wanted.Contains($Matches[3])) { continue } + if ($shownSet.Contains($line)) { continue } + + [void]$picked.Add($line) + if ($picked.Count -ge $MaxLessons) { break } + } + } catch { } + return $picked +} + function Remove-StaleStateFiles { param([string]$StateDir) if (-not (Test-Path -LiteralPath $StateDir)) { return } @@ -180,31 +476,112 @@ if ([string]::IsNullOrWhiteSpace($sessionId)) { $sessionId = 'no-session' } $config = Get-ProjectConfig -Cwd $cwd try { - if ($null -ne $config.hooks -and $null -ne $config.hooks.subagentRetro -and -not $config.hooks.subagentRetro.enabled) { + # Type-strict: only a literal JSON boolean false disables the hook. A plain + # `-not ...enabled` fires on an ABSENT key ($null), silently disabling the + # hook when a hand-trimmed config carries a subagentRetro block with no + # `enabled` - diverging from subagent-retro.sh's `== false`, which leaves it + # on. -is [bool] matches jq (SW-22); mirrors the metrics/injectLessons reads. + if (($config.hooks.subagentRetro.enabled -is [bool]) -and (-not $config.hooks.subagentRetro.enabled)) { exit 0 } } catch { } $staleMinutes = 30 $debounceMinutes = 10 -try { if ($config.hooks.subagentRetro.retroStaleMinutes) { $staleMinutes = [int]$config.hooks.subagentRetro.retroStaleMinutes } } catch { } -try { if ($config.hooks.subagentRetro.debounceMinutes) { $debounceMinutes = [int]$config.hooks.subagentRetro.debounceMinutes } } catch { } +# `$null -ne`, NOT a truthiness test: PowerShell treats 0 as falsy, so +# `if ($config...retroStaleMinutes)` would silently ignore an explicit 0 and keep +# the default while the bash twin's `// 30` / `// 10` accept 0. Mirrors the +# maxLessons read that SW-19 already fixed for exactly this reason (SW-22). +try { if ($null -ne $config.hooks.subagentRetro.retroStaleMinutes) { $staleMinutes = [int]$config.hooks.subagentRetro.retroStaleMinutes } } catch { } +try { if ($null -ne $config.hooks.subagentRetro.debounceMinutes) { $debounceMinutes = [int]$config.hooks.subagentRetro.debounceMinutes } } catch { } + +# Lesson injection (SW-19). Same explicit-false handling as the enabled flag: +# an absent key means on, only a literal false turns it off. +$injectLessons = $true +$maxLessons = 3 +try { if ($null -ne $config.hooks.subagentRetro.injectLessons -and -not $config.hooks.subagentRetro.injectLessons) { $injectLessons = $false } } catch { } +# `$null -ne`, NOT a truthiness test: PowerShell treats 0 as falsy, so +# `if ($config...maxLessons)` would silently ignore an explicit 0 and keep the +# default of 3 while the bash twin's `// 3` accepts 0 and goes quiet. A +# non-numeric or negative value falls back to 3 in both, matching the bash +# `^[0-9]+$` guard. +try { + if ($null -ne $config.hooks.subagentRetro.maxLessons) { + $maxLessons = [int]$config.hooks.subagentRetro.maxLessons + } +} catch { $maxLessons = 3 } +if ($maxLessons -lt 0) { $maxLessons = 3 } $specDir = if ($config.spec.dir) { Join-Path $cwd $config.spec.dir } else { Join-Path $cwd '.specs' } $indexFile = if ($config.spec.indexFile) { Join-Path $cwd $config.spec.indexFile } else { Join-Path $cwd '.specs/index.md' } -$stateDir = Join-Path $cwd '.claude/.hookstate' -$safeId = ($sessionId -replace '[^A-Za-z0-9_\-]','_') -$statePath = Join-Path $stateDir ("subagent-retro-$safeId.json") +$stateDir = Join-Path $cwd '.claude/.hookstate' +$safeId = ($sessionId -replace '[^A-Za-z0-9_\-]','_') +$statePath = Join-Path $stateDir ("subagent-retro-$safeId.json") +$lessonsPath = Join-Path $specDir (Join-Path '_lessons' 'lessons.md') Remove-StaleStateFiles -StateDir $stateDir +$state = Read-State -StatePath $statePath + $specs = Get-IndexSpecs -IndexPath $indexFile if ($specs.Count -eq 0) { exit 0 } $stale = Get-StaleRetros -SpecDir $specDir -Specs $specs -StaleMinutes $staleMinutes + +# Metrics: one subagent_stop event per in-progress spec, emitted regardless +# of staleness or debounce - debounce below only suppresses the user-facing +# reminder, never this measurement (SW-10). A spec not in $stale (including +# every RCA, which Get-StaleRetros always skips) reports stale=0. +$staleIds = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::Ordinal) +foreach ($s in $stale) { [void]$staleIds.Add($s.Id) } +foreach ($spec in $specs) { + $staleCount = if ($staleIds.Contains($spec.Id)) { 1 } else { 0 } + Write-SubagentStopMetric -Cwd $cwd -Config $config -SpecId $spec.Id -Phase 'in-progress' -Stale $staleCount +} + +# --- lesson injection (SW-19) --- +# +# PLACEMENT IS LOAD-BEARING. This sits beside the metrics emit above, BEFORE the +# staleness early-exit and BEFORE the debounce window - deliberately, and for the +# same reason the metrics call site does. Moved down to the reminder block, it +# would only ever surface lessons to users who are already behind on their +# retros, which is exactly the population that needs them least. +# +# The one gate it keeps is the in-progress-spec check above: no spec in flight, +# no output. That gate IS the relevance filter - the workflow type of the +# in-progress spec selects which lessons apply. +# +# Repetition is bounded per SESSION, not by a clock. shownLessons records what +# has already been surfaced, so maxLessons caps how many NEW lessons appear at +# one stop and a session converges to silence once it has said everything +# relevant. A time debounce was rejected: it would suppress a lesson the user +# has never seen purely because a different one was shown recently. +if ($injectLessons) { + $picked = Select-Lessons -LessonsPath $lessonsPath -Specs $specs -MaxLessons $maxLessons -Shown $state.Shown + if ($picked.Count -gt 0) { + $lessonLines = New-Object System.Collections.Generic.List[string] + $lessonLines.Add('') | Out-Null + $lessonLines.Add('Lessons recorded in earlier retros of this project, matching the workflow') | Out-Null + $lessonLines.Add('type of the spec(s) currently in progress:') | Out-Null + foreach ($p in $picked) { $lessonLines.Add(" $p") | Out-Null } + $lessonLines.Add('') | Out-Null + $lessonLines.Add('These are not shown again this session.') | Out-Null + $lessonLines.Add('') | Out-Null + + # Write, not WriteLine: WriteLine appends [Environment]::NewLine, which is + # CRLF on Windows, so the final line would differ from the bash twin by + # exactly one byte and fail the conformance comparison. The body is + # already LF-joined; terminate it the same way. + [Console]::Out.Write(($lessonLines -join "`n") + "`n") + + foreach ($p in $picked) { [void]$state.Shown.Add($p) } + Save-State -StatePath $statePath -LastIso $state.LastIso -Shown $state.Shown + } +} + if ($stale.Count -eq 0) { exit 0 } -if (-not (Test-DebounceElapsed -StatePath $statePath -DebounceMinutes $debounceMinutes)) { exit 0 } +if (-not (Test-DebounceElapsed -LastIso $state.LastIso -DebounceMinutes $debounceMinutes)) { exit 0 } # Emit reminder $lines = New-Object System.Collections.Generic.List[string] @@ -221,6 +598,12 @@ $lines.Add('') | Out-Null $lines.Add('Consider appending: decisions made, surprises encountered, follow-ups identified.') | Out-Null $lines.Add('') | Out-Null -[Console]::Out.WriteLine($lines -join "`n") -Save-State -StatePath $statePath +# Write, not WriteLine - see the note on the lesson block above. This block had +# the same one-byte CRLF divergence from the bash twin before SW-19. +[Console]::Out.Write(($lines -join "`n") + "`n") + +# Re-emits shownLessons alongside the new stamp - dropping it here would clear +# the session's lesson history and make every lesson repeat. +$stamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') +Save-State -StatePath $statePath -LastIso $stamp -Shown $state.Shown exit 0 diff --git a/install/README.md b/install/README.md index 3a376f1..7f14fab 100644 --- a/install/README.md +++ b/install/README.md @@ -4,11 +4,11 @@ The installer copies the engine (commands, agents, hooks, templates) into a Clau ``` / -โ”œโ”€โ”€ commands/sd/ 11 slash commands +โ”œโ”€โ”€ commands/sd/ 13 slash commands โ”œโ”€โ”€ agents/sd/ 6 subagent definitions โ”œโ”€โ”€ hooks/sd/ 3 hook scripts (.ps1 on Windows, .sh on Unix) โ”œโ”€โ”€ templates/sd/ 9 templates (4 setup + 5 spec) -โ””โ”€โ”€ skills/sd/ 6 skills (one folder per skill with SKILL.md) +โ””โ”€โ”€ skills/sd/ 8 skills (one folder per skill with SKILL.md) ``` Default base is `$HOME/.claude` (Unix) or `$env:USERPROFILE\.claude` (Windows). @@ -46,14 +46,14 @@ Default base is `$HOME/.claude` (Unix) or `$env:USERPROFILE\.claude` (Windows). | Source (in repo) | Target (under `/`) | Files | Notes | |---|---|---|---| -| `commands/` | `commands/sd/` | 11 | `feature`, `bug`, `rca`, `refactor`, `perf`, `spec`, `explore`, `review`, `setup`, `release`, `adr` | +| `commands/` | `commands/sd/` | 13 | `feature`, `bug`, `rca`, `refactor`, `perf`, `spec`, `explore`, `review`, `setup`, `release`, `adr`, `verify`, `status` | | `agents/` | `agents/sd/` | 6 | `sd-spec-architect`, `sd-code-explorer`, `sd-debugger`, `sd-implementer`, `sd-reviewer`, `sd-docs-writer` | | `hooks/powershell/` (Windows installer) | `hooks/sd/` | 3 | `prompt-router.ps1`, `spec-gate.ps1`, `subagent-retro.ps1` | | `hooks/bash/` (Unix installer) | `hooks/sd/` | 3 | `prompt-router.sh`, `spec-gate.sh`, `subagent-retro.sh` (chmod +x applied) | | `templates/` | `templates/sd/` | 4 + 5 | Setup templates + `specs/` subfolder with 5 spec templates | -| `skills/` | `skills/sd/` | 6 | One folder per skill, each with a `SKILL.md` | +| `skills/` | `skills/sd/` | 8 | One folder per skill, each with a `SKILL.md` | -**Total**: 35 files per OS. +**Total**: 39 files per OS. --- @@ -81,7 +81,7 @@ After install, check the target directories: **Windows:** ```powershell -Get-ChildItem $env:USERPROFILE\.claude\commands\sd\ # expect 11 .md files +Get-ChildItem $env:USERPROFILE\.claude\commands\sd\ # expect 12 .md files Get-ChildItem $env:USERPROFILE\.claude\agents\sd\ # expect 6 .md files Get-ChildItem $env:USERPROFILE\.claude\hooks\sd\ # expect 3 .ps1 files Get-ChildItem $env:USERPROFILE\.claude\templates\sd\ # expect 4 files + specs\ folder @@ -89,7 +89,7 @@ Get-ChildItem $env:USERPROFILE\.claude\templates\sd\ # expect 4 files + specs **Unix:** ```bash -ls ~/.claude/commands/sd/ # 11 .md files +ls ~/.claude/commands/sd/ # 13 .md files ls ~/.claude/agents/sd/ # 6 .md files ls ~/.claude/hooks/sd/ # 3 .sh files (executable) ls -l ~/.claude/hooks/sd/ # confirm +x bits set diff --git a/scripts/aggregate-lessons.ps1 b/scripts/aggregate-lessons.ps1 new file mode 100644 index 0000000..f2ac285 --- /dev/null +++ b/scripts/aggregate-lessons.ps1 @@ -0,0 +1,265 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + specwright: lesson aggregator (Windows / PowerShell). + +.DESCRIPTION + Mirror of scripts/aggregate-lessons.sh - both must emit byte-identical + output for the same corpus. SW-18, under epic SW-7. + + Reads every /*/05-retro.md, extracts well-formed lesson lines (the + grammar in skills/sd-retro-lessons/SKILL.md), dedupes them, and renders + /_lessons/lessons.md. + + -Check writes nothing and exits 1 if the rendered output differs from what + is already on disk. That is how idempotence is asserted in CI. + + TWO DESIGN DECISIONS worth knowing before editing: + + 1. The RETROS are append-only; lessons.md is a DERIVED file, fully + regenerated on every run. SW-18 originally called lessons.md itself + append-only, but dedupe-with-a-count requires rewriting the line, so + append-only and idempotent are mutually exclusive. Regenerating makes + idempotence a property of the design rather than something to defend. + + 2. Abstraction is NOT done here. Turning a retro note into an + identifier-free rule is judgement work and belongs to the + sd-retro-lessons skill, which writes tagged lines into 05-retro.md. + This script only collects, dedupes and orders - no judgement, so the + output is reproducible. + + PARITY: every comparison and sort in this file is ORDINAL. PowerShell's + default string handling is culture-aware and case-insensitive - Sort-Object, + hashtable keys and -eq would all silently diverge from the bash twin's + LC_ALL=C byte ordering. Output is written as UTF-8 without BOM and with LF + line endings, because Set-Content would emit CRLF and break the byte + comparison on Windows. + + PURE ASCII. Scanned by validate.ps1 Check 1. + +.EXAMPLE + .\scripts\aggregate-lessons.ps1 + .\scripts\aggregate-lessons.ps1 -SpecDir tests\lessons\fixtures\corpus -Out out.md -Check +#> + +[CmdletBinding()] +param( + [string] $SpecDir = '.specs', + [string] $Out = '', + [switch] $Check +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# Enum order, NOT alphabetical - this array defines the section order in the +# rendered file, and it is duplicated in aggregate-lessons.sh and the enum +# table in skills/sd-retro-lessons/SKILL.md. All three must agree. +$TAGS = @( + 'sibling-repo-assumption', 'missed-context', 'baseline-attribution', + 'tooling-surprise', 'gate-friction', 'config-drift', + 'test-fragility', 'test-gap', 'precedent-conflict', 'scope-discipline' +) + +$SEVERITIES = @('high', 'medium', 'low') +$SCOPES = @('feature', 'bug', 'refactor', 'perf', 'rca', 'all') + +$LESSON_RE = '^- \[([a-z-]+)\] ([a-z]+)/([a-z]+): (.+)$' +$COUNT_RE = '^(.*) \([0-9]+\)$' + +function Write-Section { param([string]$Title) Write-Host ''; Write-Host "=== $Title ===" -ForegroundColor Cyan } +function Write-Ok { param([string]$m) Write-Host " [OK] $m" -ForegroundColor Green } +function Write-FailMsg { param([string]$m) Write-Host " [FAIL] $m" -ForegroundColor Red } + +if ([string]::IsNullOrEmpty($Out)) { + $Out = Join-Path (Join-Path $SpecDir '_lessons') 'lessons.md' +} + +function Get-OrdinalIndex { + param([string] $Needle, [string[]] $Haystack) + + for ($i = 0; $i -lt $Haystack.Count; $i++) { + if ([string]::CompareOrdinal($Haystack[$i], $Needle) -eq 0) { return $i } + } + return -1 +} + +# Dedupe identity. Case and spacing differences are not different lessons, and +# a trailing period is punctuation, not meaning. +function Get-NormalizedRule { + param([string] $Rule) + + $r = $Rule.ToLowerInvariant() + $r = [regex]::Replace($r, '\s+', ' ') + $r = $r.Trim() + if ($r.EndsWith('.')) { $r = $r.Substring(0, $r.Length - 1) } + return $r +} + +Write-Section 'specwright aggregate-lessons' +Write-Host " Spec dir: $SpecDir" +Write-Host " Output: $Out" + +if (-not (Test-Path -LiteralPath $SpecDir -PathType Container)) { + Write-FailMsg "spec dir not found: $SpecDir" + exit 1 +} + +# ---- collect ---------------------------------------------------------------- + +# Ordinal comparer: the default hashtable is case-insensitive, which would merge +# two lessons the bash twin keeps apart. +$groups = New-Object 'System.Collections.Generic.Dictionary[string,object]' ([System.StringComparer]::Ordinal) + +$retroCount = 0 +$skipped = 0 + +# Sorted so the traversal itself is deterministic. Nothing downstream depends on +# file order (the sort below is total), but a stable walk keeps the skipped +# counter reproducible too. +$retros = @(Get-ChildItem -LiteralPath $SpecDir -Directory | + ForEach-Object { Join-Path $_.FullName '05-retro.md' } | + Where-Object { Test-Path -LiteralPath $_ -PathType Leaf }) + +$retros = @($retros | Sort-Object -Property { $_ } -CaseSensitive) + +foreach ($retro in $retros) { + $retroCount++ + + foreach ($line in (Get-Content -LiteralPath $retro)) { + if (-not $line.StartsWith('- [')) { continue } + + # Auto-generated transition lines written by /sd:spec status and + # /sd:release open with "- [" too (they carry a timestamp in the + # brackets) but never match the lesson grammar, so they fall out here + # rather than needing a rule of their own. + if ($line -cnotmatch $LESSON_RE) { + $skipped++ + continue + } + + $tag = $Matches[1] + $severity = $Matches[2] + $scope = $Matches[3] + $rule = $Matches[4] + if ($rule -cmatch $COUNT_RE) { $rule = $Matches[1] } + + $tagIdx = Get-OrdinalIndex -Needle $tag -Haystack $TAGS + $sevIdx = Get-OrdinalIndex -Needle $severity -Haystack $SEVERITIES + $scopeIdx = Get-OrdinalIndex -Needle $scope -Haystack $SCOPES + if ($tagIdx -lt 0 -or $sevIdx -lt 0 -or $scopeIdx -lt 0) { + $skipped++ + continue + } + + $norm = Get-NormalizedRule -Rule $rule + $key = "$tagIdx" + [char]0x1f + "$scopeIdx" + [char]0x1f + $norm + + if ($groups.ContainsKey($key)) { + $g = $groups[$key] + # Severity and surviving wording are resolved INDEPENDENTLY. Tying + # them together means the sloppier phrasing wins whenever it happens + # to carry the lower severity. + # severity -> least severe seen (largest rank): never promote. + # wording -> byte-smallest seen: stable, and ASCII puts a proper + # capitalised sentence ahead of a lowercase one. + if ($sevIdx -gt $g.SevIdx) { $g.SevIdx = $sevIdx } + if ([string]::CompareOrdinal($rule, $g.Rule) -lt 0) { $g.Rule = $rule } + $g.Count++ + } + else { + $groups[$key] = [pscustomobject]@{ + TagIdx = $tagIdx + ScopeIdx = $scopeIdx + SevIdx = $sevIdx + Rule = $rule + Tag = $tag + Count = 1 + } + } + } +} + +# ---- order ------------------------------------------------------------------ +# +# Sort-Object is culture-aware; an explicit ordinal comparison is the only way +# to match the bash twin's LC_ALL=C sort. This is the whole parity risk of the +# story, so it is done by hand rather than delegated. + +$ordered = New-Object 'System.Collections.Generic.List[object]' +foreach ($g in $groups.Values) { [void]$ordered.Add($g) } + +$comparison = [System.Comparison[object]] { + param($a, $b) + if ($a.TagIdx -ne $b.TagIdx) { return $a.TagIdx - $b.TagIdx } + if ($a.SevIdx -ne $b.SevIdx) { return $a.SevIdx - $b.SevIdx } + return [string]::CompareOrdinal($a.Rule, $b.Rule) +} +$ordered.Sort($comparison) + +# ---- render ----------------------------------------------------------------- + +$lines = New-Object 'System.Collections.Generic.List[string]' +[void]$lines.Add('# Lessons') +[void]$lines.Add('') +[void]$lines.Add('GENERATED FILE - do not edit by hand. Regenerate with') +[void]$lines.Add('`scripts/aggregate-lessons.sh`; edits are lost on the next run.') +[void]$lines.Add('') +[void]$lines.Add('Every rule below is written to be free of identifiers - no paths, file names,') +[void]$lines.Add('line numbers, class or variable names - so this file can be shared outside the') +[void]$lines.Add('organisation as-is. That contract is enforced by `scripts/validate-lessons.*`') +[void]$lines.Add('and is the reason a lesson reads as a general rule rather than a bug report.') +[void]$lines.Add('') +[void]$lines.Add('A trailing count is the number of retros a lesson was drawn from. Frequency') +[void]$lines.Add('never raises severity.') + +$currentTag = '' +foreach ($g in $ordered) { + if ([string]::CompareOrdinal($g.Tag, $currentTag) -ne 0) { + [void]$lines.Add('') + [void]$lines.Add('## ' + $g.Tag) + [void]$lines.Add('') + $currentTag = $g.Tag + } + $severity = $SEVERITIES[$g.SevIdx] + $scope = $SCOPES[$g.ScopeIdx] + $text = '- [' + $g.Tag + '] ' + $severity + '/' + $scope + ': ' + $g.Rule + if ($g.Count -gt 1) { $text = $text + ' (' + $g.Count + ')' } + [void]$lines.Add($text) +} + +# LF, not CRLF, and a trailing newline - Set-Content would emit CRLF on Windows +# and the byte comparison against the bash twin would fail. +$rendered = ($lines -join "`n") + "`n" + +# ---- write or check --------------------------------------------------------- + +$lessonCount = $ordered.Count + +if ($Check) { + $existing = $null + if (Test-Path -LiteralPath $Out -PathType Leaf) { + $existing = [System.IO.File]::ReadAllText($Out) + } + if ($null -ne $existing -and [string]::CompareOrdinal($existing, $rendered) -eq 0) { + Write-Ok "$lessonCount lesson(s) from $retroCount retro(s); $Out is current" + exit 0 + } + Write-FailMsg "$Out is out of date - run without -Check to regenerate" + if ($null -eq $existing) { Write-Host ' (file does not exist)' } + exit 1 +} + +$outDir = Split-Path -Parent $Out +if (-not [string]::IsNullOrEmpty($outDir) -and -not (Test-Path -LiteralPath $outDir)) { + New-Item -ItemType Directory -Path $outDir -Force | Out-Null +} + +$utf8NoBom = New-Object System.Text.UTF8Encoding($false) +[System.IO.File]::WriteAllText($Out, $rendered, $utf8NoBom) + +Write-Ok "$lessonCount lesson(s) from $retroCount retro(s) -> $Out" +if ($skipped -gt 0) { + Write-Host " $skipped non-lesson line(s) skipped (transition logs, unknown tag/severity/scope)" +} +exit 0 diff --git a/scripts/aggregate-lessons.sh b/scripts/aggregate-lessons.sh new file mode 100644 index 0000000..b0a506c --- /dev/null +++ b/scripts/aggregate-lessons.sh @@ -0,0 +1,270 @@ +#!/usr/bin/env bash +# specwright: lesson aggregator (Unix / bash). +# +# Mirror of scripts/aggregate-lessons.ps1 - both must emit byte-identical +# output for the same corpus. SW-18, under epic SW-7. +# +# bash scripts/aggregate-lessons.sh [--spec-dir DIR] [--out FILE] [--check] +# +# Reads every /*/05-retro.md, extracts well-formed lesson lines (the +# grammar in skills/sd-retro-lessons/SKILL.md), dedupes them, and renders +# /_lessons/lessons.md. +# +# --check writes nothing and exits 1 if the rendered output differs from what +# is already on disk. That is how idempotence is asserted in CI. +# +# TWO DESIGN DECISIONS worth knowing before editing: +# +# 1. The RETROS are append-only; lessons.md is a DERIVED file, fully +# regenerated on every run. SW-18 originally called lessons.md itself +# append-only, but dedupe-with-a-count requires rewriting the line, so +# append-only and idempotent are mutually exclusive. Regenerating makes +# idempotence a property of the design rather than something to defend. +# +# 2. Abstraction is NOT done here. Turning a retro note into an +# identifier-free rule is judgement work and belongs to the +# sd-retro-lessons skill, which writes tagged lines into 05-retro.md. +# This script only collects, dedupes and orders - no judgement, so the +# output is reproducible. +# +# Exit 0 = rendered (or already current under --check); 1 = --check found drift +# or an argument was invalid. + +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +SPEC_DIR=".specs" +OUT_FILE="" +CHECK_ONLY=0 + +# Enum order, NOT alphabetical - this array defines the section order in the +# rendered file, and it is duplicated in aggregate-lessons.ps1 and the enum +# table in skills/sd-retro-lessons/SKILL.md. All three must agree. +TAGS=( + sibling-repo-assumption + missed-context + baseline-attribution + tooling-surprise + gate-friction + config-drift + test-fragility + test-gap + precedent-conflict + scope-discipline +) + +SEVERITIES=(high medium low) +SCOPES=(feature bug refactor perf rca all) + +LESSON_RE='^- \[([a-z-]+)\] ([a-z]+)/([a-z]+): (.+)$' +COUNT_RE='^(.*) \([0-9]+\)$' + +if [[ -t 1 ]]; then + c_reset=$'\033[0m'; c_cyan=$'\033[36m'; c_green=$'\033[32m'; c_red=$'\033[31m' +else + c_reset=''; c_cyan=''; c_green=''; c_red='' +fi +section() { echo; echo "${c_cyan}=== $* ===${c_reset}"; } +ok() { echo " ${c_green}[OK]${c_reset} $*"; } +fail() { echo " ${c_red}[FAIL]${c_reset} $*"; } + +while [[ $# -gt 0 ]]; do + case "$1" in + --spec-dir) SPEC_DIR="$2"; shift 2 ;; + --out) OUT_FILE="$2"; shift 2 ;; + --check) CHECK_ONLY=1; shift ;; + -h|--help) sed -n '2,30p' "${BASH_SOURCE[0]}"; exit 0 ;; + *) fail "unknown argument: $1"; exit 1 ;; + esac +done + +[[ -n "$OUT_FILE" ]] || OUT_FILE="$SPEC_DIR/_lessons/lessons.md" + +index_of() { + local needle="$1"; shift + local i=0 item + for item in "$@"; do + if [[ "$item" == "$needle" ]]; then echo "$i"; return 0; fi + i=$((i + 1)) + done + echo "-1" +} + +# Dedupe identity. Case and spacing differences are not different lessons, and +# a trailing period is punctuation, not meaning. +normalize_rule() { + local r="$1" + r="$(printf '%s' "$r" | tr '[:upper:]' '[:lower:]')" + r="$(printf '%s' "$r" | tr -s '[:space:]' ' ')" + r="${r#"${r%%[![:space:]]*}"}" + r="${r%"${r##*[![:space:]]}"}" + r="${r%.}" + printf '%s' "$r" +} + +# ---- collect ---------------------------------------------------------------- + +section "specwright aggregate-lessons" +echo " Spec dir: $SPEC_DIR" +echo " Output: $OUT_FILE" + +if [[ ! -d "$SPEC_DIR" ]]; then + fail "spec dir not found: $SPEC_DIR" + exit 1 +fi + +raw="$(mktemp)" +grouped="$(mktemp)" +rendered="$(mktemp)" +cleanup() { rm -f "$raw" "$grouped" "$rendered"; } +trap cleanup EXIT + +retro_count=0 +skipped=0 + +# Sorted so the traversal itself is deterministic. Nothing downstream depends +# on file order (the sort below is total), but a stable walk keeps the skipped +# counter reproducible too. +while IFS= read -r retro; do + [[ -n "$retro" ]] || continue + retro_count=$((retro_count + 1)) + + while IFS= read -r line || [[ -n "$line" ]]; do + line="${line%$'\r'}" + [[ "$line" == "- ["* ]] || continue + + # Auto-generated transition lines written by /sd:spec status and + # /sd:release open with "- [" too (they carry a timestamp in the + # brackets) but never match the lesson grammar, so they fall out here + # rather than needing a rule of their own. + if [[ ! "$line" =~ $LESSON_RE ]]; then + skipped=$((skipped + 1)) + continue + fi + + tag="${BASH_REMATCH[1]}" + severity="${BASH_REMATCH[2]}" + scope="${BASH_REMATCH[3]}" + rule="${BASH_REMATCH[4]}" + if [[ "$rule" =~ $COUNT_RE ]]; then + rule="${BASH_REMATCH[1]}" + fi + + tag_idx="$(index_of "$tag" "${TAGS[@]}")" + sev_idx="$(index_of "$severity" "${SEVERITIES[@]}")" + scope_idx="$(index_of "$scope" "${SCOPES[@]}")" + if [[ "$tag_idx" == "-1" || "$sev_idx" == "-1" || "$scope_idx" == "-1" ]]; then + skipped=$((skipped + 1)) + continue + fi + + norm="$(normalize_rule "$rule")" + printf '%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$tag_idx" "$scope_idx" "$norm" "$sev_idx" "$rule" "$tag" >> "$raw" + done < "$retro" +done < <(find "$SPEC_DIR" -mindepth 2 -maxdepth 2 -type f -name '05-retro.md' 2>/dev/null | LC_ALL=C sort) + +# ---- dedupe ----------------------------------------------------------------- +# +# Identity is (tag, scope, normalized rule). +# +# SEVERITIES is ordered high, medium, low - so a LARGER rank index means a LESS +# severe lesson. Sorting rank descending (-k4,4nr) therefore puts the least +# severe row first, and the first row of each group wins. That is deliberate: a +# lesson that reappears gets a count, never a promotion (see the anti-patterns +# in sd-retro-lessons). Ties break on the original rule text so the surviving +# wording is fixed rather than dependent on which retro was read first. +# +# Every sort here is byte-wise via LC_ALL=C so bash and PowerShell agree. +# PowerShell's default Sort-Object is culture-aware and would diverge - that +# divergence is the whole parity risk of this story. + +if [[ -s "$raw" ]]; then + LC_ALL=C sort -t $'\t' -k1,1n -k2,2n -k3,3 -k4,4nr -k5,5 "$raw" \ + | LC_ALL=C awk -F '\t' ' + { + key = $1 "\x1f" $2 "\x1f" $3 + if (key != prev) { + if (prev != "") { print out_tagidx "\t" out_sev "\t" out_rule "\t" out_tag "\t" out_scopeidx "\t" count } + prev = key; count = 0 + out_tagidx = $1; out_scopeidx = $2; out_sev = $4; out_rule = $5; out_tag = $6 + } + # Severity and surviving wording are resolved INDEPENDENTLY. Tying + # them together means the sloppier phrasing wins whenever it happens + # to carry the lower severity, which is how the first draft kept an + # uncapitalised, unpunctuated variant over a well-formed one. + # severity -> least severe seen (largest rank): never promote. + # wording -> byte-smallest seen: stable, and ASCII puts a proper + # capitalised sentence ahead of a lowercase one. + if ($4 > out_sev) { out_sev = $4 } + if ($5 < out_rule) { out_rule = $5 } + count++ + } + END { if (prev != "") { print out_tagidx "\t" out_sev "\t" out_rule "\t" out_tag "\t" out_scopeidx "\t" count } } + ' | LC_ALL=C sort -t $'\t' -k1,1n -k2,2n -k3,3 > "$grouped" +else + : > "$grouped" +fi + +# ---- render ----------------------------------------------------------------- + +{ + echo '# Lessons' + echo '' + echo 'GENERATED FILE - do not edit by hand. Regenerate with' + echo '`scripts/aggregate-lessons.sh`; edits are lost on the next run.' + echo '' + echo 'Every rule below is written to be free of identifiers - no paths, file names,' + echo 'line numbers, class or variable names - so this file can be shared outside the' + echo 'organisation as-is. That contract is enforced by `scripts/validate-lessons.*`' + echo 'and is the reason a lesson reads as a general rule rather than a bug report.' + echo '' + echo 'A trailing count is the number of retros a lesson was drawn from. Frequency' + echo 'never raises severity.' + + current_tag='' + while IFS=$'\t' read -r tag_idx sev_idx rule tag scope_idx count; do + [[ -n "$tag" ]] || continue + if [[ "$tag" != "$current_tag" ]]; then + echo '' + echo "## $tag" + echo '' + current_tag="$tag" + fi + severity="${SEVERITIES[$sev_idx]}" + scope="${SCOPES[$scope_idx]}" + if [[ "$count" -gt 1 ]]; then + echo "- [$tag] $severity/$scope: $rule ($count)" + else + echo "- [$tag] $severity/$scope: $rule" + fi + done < "$grouped" +} > "$rendered" + +lesson_count="$(wc -l < "$grouped" | tr -d ' ')" + +# ---- write or check --------------------------------------------------------- + +if [[ "$CHECK_ONLY" -eq 1 ]]; then + if [[ -f "$OUT_FILE" ]] && diff -q "$OUT_FILE" "$rendered" >/dev/null 2>&1; then + ok "$lesson_count lesson(s) from $retro_count retro(s); $OUT_FILE is current" + exit 0 + fi + fail "$OUT_FILE is out of date - run without --check to regenerate" + if [[ -f "$OUT_FILE" ]]; then + diff "$OUT_FILE" "$rendered" | head -20 | sed 's/^/ /' || true + else + echo " (file does not exist)" + fi + exit 1 +fi + +mkdir -p "$(dirname "$OUT_FILE")" +cp "$rendered" "$OUT_FILE" + +ok "$lesson_count lesson(s) from $retro_count retro(s) -> $OUT_FILE" +if [[ "$skipped" -gt 0 ]]; then + echo " $skipped non-lesson line(s) skipped (transition logs, unknown tag/severity/scope)" +fi +exit 0 diff --git a/scripts/selftest-docs.ps1 b/scripts/selftest-docs.ps1 new file mode 100644 index 0000000..e4b98cc --- /dev/null +++ b/scripts/selftest-docs.ps1 @@ -0,0 +1,223 @@ +#requires -Version 5.1 +<# +.SYNOPSIS + Negative self-test for validate.ps1 Check 7 (docs consistency), Windows. + +.DESCRIPTION + Mirror of scripts/selftest-docs.sh. + + Check 7 only earns its place in CI if it FAILS when the docs lie. A check that + silently degrades into a no-op still reports success, so this test corrupts a + throwaway copy of the repo in several ways and asserts the validator catches each: + + 1. Clean copy -> passes. + 2. A wrong published number -> fails, naming the number and the truth. + 3. A reworded claim -> fails as vacuous (pattern matched no lines). + 4. An undeclared new claim -> fails as undeclared. + 5. A spelled-out CAPITALISED -> fails as undeclared. (SW-24) + 6. A bare-noun claim -> fails as undeclared. (SW-24) + + Scenarios 3 and 4 are what stop the check rotting: without them someone could + reword or add docs and quietly leave Check 7 guarding nothing. Scenarios 5 and 6 + cover the two escapes SW-24 found, both of which had let a real wrong claim sit + in a tracked doc through many green runs. They are deliberately separate: a fix + that only adds a lowercase word alternation passes 4 and fails 5, and a fix that + only handles decorated nouns passes 5 and fails 6. + + Exit code 0 = the check behaves correctly; 1 = the check is broken. + + PURE ASCII. Scanned by validate.ps1 Check 1. + +.EXAMPLE + .\scripts\selftest-docs.ps1 +#> + +param() + +$ErrorActionPreference = 'Stop' + +$scriptDir = $PSScriptRoot +$repoRoot = Split-Path -Parent $scriptDir + +function Write-Section { param([string]$Title) Write-Host ''; Write-Host "=== $Title ===" -ForegroundColor Cyan } +function Write-Ok { param([string]$m) Write-Host " [OK] $m" -ForegroundColor Green } +function Write-FailMsg { param([string]$m) Write-Host " [FAIL] $m" -ForegroundColor Red } + +$script:Failures = 0 + +# Working dirs are throwaway copies - the real repo is never mutated. +$SkipTop = @('.git', 'node_modules', '_bmad', '_bmad-output', 'design-artifacts', '.claude', 'dist') + +$workRoot = Join-Path $env:TEMP "sd-selftest-docs-$PID" +$psExe = (Get-Process -Id $PID).Path + +function New-RepoCopy { + param([string]$Dest) + New-Item -ItemType Directory -Path $Dest -Force | Out-Null + foreach ($entry in (Get-ChildItem -LiteralPath $repoRoot -Force)) { + if ($SkipTop -contains $entry.Name) { continue } + Copy-Item -LiteralPath $entry.FullName -Destination $Dest -Recurse -Force + } +} + +# The count this test corrupts is DERIVED from disk, never written down. A literal here +# would rot the moment a command is added: the pattern would stop matching, the sandbox +# copy would never be corrupted, and the scenario would report the validator as passing +# when in truth nothing was ever tested. That is exactly what happened when the 12th +# command landed against a hardcoded '11' (SW-20), and it is the same anti-pattern +# specwright.manifest.json exists to abolish. +$TrueCommands = @(Get-ChildItem -LiteralPath (Join-Path $repoRoot 'commands') -Filter '*.md' -File).Count +$WrongCommands = $TrueCommands + 1 + +function Edit-File { + param([string]$Path, [string]$From, [string]$To) + $text = Get-Content -LiteralPath $Path -Raw + $updated = $text -replace $From, $To + Set-Content -LiteralPath $Path -Value $updated -NoNewline + return ($updated -ne $text) +} + +# Asserts the transition, not just the destination: Edit-File reports whether the file +# actually changed. Checking only that the planted text is present is what defeated the +# original scenario-2 guard - it planted the then-current count, which by then was also +# the TRUE value already in README.md, so the check found the real line and passed +# vacuously. Returns $true when the corruption applied. +function Assert-Corruption { + param([string]$Name, [string]$Path, [string]$From, [string]$To) + + if (Edit-File -Path $Path -From $From -To $To) { + return $true + } + Write-FailMsg "$Name : fixture setup - pattern did not match, nothing was corrupted" + $script:Failures++ + return $false +} + +# Run the validator inside a copy and assert exit status + expected message. +# ExpectPass -> exit 0 required; otherwise non-zero AND $Needle in the output. +function Invoke-Case { + param( + [string]$Name, + [bool]$ExpectPass, + [string]$Needle, + [string]$Dir + ) + $validator = Join-Path $Dir 'scripts\validate.ps1' + $out = & $psExe -NoProfile -ExecutionPolicy Bypass -File $validator 2>&1 | Out-String + $status = $LASTEXITCODE + + if ($ExpectPass) { + if ($status -eq 0) { + Write-Ok "$Name : validator passed as expected" + } else { + Write-FailMsg "$Name : expected exit 0, got $status" + Write-Host $out + $script:Failures++ + } + return + } + + if ($status -eq 0) { + Write-FailMsg "$Name : expected non-zero exit, got 0 - THE CHECK DID NOT BITE" + $script:Failures++ + return + } + if ($out.Contains($Needle)) { + Write-Ok "$Name : failed with the right reason (exit $status)" + } else { + # Non-zero for the wrong reason is not a pass - it would mask a broken check. + Write-FailMsg "$Name : exited $status but never said '$Needle'" + Write-Host $out + $script:Failures++ + } +} + +try { + if (Test-Path -LiteralPath $workRoot) { Remove-Item -Recurse -Force $workRoot } + + Write-Section 'selftest: docs-consistency check (Check 7)' + Write-Host " Repo root: $repoRoot" + Write-Host " Sandbox: $workRoot" + + # ---- Scenario 1: clean copy passes ------------------------------------- + + Write-Section 'Scenario 1/6: clean copy passes' + $clean = Join-Path $workRoot 'clean' + New-RepoCopy -Dest $clean + Invoke-Case -Name 'clean' -ExpectPass $true -Needle '' -Dir $clean + + # ---- Scenario 2: a wrong published number fails ------------------------- + + Write-Section 'Scenario 2/6: wrong README number fails' + $wrong = Join-Path $workRoot 'wrong-number' + New-RepoCopy -Dest $wrong + $planted = Assert-Corruption -Name 'wrong-number' -Path (Join-Path $wrong 'README.md') ` + -From "\*\*$TrueCommands slash commands\*\*" -To "**$WrongCommands slash commands**" + if ($planted) { + Invoke-Case -Name 'wrong-number' -ExpectPass $false ` + -Needle "says $WrongCommands, disk has $TrueCommands" -Dir $wrong + } + + # ---- Scenario 3: a reworded claim fails as vacuous ---------------------- + + Write-Section 'Scenario 3/6: reworded claim fails as vacuous' + $reworded = Join-Path $workRoot 'reworded' + New-RepoCopy -Dest $reworded + $planted = Assert-Corruption -Name 'reworded' -Path (Join-Path $reworded 'README.md') ` + -From "\*\*$TrueCommands slash commands\*\*" -To "**$TrueCommands slash cmds**" + if ($planted) { + Invoke-Case -Name 'reworded' -ExpectPass $false ` + -Needle 'pattern matched no lines' -Dir $reworded + } + + # ---- Scenario 4: an undeclared claim fails ------------------------------ + + Write-Section 'Scenario 4/6: undeclared claim in a new doc fails' + $undeclared = Join-Path $workRoot 'undeclared' + New-RepoCopy -Dest $undeclared + Add-Content -LiteralPath (Join-Path $undeclared 'docs\usage.md') ` + -Value "`nThe engine ships 99 reusable skills." + Invoke-Case -Name 'undeclared' -ExpectPass $false -Needle 'undeclared inventory claim' -Dir $undeclared + + # ---- Scenario 5: a spelled-out, CAPITALISED claim fails (SW-24) --------- + + # A spelled-out number can never be validated against disk - the comparison is + # against an integer - so the only correct outcome is rejection as undeclared. + # Capitalised on purpose: a spelled-out count in prose is usually sentence-initial, + # which is exactly the form a lowercase-only word alternation misses. + + Write-Section 'Scenario 5/6: spelled-out capitalised claim fails' + $spelled = Join-Path $workRoot 'spelled-out' + New-RepoCopy -Dest $spelled + Add-Content -LiteralPath (Join-Path $spelled 'docs\usage.md') ` + -Value "`nSeven reusable skills ship with the engine." + Invoke-Case -Name 'spelled-out' -ExpectPass $false -Needle 'undeclared inventory claim' -Dir $spelled + + # ---- Scenario 6: a bare-noun claim fails (SW-24) ----------------------- + + # Before SW-24 the vocabulary only listed decorated forms ('slash commands', + # 'workflow commands'), so an undecorated 'N commands' matched nothing at all. + # That is how 'Five commands invoke no subagent' sat in docs/architecture.md unseen. + + Write-Section 'Scenario 6/6: bare-noun claim fails' + $bareNoun = Join-Path $workRoot 'bare-noun' + New-RepoCopy -Dest $bareNoun + Add-Content -LiteralPath (Join-Path $bareNoun 'docs\usage.md') ` + -Value "`nThe engine ships 99 commands." + Invoke-Case -Name 'bare-noun' -ExpectPass $false -Needle 'undeclared inventory claim' -Dir $bareNoun + + # ---- summary ----------------------------------------------------------- + + Write-Section 'Summary' + if ($script:Failures -eq 0) { + Write-Ok 'Check 7 bites on all 6 scenarios.' + exit 0 + } else { + Write-FailMsg "$($script:Failures) scenario(s) behaved wrong - Check 7 is not trustworthy." + exit 1 + } +} finally { + if (Test-Path -LiteralPath $workRoot) { + Remove-Item -Recurse -Force $workRoot -ErrorAction SilentlyContinue + } +} diff --git a/scripts/selftest-docs.sh b/scripts/selftest-docs.sh new file mode 100644 index 0000000..89e27aa --- /dev/null +++ b/scripts/selftest-docs.sh @@ -0,0 +1,204 @@ +#!/usr/bin/env bash +# Negative self-test for validate.sh Check 7 (docs consistency), Unix / bash. +# +# Mirror of scripts/selftest-docs.ps1. +# +# Check 7 only earns its place in CI if it FAILS when the docs lie. A check that +# silently degrades into a no-op still reports success, so this test corrupts a +# throwaway copy of the repo in several ways and asserts the validator catches each: +# +# 1. Clean copy -> passes. +# 2. A wrong published number -> fails, naming the number and the truth. +# 3. A reworded claim -> fails as vacuous (pattern matched no lines). +# 4. An undeclared new claim -> fails as undeclared. +# 5. A spelled-out CAPITALISED -> fails as undeclared. (SW-24) +# 6. A bare-noun claim -> fails as undeclared. (SW-24) +# +# Scenarios 3 and 4 are what stop the check rotting: without them someone could +# reword or add docs and quietly leave Check 7 guarding nothing. Scenarios 5 and 6 +# cover the two escapes SW-24 found, both of which had let a real wrong claim sit in +# a tracked doc through many green runs. They are deliberately separate: a fix that +# only adds a lowercase word alternation passes 4 and fails 5, and a fix that only +# handles decorated nouns passes 5 and fails 6. +# +# Exit 0 = the check behaves correctly; 1 = the check is broken. + +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/.." && pwd)" + +if [[ -t 1 ]]; then + c_reset=$'\033[0m'; c_cyan=$'\033[36m'; c_green=$'\033[32m'; c_red=$'\033[31m' +else + c_reset=''; c_cyan=''; c_green=''; c_red='' +fi +section() { echo; echo "${c_cyan}=== $* ===${c_reset}"; } +ok() { echo " ${c_green}[OK]${c_reset} $*"; } +fail() { echo " ${c_red}[FAIL]${c_reset} $*"; } + +failures=0 + +# Working dirs are throwaway copies - the real repo is never mutated. +SKIP_TOP=(".git" "node_modules" "_bmad" "_bmad-output" "design-artifacts" ".claude" "dist") + +work_root="$(mktemp -d)" +cleanup() { rm -rf "$work_root"; } +trap cleanup EXIT + +make_copy() { + local dest="$1" entry base skip + mkdir -p "$dest" + for entry in "$repo_root"/* "$repo_root"/.[!.]*; do + [[ -e "$entry" ]] || continue + base="$(basename "$entry")" + skip=0 + for s in "${SKIP_TOP[@]}"; do + [[ "$base" == "$s" ]] && { skip=1; break; } + done + [[ $skip -eq 1 ]] && continue + cp -R "$entry" "$dest/" + done +} + +# The count this test corrupts is DERIVED from disk, never written down. A literal here +# would rot the moment a command is added: the pattern would stop matching, the sandbox +# copy would never be corrupted, and the scenario would report the validator as passing +# when in truth nothing was ever tested. That is exactly what happened when the 12th +# command landed against a hardcoded '11' (SW-20), and it is the same anti-pattern +# specwright.manifest.json exists to abolish. +TRUE_COMMANDS="$(find "$repo_root/commands" -maxdepth 1 -type f -name '*.md' | wc -l | tr -d ' ')" +WRONG_COMMANDS=$((TRUE_COMMANDS + 1)) + +# Replace first match of a regex in a file, portably (macOS sed -i differs from GNU). +# Returns non-zero when the file did not change, so a corruption that silently failed to +# apply is reported as a fixture-setup error rather than sailing on as a passing validator. +replace_in() { + local file="$1" from="$2" to="$3" + local before after + before="$(cat "$file")" + sed "s|$from|$to|" "$file" > "$file.tmp" && mv "$file.tmp" "$file" + after="$(cat "$file")" + [[ "$before" != "$after" ]] +} + +# Asserts the transition, not just the destination: the original text must be GONE and the +# planted text present. Checking only for the planted text is what defeated the original +# scenario-2 guard - it planted the then-current count, which by then was also the TRUE +# value already in README.md, so the grep found the real line and passed vacuously. +corrupt_or_fail() { + local name="$1" file="$2" from="$3" to="$4" + if replace_in "$file" "$from" "$to"; then + return 0 + fi + fail "$name : fixture setup - pattern did not match, nothing was corrupted" + failures=$((failures + 1)) + return 1 +} + +# Run the validator inside a copy and assert exit status + expected message. +# expect_pass=1 -> exit 0 required; expect_pass=0 -> non-zero AND $needle in output. +run_case() { + local name="$1" expect_pass="$2" needle="${3:-}" dir="$4" + local out status=0 + out="$(bash "$dir/scripts/validate.sh" 2>&1)" || status=$? + + if [[ "$expect_pass" -eq 1 ]]; then + if [[ $status -eq 0 ]]; then + ok "$name : validator passed as expected" + else + fail "$name : expected exit 0, got $status" + printf '%s\n' "$out" | sed -n '/Check 7/,$p' | sed 's/^/ /' + failures=$((failures + 1)) + fi + return + fi + + if [[ $status -eq 0 ]]; then + fail "$name : expected non-zero exit, got 0 - THE CHECK DID NOT BITE" + failures=$((failures + 1)) + return + fi + if printf '%s\n' "$out" | grep -qF "$needle"; then + ok "$name : failed with the right reason (exit $status)" + else + # Non-zero for the wrong reason is not a pass - it would mask a broken check. + fail "$name : exited $status but never said '$needle'" + printf '%s\n' "$out" | sed -n '/Check 7/,$p' | sed 's/^/ /' + failures=$((failures + 1)) + fi +} + +section "selftest: docs-consistency check (Check 7)" +echo " Repo root: $repo_root" +echo " Sandbox: $work_root" + +# ---- Scenario 1: clean copy passes ----------------------------------------- + +section "Scenario 1/6: clean copy passes" +clean="$work_root/clean" +make_copy "$clean" +run_case "clean" 1 "" "$clean" + +# ---- Scenario 2: a wrong published number fails ---------------------------- + +section "Scenario 2/6: wrong README number fails" +wrong="$work_root/wrong-number" +make_copy "$wrong" +if corrupt_or_fail "wrong-number" "$wrong/README.md" \ + "\*\*${TRUE_COMMANDS} slash commands\*\*" "**${WRONG_COMMANDS} slash commands**"; then + run_case "wrong-number" 0 "says ${WRONG_COMMANDS}, disk has ${TRUE_COMMANDS}" "$wrong" +fi + +# ---- Scenario 3: a reworded claim fails as vacuous -------------------------- + +section "Scenario 3/6: reworded claim fails as vacuous" +reworded="$work_root/reworded" +make_copy "$reworded" +if corrupt_or_fail "reworded" "$reworded/README.md" \ + "\*\*${TRUE_COMMANDS} slash commands\*\*" "**${TRUE_COMMANDS} slash cmds**"; then + run_case "reworded" 0 "pattern matched no lines" "$reworded" +fi + +# ---- Scenario 4: an undeclared claim fails --------------------------------- + +section "Scenario 4/6: undeclared claim in a new doc fails" +undeclared="$work_root/undeclared" +make_copy "$undeclared" +printf '\nThe engine ships 99 reusable skills.\n' >> "$undeclared/docs/usage.md" +run_case "undeclared" 0 "undeclared inventory claim" "$undeclared" + +# ---- Scenario 5: a spelled-out, CAPITALISED claim fails (SW-24) ------------- + +# A spelled-out number can never be validated against disk - the comparison is against an +# integer - so the only correct outcome is rejection as undeclared. Capitalised on purpose: +# a spelled-out count in prose is usually sentence-initial, which is exactly the form a +# lowercase-only word alternation misses. A lowercase-only fix passes scenario 4 and fails +# here, which is the whole reason this scenario is separate. +section "Scenario 5/6: spelled-out capitalised claim fails" +spelled="$work_root/spelled-out" +make_copy "$spelled" +printf '\nSeven reusable skills ship with the engine.\n' >> "$spelled/docs/usage.md" +run_case "spelled-out" 0 "undeclared inventory claim" "$spelled" + +# ---- Scenario 6: a bare-noun claim fails (SW-24) --------------------------- + +# Before SW-24 the vocabulary only listed decorated forms ('slash commands', 'workflow +# commands'), so an undecorated 'N commands' matched nothing at all. That is how +# 'Five commands invoke no subagent' sat in docs/architecture.md unseen. +section "Scenario 6/6: bare-noun claim fails" +barenoun="$work_root/bare-noun" +make_copy "$barenoun" +printf '\nThe engine ships 99 commands.\n' >> "$barenoun/docs/usage.md" +run_case "bare-noun" 0 "undeclared inventory claim" "$barenoun" + +# ---- summary --------------------------------------------------------------- + +section "Summary" +if [[ $failures -eq 0 ]]; then + ok "Check 7 bites on all 6 scenarios." + exit 0 +else + fail "$failures scenario(s) behaved wrong - Check 7 is not trustworthy." + exit 1 +fi diff --git a/scripts/validate-lessons.ps1 b/scripts/validate-lessons.ps1 new file mode 100644 index 0000000..690f773 --- /dev/null +++ b/scripts/validate-lessons.ps1 @@ -0,0 +1,222 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + specwright: privacy validator for lesson files (Windows / PowerShell). + +.DESCRIPTION + Mirror of scripts/validate-lessons.sh - both must accept and reject exactly + the same lines. Unlike scripts/validate.ps1 (which checks THIS repo's own + invariants), this validator runs against a consumer repo's lessons file: + specwright itself has no .specs/ tree, so there is nothing here to check + except the fixtures under tests/lessons/fixtures/. + + With no argument it defaults to .specs/_lessons/lessons.md relative to the + current directory. A missing default file is NOT an error (a repo that has + not produced lessons yet is valid); a missing explicit argument is. + + Grammar enforced (see skills/sd-retro-lessons/SKILL.md): + - [tag] severity/scope: Rule sentence. + + Exit 0 = every lesson line is well-formed and identifier-free; + exit 1 = at least one violation. + +.EXAMPLE + .\scripts\validate-lessons.ps1 + .\scripts\validate-lessons.ps1 tests\lessons\fixtures\clean-lessons.md +#> + +[CmdletBinding()] +param( + [Parameter(ValueFromRemainingArguments = $true)] + [string[]] $Path +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repoRoot = Split-Path -Parent $PSScriptRoot + +$DEFAULT_REL = '.specs/_lessons/lessons.md' +$MAX_RULE_LEN = 120 + +# Kept in sync with $TAGS in validate-lessons.sh and the enum table in +# skills/sd-retro-lessons/SKILL.md. Capped at 12 by that skill; adding one +# takes a PR citing the retro that produced it. +$TAGS = @( + 'sibling-repo-assumption', 'missed-context', 'baseline-attribution', + 'tooling-surprise', 'gate-friction', 'config-drift', + 'test-fragility', 'test-gap', 'precedent-conflict', 'scope-discipline' +) + +$SCOPES = @('feature', 'bug', 'refactor', 'perf', 'rca', 'all') +$SEVERITIES = @('high', 'medium', 'low') + +# Technology proper nouns that are legitimately PascalCase. Deliberately short - +# a lesson needing a word that is not here is usually less portable than its +# author thinks. Extending this list takes a PR (and the same edit in the +# bash twin). +$IDENT_ALLOWLIST = @( + 'PowerShell', 'TypeScript', 'JavaScript', 'PostgreSQL', 'MySQL', 'MongoDB', + 'SQLite', 'GitHub', 'GitLab', 'OpenAPI', 'GraphQL', 'WebSocket', 'DevOps', + 'JSONPath' +) + +# Extensions that mark a filename. An explicit list rather than a generic +# dot-letters pattern, which would flag ordinary prose such as "e.g." or a +# sentence-ending period followed by a lowercase word. +$EXT_RE = '\.(md|json|jsonl|ps1|sh|bash|cs|ts|tsx|js|jsx|mjs|py|go|rb|rs|java|kt|php|yml|yaml|xml|sql|txt|csv|html|css|scss|toml|ini|cfg|lua|sln|csproj)([^a-zA-Z0-9]|$)' + +# Same grammar as the bash twin's [[ =~ ]] pattern. +$LESSON_RE = '^- \[([a-z-]+)\] ([a-z]+)/([a-z]+): (.+)$' +$COUNT_RE = '^(.*) \([0-9]+\)$' + +$script:Violations = 0 +$script:LessonsSeen = 0 + +function Write-Section { param([string] $Text) Write-Host ''; Write-Host "=== $Text ===" -ForegroundColor Cyan } +function Write-Ok { param([string] $Text) Write-Host " [OK] $Text" -ForegroundColor Green } +function Write-Fail { param([string] $Text) Write-Host " [FAIL] $Text" -ForegroundColor Red } + +function Add-Violation { + param([string] $File, [int] $LineNo, [string] $Message) + + Write-Fail "${File}:${LineNo} : $Message" + $script:Violations++ +} + +# Splits the rule text into word tokens and tests each one whole. Done this way +# rather than with a word-boundary regex so the logic reads identically to the +# bash twin, which cannot portably use \b. All identifier tests use -cmatch: +# PowerShell's -match is case-insensitive, which would make every casing test +# here vacuously true. +function Test-Identifier { + param([string] $File, [int] $LineNo, [string] $Text) + + $scrubbed = $Text + foreach ($word in $IDENT_ALLOWLIST) { + $scrubbed = $scrubbed.Replace($word, '') + } + + foreach ($token in ($scrubbed -split '[^A-Za-z0-9_]+')) { + if ([string]::IsNullOrEmpty($token)) { + continue + } + if ($token -cmatch '^[A-Z][a-z]+[A-Z][A-Za-z0-9]*$') { + Add-Violation $File $LineNo "PascalCase identifier '$token' in rule text" + } + elseif ($token -cmatch '^[a-z]+[A-Z][A-Za-z0-9]*$') { + Add-Violation $File $LineNo "camelCase identifier '$token' in rule text" + } + elseif ($token -cmatch '^[a-z]+_[a-z0-9_]+$') { + Add-Violation $File $LineNo "snake_case identifier '$token' in rule text" + } + } +} + +function Test-RuleText { + param([string] $File, [int] $LineNo, [string] $Text) + + if ($Text.Length -gt $MAX_RULE_LEN) { + Add-Violation $File $LineNo ("rule is " + $Text.Length + " chars (max $MAX_RULE_LEN)") + } + if ($Text.Contains('`')) { + Add-Violation $File $LineNo 'backtick in rule text - still describing code' + } + if ($Text.Contains('/') -or $Text.Contains('\')) { + Add-Violation $File $LineNo 'path separator in rule text' + } + if ($Text -cmatch $EXT_RE) { + Add-Violation $File $LineNo 'file extension in rule text' + } + if ($Text -cmatch ':[0-9]+') { + Add-Violation $File $LineNo 'line citation in rule text' + } + Test-Identifier $File $LineNo $Text +} + +function Test-LessonFile { + param([string] $FullPath, [string] $Rel) + + $lineNo = 0 + foreach ($line in (Get-Content -LiteralPath $FullPath)) { + $lineNo++ + + # Only lines that open like a lesson are candidates. Prose, headers and + # blank lines in the file are none of this validator's business. + if (-not $line.StartsWith('- [')) { + continue + } + $script:LessonsSeen++ + + if ($line -cnotmatch $LESSON_RE) { + Add-Violation $Rel $lineNo "does not match '- [tag] severity/scope: Rule sentence.'" + continue + } + + $tag = $Matches[1] + $severity = $Matches[2] + $scope = $Matches[3] + $rule = $Matches[4] + + # An aggregator-appended repeat count is metadata, not rule text. + if ($rule -cmatch $COUNT_RE) { + $rule = $Matches[1] + } + + if ($TAGS -cnotcontains $tag) { + Add-Violation $Rel $lineNo "unknown tag '$tag'" + } + if ($SEVERITIES -cnotcontains $severity) { + Add-Violation $Rel $lineNo "unknown severity '$severity'" + } + if ($SCOPES -cnotcontains $scope) { + Add-Violation $Rel $lineNo "unknown scope '$scope'" + } + + Test-RuleText $Rel $lineNo $rule + } +} + +# ---- collect targets -------------------------------------------------------- + +$targets = @() +if ($Path -and $Path.Count -gt 0) { + foreach ($arg in $Path) { + if (-not (Test-Path -LiteralPath $arg -PathType Leaf)) { + Write-Section 'specwright validate-lessons' + Write-Fail "$arg : file not found" + exit 1 + } + $targets += (Resolve-Path -LiteralPath $arg).Path + } +} +else { + if (Test-Path -LiteralPath $DEFAULT_REL -PathType Leaf) { + $targets += (Resolve-Path -LiteralPath $DEFAULT_REL).Path + } + else { + Write-Section 'specwright validate-lessons' + Write-Ok ("no $DEFAULT_REL in " + (Get-Location).Path + " - nothing to validate") + exit 0 + } +} + +# ---- run -------------------------------------------------------------------- + +Write-Section 'specwright validate-lessons' +foreach ($t in $targets) { + $rel = $t + if ($t.StartsWith($repoRoot)) { + $rel = $t.Substring($repoRoot.Length).TrimStart('\', '/').Replace('\', '/') + } + Test-LessonFile $t $rel +} + +if ($script:Violations -eq 0) { + Write-Ok ("$script:LessonsSeen lesson line(s) across " + $targets.Count + " file(s): well-formed, no identifiers") + exit 0 +} +else { + Write-Fail "$script:Violations violation(s) across $script:LessonsSeen lesson line(s)" + exit 1 +} diff --git a/scripts/validate-lessons.sh b/scripts/validate-lessons.sh new file mode 100644 index 0000000..eac9f14 --- /dev/null +++ b/scripts/validate-lessons.sh @@ -0,0 +1,202 @@ +#!/usr/bin/env bash +# specwright: privacy validator for lesson files (Unix / bash). +# +# Mirror of scripts/validate-lessons.ps1 - both must accept and reject exactly +# the same lines. Unlike scripts/validate.sh (which checks THIS repo's own +# invariants), this validator runs against a consumer repo's lessons file: +# specwright itself has no .specs/ tree, so there is nothing here to check +# except the fixtures under tests/lessons/fixtures/. +# +# bash scripts/validate-lessons.sh [FILE ...] +# +# With no argument it defaults to .specs/_lessons/lessons.md relative to the +# current directory. A missing default file is NOT an error (a repo that has +# not produced lessons yet is valid); a missing explicit argument is. +# +# Grammar enforced (see skills/sd-retro-lessons/SKILL.md): +# - [tag] severity/scope: Rule sentence. +# +# Exit 0 = every lesson line is well-formed and identifier-free; 1 = at least +# one violation. + +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/.." && pwd)" + +DEFAULT_REL=".specs/_lessons/lessons.md" +MAX_RULE_LEN=120 + +# Kept in sync with $TAGS in validate-lessons.ps1 and the enum table in +# skills/sd-retro-lessons/SKILL.md. Capped at 12 by that skill; adding one +# takes a PR citing the retro that produced it. +TAGS="sibling-repo-assumption missed-context baseline-attribution tooling-surprise \ +gate-friction config-drift test-fragility test-gap precedent-conflict scope-discipline" + +SCOPES="feature bug refactor perf rca all" +SEVERITIES="high medium low" + +# Technology proper nouns that are legitimately PascalCase. Deliberately short - +# a lesson needing a word that is not here is usually less portable than its +# author thinks. Extending this list takes a PR (and the same edit in the +# PowerShell twin). +IDENT_ALLOWLIST="PowerShell TypeScript JavaScript PostgreSQL MySQL MongoDB SQLite \ +GitHub GitLab OpenAPI GraphQL WebSocket DevOps JSONPath" + +# Extensions that mark a filename. An explicit list rather than a generic +# dot-letters pattern, which would flag ordinary prose such as "e.g." or a +# sentence-ending period followed by a lowercase word. +EXT_RE='\.(md|json|jsonl|ps1|sh|bash|cs|ts|tsx|js|jsx|mjs|py|go|rb|rs|java|kt|php|yml|yaml|xml|sql|txt|csv|html|css|scss|toml|ini|cfg|lua|sln|csproj)([^a-zA-Z0-9]|$)' + +# Same grammar as the PowerShell twin's $LESSON_RE. Held in variables and +# referenced unquoted inside [[ =~ ]] so the spaces need no backslash escaping. +LESSON_RE='^- \[([a-z-]+)\] ([a-z]+)/([a-z]+): (.+)$' +COUNT_RE='^(.*) \([0-9]+\)$' + +if [[ -t 1 ]]; then + c_reset=$'\033[0m'; c_cyan=$'\033[36m'; c_green=$'\033[32m'; c_red=$'\033[31m' +else + c_reset=''; c_cyan=''; c_green=''; c_red='' +fi + +section() { echo; echo "${c_cyan}=== $* ===${c_reset}"; } +ok() { echo " ${c_green}[OK]${c_reset} $*"; } +fail() { echo " ${c_red}[FAIL]${c_reset} $*"; } + +violations=0 +lessons_seen=0 + +report() { + local file="$1" lineno="$2" msg="$3" + fail "$file:$lineno : $msg" + violations=$((violations + 1)) +} + +in_list() { + local needle="$1" list="$2" item + for item in $list; do + if [[ "$item" == "$needle" ]]; then return 0; fi + done + return 1 +} + +# Splits the rule text into word tokens and tests each one whole. Done this way +# rather than with a word-boundary regex because \b is not portable across the +# bash builtin [[ =~ ]] and BSD grep, and an anchored per-token test is easier +# to reason about than an embedded boundary assertion. +check_identifiers() { + local file="$1" lineno="$2" text="$3" + local scrubbed="$text" word token + for word in $IDENT_ALLOWLIST; do + scrubbed="${scrubbed//$word/}" + done + + # Replace every character that cannot appear inside an identifier with a + # space, then iterate the remaining tokens. + local split + split="$(printf '%s' "$scrubbed" | tr -c 'A-Za-z0-9_' ' ')" + for token in $split; do + if [[ "$token" =~ ^[A-Z][a-z]+[A-Z][A-Za-z0-9]*$ ]]; then + report "$file" "$lineno" "PascalCase identifier '$token' in rule text" + elif [[ "$token" =~ ^[a-z]+[A-Z][A-Za-z0-9]*$ ]]; then + report "$file" "$lineno" "camelCase identifier '$token' in rule text" + elif [[ "$token" =~ ^[a-z]+_[a-z0-9_]+$ ]]; then + report "$file" "$lineno" "snake_case identifier '$token' in rule text" + fi + done +} + +check_rule_text() { + local file="$1" lineno="$2" text="$3" + + if [[ ${#text} -gt $MAX_RULE_LEN ]]; then + report "$file" "$lineno" "rule is ${#text} chars (max $MAX_RULE_LEN)" + fi + if [[ "$text" == *'`'* ]]; then + report "$file" "$lineno" "backtick in rule text - still describing code" + fi + if [[ "$text" == */* || "$text" == *'\'* ]]; then + report "$file" "$lineno" "path separator in rule text" + fi + if [[ "$text" =~ $EXT_RE ]]; then + report "$file" "$lineno" "file extension in rule text" + fi + if [[ "$text" =~ :[0-9]+ ]]; then + report "$file" "$lineno" "line citation in rule text" + fi + check_identifiers "$file" "$lineno" "$text" +} + +check_file() { + local path="$1" rel="$2" + local lineno=0 line + + while IFS= read -r line || [[ -n "$line" ]]; do + lineno=$((lineno + 1)) + line="${line%$'\r'}" + + # Only lines that open like a lesson are candidates. Prose, headers and + # blank lines in the file are none of this validator's business. + [[ "$line" == "- ["* ]] || continue + lessons_seen=$((lessons_seen + 1)) + + if [[ ! "$line" =~ $LESSON_RE ]]; then + report "$rel" "$lineno" "does not match '- [tag] severity/scope: Rule sentence.'" + continue + fi + + local tag="${BASH_REMATCH[1]}" + local severity="${BASH_REMATCH[2]}" + local scope="${BASH_REMATCH[3]}" + local rule="${BASH_REMATCH[4]}" + + # An aggregator-appended repeat count is metadata, not rule text. + if [[ "$rule" =~ $COUNT_RE ]]; then + rule="${BASH_REMATCH[1]}" + fi + + in_list "$tag" "$TAGS" || report "$rel" "$lineno" "unknown tag '$tag'" + in_list "$severity" "$SEVERITIES" || report "$rel" "$lineno" "unknown severity '$severity'" + in_list "$scope" "$SCOPES" || report "$rel" "$lineno" "unknown scope '$scope'" + + check_rule_text "$rel" "$lineno" "$rule" + done < "$path" +} + +# ---- collect targets -------------------------------------------------------- + +targets=() +if [[ $# -gt 0 ]]; then + for arg in "$@"; do + if [[ ! -f "$arg" ]]; then + section "specwright validate-lessons" + fail "$arg : file not found" + exit 1 + fi + targets+=("$arg") + done +else + if [[ -f "$DEFAULT_REL" ]]; then + targets+=("$DEFAULT_REL") + else + section "specwright validate-lessons" + ok "no $DEFAULT_REL in $(pwd) - nothing to validate" + exit 0 + fi +fi + +# ---- run -------------------------------------------------------------------- + +section "specwright validate-lessons" +for t in "${targets[@]}"; do + rel="${t#"$repo_root"/}" + check_file "$t" "$rel" +done + +if [[ $violations -eq 0 ]]; then + ok "$lessons_seen lesson line(s) across ${#targets[@]} file(s): well-formed, no identifiers" + exit 0 +else + fail "$violations violation(s) across $lessons_seen lesson line(s)" + exit 1 +fi diff --git a/scripts/validate.ps1 b/scripts/validate.ps1 index 64b2a81..1290091 100644 --- a/scripts/validate.ps1 +++ b/scripts/validate.ps1 @@ -13,6 +13,8 @@ 5. Install-target count: a real install to a temp base lands the expected file counts under each /sd/ subfolder. 6. CHANGELOG gate: the [Unreleased] section is non-empty. + 7. Docs consistency: published numbers in the docs match disk, per + specwright.manifest.json. Exit code 0 = all checks passed; 1 = at least one check failed. @@ -119,7 +121,7 @@ Write-Host " Repo root: $repoRoot" # ---- Check 1: pure-ASCII scan ---------------------------------------------- -Write-Section 'Check 1/6: Pure-ASCII scan (*.ps1)' +Write-Section 'Check 1/7: Pure-ASCII scan (*.ps1)' $ps1Files = Get-ChildItem -Path $repoRoot -Recurse -Filter *.ps1 -File | Where-Object { $_.FullName -notmatch '[\\/]\.git[\\/]' } $asciiBad = 0 @@ -136,7 +138,7 @@ if ($asciiBad -eq 0) { Write-Ok "$($ps1Files.Count) .ps1 file(s) are pure ASCII" # ---- Check 2: bash -n syntax ----------------------------------------------- -Write-Section 'Check 2/6: bash -n syntax (*.sh)' +Write-Section 'Check 2/7: bash -n syntax (*.sh)' $shFiles = @() foreach ($sub in @('hooks\bash', 'install', 'scripts')) { $dir = Join-Path $repoRoot $sub @@ -164,7 +166,7 @@ if ($null -eq $bashExe) { # ---- Check 3: hook-pair parity --------------------------------------------- -Write-Section 'Check 3/6: Hook-pair parity' +Write-Section 'Check 3/7: Hook-pair parity' $psHooks = Get-ChildItem (Join-Path $repoRoot 'hooks\powershell') -Filter *.ps1 -File | ForEach-Object { $_.BaseName } $shHooks = Get-ChildItem (Join-Path $repoRoot 'hooks\bash') -Filter *.sh -File | @@ -188,7 +190,7 @@ if ($parityBad -eq 0) { Write-Ok "$($psHooks.Count) hook pair(s) present on both # ---- Check 4: agent model aliases ------------------------------------------ -Write-Section 'Check 4/6: Agent model aliases' +Write-Section 'Check 4/7: Agent model aliases' $agentFiles = Get-ChildItem (Join-Path $repoRoot 'agents') -Filter *.md -File $modelBad = 0 foreach ($f in $agentFiles) { @@ -211,7 +213,7 @@ if ($modelBad -eq 0) { Write-Ok "$($agentFiles.Count) agent(s) use a model alias # ---- Check 5: install-target counts ---------------------------------------- -Write-Section 'Check 5/6: Install-target counts' +Write-Section 'Check 5/7: Install-target counts' $installPs1 = Join-Path $repoRoot 'install\install.ps1' $tmp = Join-Path $env:TEMP "sd-validate-$PID" $psExe = (Get-Process -Id $PID).Path @@ -255,7 +257,7 @@ try { # ---- Check 6: CHANGELOG [Unreleased] non-empty ----------------------------- -Write-Section 'Check 6/6: CHANGELOG [Unreleased] gate' +Write-Section 'Check 6/7: CHANGELOG [Unreleased] gate' $changelog = Join-Path $repoRoot 'CHANGELOG.md' $lines = Get-Content -LiteralPath $changelog $start = -1 @@ -286,6 +288,140 @@ if ($start -lt 0) { } } +# ---- Check 7: docs consistency --------------------------------------------- + +Write-Section 'Check 7/7: Docs consistency (published numbers vs disk)' +$manifestPath = Join-Path $repoRoot 'specwright.manifest.json' +if (-not (Test-Path -LiteralPath $manifestPath)) { + Write-FailMsg 'specwright.manifest.json not found at repo root' + Add-Failure 'docs: manifest missing' +} else { + $docsBad = 0 + $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json + $quantities = @{} + $filePatterns = @{} + + # Area counts are derived from disk, never stored in the manifest. + foreach ($areaProp in $manifest.areas.PSObject.Properties) { + $areaName = $areaProp.Name + $area = $areaProp.Value + $areaCount = 0 + if ($area.glob) { + $globPath = Join-Path $repoRoot ($area.glob -replace '/', '\') + $areaCount = @(Get-ChildItem -Path $globPath -File -ErrorAction SilentlyContinue).Count + } else { + foreach ($relFile in $area.files) { + $full = Join-Path $repoRoot ($relFile -replace '/', '\') + if (Test-Path -LiteralPath $full -PathType Leaf) { + $areaCount++ + } else { + Write-FailMsg "area '$areaName' lists a file that does not exist: $relFile" + Add-Failure "docs: area $areaName missing $relFile" + $docsBad++ + } + } + } + if ($areaCount -eq 0) { + Write-FailMsg "area '$areaName' matched 0 files" + Add-Failure "docs: area $areaName derived 0" + $docsBad++ + } + $quantities[$areaName] = $areaCount + } + + foreach ($derProp in $manifest.derived.PSObject.Properties) { + $derTotal = 0 + foreach ($part in $derProp.Value) { + if ($quantities.ContainsKey($part)) { $derTotal += [int]$quantities[$part] } + } + $quantities[$derProp.Name] = $derTotal + } + + foreach ($claim in $manifest.docClaims) { + if (-not $filePatterns.ContainsKey($claim.file)) { + $filePatterns[$claim.file] = New-Object System.Collections.Generic.List[string] + } + $filePatterns[$claim.file].Add($claim.pattern) + + $target = Join-Path $repoRoot ($claim.file -replace '/', '\') + if (-not (Test-Path -LiteralPath $target -PathType Leaf)) { + Write-FailMsg "$($claim.file) : declared claim file does not exist" + Add-Failure "docs: missing claim file $($claim.file)" + $docsBad++ + continue + } + if (-not $quantities.ContainsKey($claim.equals)) { + Write-FailMsg "$($claim.file) : claim references unknown quantity '$($claim.equals)'" + Add-Failure "docs: unknown quantity $($claim.equals)" + $docsBad++ + continue + } + $expected = "$($quantities[$claim.equals])" + + $hits = 0 + $lineNo = 0 + foreach ($line in (Get-Content -LiteralPath $target)) { + $lineNo++ + # [regex] rather than -match: PowerShell's -match is case-insensitive by + # default, which would silently diverge from the bash twin's [[ =~ ]]. + $m = [regex]::Match($line, $claim.pattern) + if ($m.Success) { + $hits++ + $found = $m.Groups[1].Value + if ($found -ne $expected) { + Write-FailMsg "$($claim.file):$lineNo : says $found, disk has $expected ($($claim.equals))" + Add-Failure "docs: $($claim.file):$lineNo $($claim.equals) says $found not $expected" + $docsBad++ + } + } + } + + # A pattern that matches nothing is a rotted regex, not a pass - without this + # a reworded doc sentence silently turns the claim into a no-op. + if ($hits -eq 0) { + Write-FailMsg "$($claim.file) : pattern matched no lines (reworded?): $($claim.pattern)" + Add-Failure "docs: vacuous claim in $($claim.file) ($($claim.equals))" + $docsBad++ + } + } + + # Undeclared-claim scan: any line that looks like an inventory claim but is not + # covered by a docClaims entry. This is what keeps the manifest canonical - a new + # doc cannot publish a number that nothing checks. + $phrasesRe = ($manifest.claimPhrases) -join '|' + $mdFiles = Get-ChildItem -Path $repoRoot -Recurse -Filter *.md -File -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -notmatch '[\\/]\.git[\\/]' } + foreach ($f in $mdFiles) { + $rel = (Get-RelPath $f.FullName) -replace '\\', '/' + $skip = $false + foreach ($ex in $manifest.historicalExclusions) { + if ($rel.StartsWith($ex)) { $skip = $true; break } + } + if ($skip) { continue } + + $lineNo = 0 + foreach ($line in (Get-Content -LiteralPath $f.FullName)) { + $lineNo++ + if (-not [regex]::IsMatch($line, $phrasesRe)) { continue } + $covered = $false + if ($filePatterns.ContainsKey($rel)) { + foreach ($pat in $filePatterns[$rel]) { + if ([regex]::IsMatch($line, $pat)) { $covered = $true; break } + } + } + if (-not $covered) { + Write-FailMsg "${rel}:$lineNo : undeclared inventory claim (add a docClaims entry or an exclusion)" + Add-Failure "docs: undeclared claim ${rel}:$lineNo" + $docsBad++ + } + } + } + + if ($docsBad -eq 0) { + Write-Ok "$($manifest.docClaims.Count) published claim(s) match disk; no undeclared claims" + } +} + # ---- summary --------------------------------------------------------------- Write-Section 'Summary' diff --git a/scripts/validate.sh b/scripts/validate.sh index 2f6a84f..f66f184 100755 --- a/scripts/validate.sh +++ b/scripts/validate.sh @@ -10,6 +10,8 @@ # 5. Install-target count: a real install to a temp base lands the expected # file counts under each /sd/ subfolder. # 6. CHANGELOG gate: the [Unreleased] section is non-empty. +# 7. Docs consistency: published numbers in the docs match disk, per +# specwright.manifest.json. # # Exit 0 = all checks passed; 1 = at least one failed. @@ -64,7 +66,7 @@ echo " Repo root: $repo_root" # ---- Check 1: pure-ASCII scan ---------------------------------------------- -section "Check 1/6: Pure-ASCII scan (*.ps1)" +section "Check 1/7: Pure-ASCII scan (*.ps1)" ascii_bad=0 ps1_count=0 while IFS= read -r -d '' f; do @@ -81,7 +83,7 @@ if [[ $ascii_bad -eq 0 ]]; then ok "$ps1_count .ps1 file(s) are pure ASCII"; fi # ---- Check 2: bash -n syntax ----------------------------------------------- -section "Check 2/6: bash -n syntax (*.sh)" +section "Check 2/7: bash -n syntax (*.sh)" syn_bad=0 sh_count=0 while IFS= read -r -d '' f; do @@ -98,7 +100,7 @@ if [[ $syn_bad -eq 0 ]]; then ok "$sh_count .sh file(s) pass bash -n"; fi # ---- Check 3: hook-pair parity --------------------------------------------- -section "Check 3/6: Hook-pair parity" +section "Check 3/7: Hook-pair parity" parity_bad=0 ps_count=0 for psf in "$repo_root"/hooks/powershell/*.ps1; do @@ -124,7 +126,7 @@ if [[ $parity_bad -eq 0 ]]; then ok "$ps_count hook pair(s) present on both plat # ---- Check 4: agent model aliases ------------------------------------------ -section "Check 4/6: Agent model aliases" +section "Check 4/7: Agent model aliases" model_bad=0 agent_count=0 for af in "$repo_root"/agents/*.md; do @@ -152,7 +154,7 @@ if [[ $model_bad -eq 0 ]]; then ok "$agent_count agent(s) use a model alias"; fi # ---- Check 5: install-target counts ---------------------------------------- -section "Check 5/6: Install-target counts" +section "Check 5/7: Install-target counts" install_sh="$repo_root/install/install.sh" tmp="${TMPDIR:-/tmp}/sd-validate-$$" cleanup_tmp() { [[ -n "${tmp:-}" && -d "$tmp" ]] && rm -rf "$tmp" || true; } @@ -183,7 +185,7 @@ trap - EXIT # ---- Check 6: CHANGELOG [Unreleased] non-empty ----------------------------- -section "Check 6/6: CHANGELOG [Unreleased] gate" +section "Check 6/7: CHANGELOG [Unreleased] gate" changelog="$repo_root/CHANGELOG.md" block="$(awk ' /^##[[:space:]]+\[Unreleased\]/ { f=1; next } @@ -206,6 +208,197 @@ else add_failure "changelog: [Unreleased] empty" fi +# ---- Check 7: docs consistency --------------------------------------------- + +section "Check 7/7: Docs consistency (published numbers vs disk)" +manifest="$repo_root/specwright.manifest.json" +if [[ ! -f "$manifest" ]]; then + fail "specwright.manifest.json not found at repo root" + add_failure "docs: manifest missing" +elif ! command -v jq >/dev/null 2>&1; then + # Hooks exit 0 silently when jq is absent so they never block a user on their own + # bugs. A validator must do the opposite: a missing jq that passed would turn CI + # green while checking nothing. + fail "jq is required to parse specwright.manifest.json - install jq" + add_failure "docs: jq not installed" +else + docs_bad=0 + # Plain (non-associative) arrays + linear-scan lookup functions, not `declare -A`: + # macOS ships /bin/bash 3.2 (no associative arrays), and this script must run there. + quantity_names=() + quantity_values=() + q_set() { # name value + local name="$1" value="$2" i + for i in "${!quantity_names[@]}"; do + if [[ "${quantity_names[$i]}" == "$name" ]]; then + quantity_values[$i]="$value" + return + fi + done + quantity_names+=("$name") + quantity_values+=("$value") + } + q_get() { # name -> stdout value, empty if unset + local name="$1" i + for i in "${!quantity_names[@]}"; do + if [[ "${quantity_names[$i]}" == "$name" ]]; then + printf '%s' "${quantity_values[$i]}" + return + fi + done + } + + file_pattern_names=() + file_pattern_values=() + fp_append() { # file pattern + local file="$1" pattern="$2" i + for i in "${!file_pattern_names[@]}"; do + if [[ "${file_pattern_names[$i]}" == "$file" ]]; then + file_pattern_values[$i]="${file_pattern_values[$i]}${pattern}"$'\n' + return + fi + done + file_pattern_names+=("$file") + file_pattern_values+=("${pattern}"$'\n') + } + fp_get() { # file -> stdout newline-joined patterns, empty if none + local file="$1" i + for i in "${!file_pattern_names[@]}"; do + if [[ "${file_pattern_names[$i]}" == "$file" ]]; then + printf '%s' "${file_pattern_values[$i]}" + return + fi + done + } + + # Some jq builds (notably jq.exe on Windows) emit CRLF. An unstripped \r rides on the + # last field of every record and silently breaks glob matches, array keys and prefix + # tests - failures that look like real drift but are not. + mjq() { jq -r "$1" "$manifest" | tr -d '\r'; } + + # Area counts are derived from disk, never stored in the manifest. + shopt -s nullglob + while IFS=$'\t' read -r area_name area_kind area_value; do + area_count=0 + if [[ "$area_kind" == "glob" ]]; then + for p in "$repo_root"/$area_value; do + [[ -f "$p" ]] && area_count=$((area_count + 1)) + done + else + for rel_f in $area_value; do + if [[ -f "$repo_root/$rel_f" ]]; then + area_count=$((area_count + 1)) + else + fail "area '$area_name' lists a file that does not exist: $rel_f" + add_failure "docs: area $area_name missing $rel_f" + docs_bad=$((docs_bad + 1)) + fi + done + fi + if [[ $area_count -eq 0 ]]; then + fail "area '$area_name' ($area_kind '$area_value') matched 0 files" + add_failure "docs: area $area_name derived 0" + docs_bad=$((docs_bad + 1)) + fi + q_set "$area_name" "$area_count" + done < <(mjq '.areas | to_entries[] | "\(.key)\t\(if .value.glob then "glob" else "files" end)\t\(.value.glob // (.value.files | join(" ")))"') + shopt -u nullglob + + while IFS=$'\t' read -r der_name der_parts; do + der_total=0 + for part in $der_parts; do + part_val="$(q_get "$part")" + der_total=$((der_total + ${part_val:-0})) + done + q_set "$der_name" "$der_total" + done < <(mjq '.derived | to_entries[] | "\(.key)\t\(.value | join(" "))"') + + while IFS=$'\t' read -r c_file c_pattern c_equals; do + fp_append "$c_file" "$c_pattern" + + target="$repo_root/$c_file" + if [[ ! -f "$target" ]]; then + fail "$c_file : declared claim file does not exist" + add_failure "docs: missing claim file $c_file" + docs_bad=$((docs_bad + 1)) + continue + fi + expected="$(q_get "$c_equals")" + if [[ -z "$expected" ]]; then + fail "$c_file : claim references unknown quantity '$c_equals'" + add_failure "docs: unknown quantity $c_equals" + docs_bad=$((docs_bad + 1)) + continue + fi + + hits=0 + lineno=0 + while IFS= read -r line || [[ -n "$line" ]]; do + lineno=$((lineno + 1)) + if [[ "$line" =~ $c_pattern ]]; then + hits=$((hits + 1)) + found="${BASH_REMATCH[1]}" + if [[ "$found" != "$expected" ]]; then + fail "$c_file:$lineno : says $found, disk has $expected ($c_equals)" + add_failure "docs: $c_file:$lineno $c_equals says $found not $expected" + docs_bad=$((docs_bad + 1)) + fi + fi + done < "$target" + + # A pattern that matches nothing is a rotted regex, not a pass - without this + # a reworded doc sentence silently turns the claim into a no-op. + if [[ $hits -eq 0 ]]; then + fail "$c_file : pattern matched no lines (reworded?): $c_pattern" + add_failure "docs: vacuous claim in $c_file ($c_equals)" + docs_bad=$((docs_bad + 1)) + fi + done < <(mjq '.docClaims[] | "\(.file)\t\(.pattern)\t\(.equals)"') + + # Undeclared-claim scan: any line that looks like an inventory claim but is not + # covered by a docClaims entry. This is what keeps the manifest canonical - a new + # doc cannot publish a number that nothing checks. + phrases_re="$(mjq '.claimPhrases | join("|")')" + # Not `mapfile` (bash 4+, absent from macOS's stock /bin/bash 3.2). + exclusions=() + while IFS= read -r ex; do + exclusions+=("$ex") + done < <(mjq '.historicalExclusions[]') + + while IFS= read -r -d '' f; do + rel="${f#"$repo_root"/}" + skip=0 + for ex in "${exclusions[@]}"; do + if [[ "$rel" == "$ex"* ]]; then skip=1; break; fi + done + [[ $skip -eq 1 ]] && continue + + lineno=0 + while IFS= read -r line || [[ -n "$line" ]]; do + lineno=$((lineno + 1)) + [[ "$line" =~ $phrases_re ]] || continue + covered=0 + fp_val="$(fp_get "$rel")" + if [[ -n "$fp_val" ]]; then + while IFS= read -r pat; do + [[ -z "$pat" ]] && continue + if [[ "$line" =~ $pat ]]; then covered=1; break; fi + done <<< "$fp_val" + fi + if [[ $covered -eq 0 ]]; then + fail "$rel:$lineno : undeclared inventory claim (add a docClaims entry or an exclusion)" + add_failure "docs: undeclared claim $rel:$lineno" + docs_bad=$((docs_bad + 1)) + fi + done < "$f" + done < <(find "$repo_root" -type f -name '*.md' -not -path '*/.git/*' -print0) + + if [[ $docs_bad -eq 0 ]]; then + claim_total="$(mjq '.docClaims | length')" + ok "$claim_total published claim(s) match disk; no undeclared claims" + fi +fi + # ---- summary --------------------------------------------------------------- section "Summary" diff --git a/skills/sd-atomic-task-format/SKILL.md b/skills/sd-atomic-task-format/SKILL.md index 954b7ab..768b977 100644 --- a/skills/sd-atomic-task-format/SKILL.md +++ b/skills/sd-atomic-task-format/SKILL.md @@ -5,7 +5,7 @@ Used by `sd-spec-architect` when authoring `02-tasks.md` and by `sd-implementer` --- -## Task block (9 required fields + Pattern refs) +## Task block (11 required fields) ```markdown ### T - @@ -15,6 +15,7 @@ Used by `sd-spec-architect` when authoring `02-tasks.md` and by `sd-implementer` - **Step type**: - **Test**: - **Acceptance**: +- **Covers**: - **Depends on**: - **Conflicts with**: - **Estimated complexity**: @@ -22,14 +23,22 @@ Used by `sd-spec-architect` when authoring `02-tasks.md` and by `sd-implementer` - **Pattern refs**: <1-3 file:line precedent citations + what to mirror | none> ``` -The first 9 fields are **required**, not optional. A task block missing any of them is malformed. -`Pattern refs` is **required when the task creates a new file or a new public symbol**, and -recommended otherwise. A block without the field is treated as `Pattern refs: none` (backward -compatible with existing `.specs/` folders). +All 11 fields are **required**, not optional. A task block missing any of them is malformed. + +`Pattern refs` is required on **every** task - the old "only when the task creates a new file or a +new public symbol" condition is gone. A task with no precedent worth citing writes +`Pattern refs: none` **explicitly**. The point of the field is the assertion: `none` says the +architect looked and found nothing, while an absent field says nothing at all, and the two are not +the same claim. + +Reading legacy specs stays lenient. A block authored before this rule, with the field absent, is +read as `Pattern refs: none` so existing `.specs/` folders keep working. `/sd:spec validate` +reports the omission as `SL060` (WARN) - it does not block, and nothing downstream refuses the +task. ### Refactor mode adds one field -`/sd:refactor` tasks append a 10th field after `Pattern refs`: +`/sd:refactor` tasks append one more field after `Pattern refs`: ```markdown - **Parallel batch**: @@ -39,6 +48,63 @@ Tasks sharing a batch number have disjoint file sets and no `Depends on` / `Conf relationship between them - they are safe to execute in parallel. `solo` means the task cannot be batched with any other. Other workflow types (feature, bug, perf) do not use this field. +### Re-plan adds one field + +A task **regenerated by a mid-execution re-plan** (`/sd:feature`, `/sd:refactor` - see the +**sd-replan-loop** skill) carries one extra field after `Pattern refs` (or after `Parallel batch` +in refactor mode): + +```markdown +- **Revised-by**: +``` + +`Revised-by` is **conditional, not one of the 11 required fields**: it is present only on a task the +re-plan gate regenerated, and absent on every task authored in the original Plan phase. It ties the +task to its `## Revisions` entry in `01-plan.md`; `/sd:spec validate` checks the two agree in both +directions (`SL070`-`SL073`). A task authored at Plan phase never carries it - do not add it +speculatively. + +--- + +## Field label grammar + +The block above shows the **canonical form to author**. It is an example, not the parsing rule - +real specs have drifted, and a reader that accepts only the canonical form rejects valid work. + +**When authoring**, always emit the canonical form: + +```markdown +- **Files**: +``` + +**When reading**, match tolerantly. A field label matches when all of the following hold: + +| Aspect | Rule | +|---|---| +| Bullet marker | `-` or `*`, any leading indentation | +| Emphasis | `**` around the label is optional | +| Colon | may sit inside the emphasis (`**Files:**`) or outside it (`**Files**:`) | +| Case | label match is case-insensitive | +| Whitespace | any amount around the marker, label, colon, and value | + +All three of these are the same field and must parse identically: + +```markdown +- **Files**: src/Foo.cs +- Files: src/Foo.cs +- **Files:** src/Foo.cs +``` + +**Value extent.** A field's value runs from after the colon to the start of the next field label +or the end of the block - it is **not** limited to one line. Indented continuation lines and +nested sub-bullets belong to the field above them. `Acceptance` and `Pattern refs` are routinely +authored as multi-line values with nested bullets; a line-oriented reader that stops at the first +newline truncates them. + +This grammar applies to **every** field in the block, not only to the field a given check cares +about. Anything that reads `02-tasks.md` - `/sd:spec validate`, `/sd:verify`, `sd-implementer` - +uses this one rule. Do not write a per-field matcher. + --- ## Field rules @@ -61,6 +127,18 @@ Must come from the constitution's declared layers. Do not invent layer names. If ### Acceptance Must be **observable**: a passing test, a 201 response, a method called exactly once. "Feels right" or "code is cleaner" are not acceptable. Every acceptance criterion must be verifiable without running the full app (unit/integration test preferred). +### Covers + +Comma-separated scenario (SC-) and success-criterion (AC-) IDs from `00-spec.md` that +this task implements or proves. `none` is allowed only for pure wiring/polish tasks that +advance no criterion directly. Every ID referenced must exist in the spec; every SC and AC in +the spec must be covered by at least one task - `/sd:verify` fails the spec otherwise. Specs +authored before this field existed (no SC/AC IDs) are handled by `/sd:verify`'s generic +checks; treat a missing field as `Covers: none` when reading legacy `02-tasks.md` files. +Spec types whose templates carry no SC-/AC-IDs (refactor, bug, perf, rca) use `Covers: none` +for every task - the coverage requirement (VF010/VF011) applies only to specs whose +`00-spec.md` declares SC-/AC-IDs, currently feature specs. + ### Estimated complexity | Value | Guideline | |---|---| @@ -79,7 +157,7 @@ Must be **observable**: a passing test, a 201 response, a method called exactly Drive sequencing and batch planning. Tasks that **conflict** cannot run in the same parallel batch. Tasks that **depend on** a prior task cannot start until that task's acceptance criterion is met. ### Pattern refs -1-3 `file:line` citations of precedent code the implementer reads BEFORE writing, each with a one-line instruction of what to mirror (e.g. "mirror handler structure and registration", "reuse this helper - do not duplicate"). Required for tasks that create a new file or a new public symbol; `none` only for tasks that exclusively edit existing files. Verify each cited file exists before writing the ref. Discovery and adherence rules live in the **sd-pattern-discipline** skill. +1-3 `file:line` citations of precedent code the implementer reads BEFORE writing, each with a one-line instruction of what to mirror (e.g. "mirror handler structure and registration", "reuse this helper - do not duplicate"). Required on every task; write `none` explicitly when there is genuinely no precedent to cite, which should be rare outside pure polish work. Verify each cited file exists before writing the ref. Discovery and adherence rules live in the **sd-pattern-discipline** skill. --- @@ -100,3 +178,6 @@ Drive sequencing and batch planning. Tasks that **conflict** cannot run in the s - Leaving `Depends on` / `Conflicts with` empty when sequential or conflicting relationships exist. - Authoring a new-file task with `Pattern refs: none` - the implementer has no precedent to mirror. - Citing Pattern refs you did not verify exist. +- Omitting `Pattern refs` instead of writing `none`. Silence is not an assertion; `SL060` flags it. +- Burying a precedent in prose beside the block instead of putting it in the field. `sd-implementer` + reads `TASK_DETAILS`, not the surrounding narrative - a ref outside the field does not reach it. diff --git a/skills/sd-pattern-discipline/SKILL.md b/skills/sd-pattern-discipline/SKILL.md index da4d1b7..13e0fc2 100644 --- a/skills/sd-pattern-discipline/SKILL.md +++ b/skills/sd-pattern-discipline/SKILL.md @@ -66,8 +66,11 @@ For every task that creates a new file or introduces a new public symbol: - Deviation from an explicit Pattern ref -> WARN, anchored to the task block. - Convention drift with no Pattern ref and no constitution anchor -> SUGGEST. - A new utility duplicating an existing one -> WARN, citing both `file:line`. -- Never BLOCK solely because a task lacks a `Pattern refs` field - the field is additive and - older specs predate it. +- Never BLOCK solely because a task lacks a `Pattern refs` field. The field is required on every + task, but a missing one is a spec-authoring defect, not a defect in the code under review - it + belongs to `/sd:spec validate` as `SL060` (WARN), and older specs predate the requirement + entirely. Reviewing the code against the nearest sibling file is the right response; failing the + review is not. --- diff --git a/skills/sd-replan-loop/SKILL.md b/skills/sd-replan-loop/SKILL.md new file mode 100644 index 0000000..c9c4457 --- /dev/null +++ b/skills/sd-replan-loop/SKILL.md @@ -0,0 +1,124 @@ +# sd-replan-loop + +Sanctioned mid-execution re-plan protocol for specwright. Lets a workflow adapt when execution (or +the batch review) reveals that `02-tasks.md` is wrong, **without** violating spec immutability or +skipping a gate. Referenced at runtime by `/sd:feature` and `/sd:refactor` - the only two workflows +that produce a `01-plan.md` + `02-tasks.md` pair to re-plan. + +Not used by `/sd:bug`, `/sd:perf`, or `/sd:rca`: bug and rca produce no atomic task list, and perf +already carries its own adaptive loop (Phase 4 reverts a failed hypothesis and re-selects at Gate 4). + +--- + +## The problem this solves + +The Planning pattern's strength is **adaptivity** - re-planning when execution surfaces new +information. specwright's immutability rule is right for audit, but it left the adaptive path +**undefined**: when an implementer discovers mid-Execute that a task's premise is false, or the batch +review finds the plan itself was wrong, there was no sanctioned move. The failure mode is a model +silently hack-editing `02-tasks.md` (violating sequencing, leaving no trail) or stalling. + +The discovery point is **not only mid-task**. In the one measured corpus, the single real case of a +wrong plan surfaced at **batch review**: an implementer found a spec decision was wrong, hand-edited +the task, corrected the spec, and left one sentence in the retro - no gate, no append-only record. +The re-plan gate must be reachable from **both** the Execute phase and the review phase. + +--- + +## What counts as a plan-invalidating discovery + +A re-plan is warranted only when the **plan** is wrong, not when a single task needs a normal +implementation adjustment. Trigger the loop when: + +- A task's stated premise is false - a `Pattern refs` precedent does not exist, an interface differs + from what the task assumed, a `Depends on` edge is backwards, or the `Files` list cannot carry the + change. +- A task is now known to be missing, redundant, or mis-sequenced given what execution revealed. +- The batch review (feature Phase 5, refactor Phase 6) finds the plan itself is wrong - the classic + case: a spec/plan decision that only proves incorrect once the code is written. + +Do **not** trigger the loop for work that stays inside one task's contract (a rename, a helper reuse, +a test tweak). That is ordinary implementation, handled by the implementer's own scope. Re-planning +is for changing the **plan**, not for doing the task. + +--- + +## Gate Re-plan (HARD) + +When a plan-invalidating discovery lands, the workflow returns to this gate. It is HARD - it STOPS +and waits for explicit user approval; silence is not approval, and there is no override path. + +1. **Surface the trigger.** Present to the user: what was discovered, which task(s) it invalidates, + and the proposed delta. Ask: + + > Re-plan ``? Discovery: ``. Affects ``. (approve / revise `` / abort task) + + - `approve` -> proceed to step 2. + - `revise` -> adjust the proposed delta and re-ask. Loop. + - `abort task` -> do not re-plan; return to the workflow's normal abort handling for that task. + +2. **Append a revision entry to `01-plan.md`** (never rewrite prior content - see format below). + Assign the next contiguous revision number `R`. + +3. **Regenerate only the affected tasks in `02-tasks.md`.** Invoke `sd-spec-architect` with + `TASK = plan`, `REPLAN_SCOPE = `, and `REVISION = R`. The architect + rewrites only those task blocks, marks each with `Revised-by: R` (per `sd-atomic-task-format`), + and leaves every other task block byte-for-byte unchanged. + +4. **Resume.** Re-enter the Execute phase at the first affected (now regenerated) task. Already-passed + tasks that the revision did not touch stay checked. + +--- + +## The `## Revisions` log (`01-plan.md`) + +Append-only. Lives at the **end** of `01-plan.md`, below the original plan prose. The original plan +text - phased overview, sequencing rationale, risks - is **never edited**; a re-plan only appends +here. Create the `## Revisions` header on the first revision. + +```markdown +## Revisions + +### R1 - + +- Trigger: +- Phase: +- Gate: re-plan +- Affected tasks: +- Delta: +- revised-from: +``` + +Rules: + +- **Contiguous numbering.** Revisions are `R1`, `R2`, `R3` ... with no gaps and no reuse. `R` + never appears twice. +- **Append-only.** A prior `R` entry is never edited or deleted. A later correction is a new + entry that supersedes it in prose, never a rewrite. +- **Every entry names a gate event.** The `Gate: re-plan` and `Phase:` lines record that the entry + came through this gate, not from a hand-edit. An entry with no gate/phase line is malformed. +- **Symmetry with tasks.** Every `Affected tasks` ID in `R` must name a task block in + `02-tasks.md` that carries `Revised-by: R`, and every task carrying `Revised-by: R` must be + listed in entry `R`. `/sd:spec validate` enforces this both ways (`SL070`-`SL073`). + +--- + +## Immutability boundary + +- **Never re-plan a `done` spec.** The loop runs only while the spec is `in-progress`. In-progress + specs are mutable by definition; this protocol never touches a `done` (or `archived`) spec. +- **The original plan prose is intact.** Only the `## Revisions` section grows. +- **Regenerated task blocks are replaced in place** in `02-tasks.md`; their history lives in the + `## Revisions` log and the `revised-from` pointer, not in stale text left behind. + +--- + +## What validate can and cannot see (honest scope) + +`/sd:spec validate` is a **static linter** over `.specs/`. It has no snapshot of `02-tasks.md` as it +stood at Plan phase, so it **cannot** detect an arbitrary silent edit by diffing. What it enforces is +the **internal consistency of the revision record** (`SL070`-`SL073`): a task marked `Revised-by: R` +with no backing entry, an entry naming tasks that do not carry the marker, non-contiguous or rewritten +history, a malformed entry. A compliant re-plan always marks its work, so the linter catches a +**broken record**; an unmarked hack-edit stays invisible to the linter and is prevented by the gate, +not the lint. State this boundary plainly - do not imply the linter diffs the file. diff --git a/skills/sd-retro-lessons/SKILL.md b/skills/sd-retro-lessons/SKILL.md new file mode 100644 index 0000000..c963d36 --- /dev/null +++ b/skills/sd-retro-lessons/SKILL.md @@ -0,0 +1,133 @@ +# sd-retro-lessons + +Lesson-extraction discipline for specwright. Turns retro prose into privacy-safe, reusable +one-line rules that transfer to a codebase the author has never seen. + +This skill is the authority on the tag enum, the lesson record shape, and the abstraction +rules. `scripts/validate-lessons.ps1` / `.sh` enforce the mechanical half; everything the +validator cannot see is your job. + +--- + +## Why a lesson is not a retro note + +A retro note records what happened *here*. A lesson records what should be done *anywhere*. + +``` +retro note The League index copied CASESENSITIVE from euro-sportsbook, but the shared + RedisQueryBuilder emits lowercase, so every dated query returned empty. + +lesson When mirroring a sibling repository's implementation, verify the local shared + helper produces the same casing before copying an index or query attribute. +``` + +Same insight. The second one carries no identifiers, transfers to any stack, and can be +shared outside the org. Producing the second from the first is the whole skill. + +--- + +## The tag enum + +Ten tags, derived from a mined corpus of real retros - not authored up front. Every tag +below traces to at least one observed occurrence. + +| Tag | Fires when | +|---|---| +| `sibling-repo-assumption` | Behaviour copied from a reference or sibling repo without verifying the local equivalent. | +| `missed-context` | The spec missed something that impact analysis, review, or execution later surfaced. | +| `baseline-attribution` | A pre-existing failure was blamed on, or dismissed because of, the current change without baselining. | +| `tooling-surprise` | A tool did something correct but unexpected that altered the working state. | +| `gate-friction` | A gate was waived, deferred, or satisfied by exception rather than met. | +| `config-drift` | Project configuration no longer matches the repository it describes. | +| `test-fragility` | A test was written coupled to names, literals, or reflection, knowingly or not. | +| `test-gap` | No test surface existed for the affected area and one had to be created mid-work. | +| `precedent-conflict` | A constitution rule conflicted with an established pattern already in the codebase. | +| `scope-discipline` | An in-scope-looking fix was correctly declined, or incorrectly absorbed. | + +**The enum is capped at 12.** Two slots are deliberately free. Adding a tag requires a PR +that cites the retro which produced it. When a candidate lesson fits two existing tags, +pick the more specific one - do not invent a third. + +**Retired:** `pattern-violation`. Every candidate instance resolved into either +`sibling-repo-assumption` or `precedent-conflict`. A tag that overlaps two others gets +applied inconsistently and poisons selection. + +--- + +## Record shape + +One line. Exactly this grammar: + +``` +- [tag] severity/scope: Rule sentence. +``` + +- `tag` - from the enum above, lowercase kebab. +- `severity` - `high` | `medium` | `low`. High means it would have shipped a defect or lost + a day. Low means it cost minutes. +- `scope` - `feature` | `bug` | `refactor` | `perf` | `rca` | `all`. This is the selector a + future run filters on, so `all` must be earned: use it only when the rule genuinely does + not depend on the workflow. +- `Rule sentence` - one sentence, imperative, **120 characters maximum**. + +After aggregation a repeat count may be appended - ` (3)`. Authors never write it. + +``` +- [sibling-repo-assumption] high/feature: When mirroring a sibling repository, verify the local shared helper matches before copying an attribute. +- [baseline-attribution] medium/all: Confirm pre-existing failures against a clean baseline before attributing or dismissing them. +- [gate-friction] medium/refactor: When waiving a coverage gate, record measured coverage, residual risk, and what would satisfy it later. +``` + +--- + +## Privacy rules + +`.specs/_lessons/lessons.md` is designed to be shared outside the organisation as-is. The +rule sentence must therefore contain **zero identifiers**. + +Forbidden in a rule sentence: + +- Path separators (`/`, `\`) and file extensions. +- Backticks - if you need one, the sentence is still describing code. +- Line citations (`:84`). +- PascalCase or camelCase identifiers - class, method, variable, and type names. +- snake_case identifiers. +- Repository, product, team, customer, or person names. + +Technology proper nouns that happen to be PascalCase (PowerShell, TypeScript, PostgreSQL, +and similar) are allowed via a small allowlist in the validator. Extending that allowlist +takes a PR - and usually means the lesson is less portable than you think. + +**The validator is a lint, not a guarantee.** It cannot see "the payment team's nightly +reconciliation job" written in plain prose. That leaks just as badly as a class name. You +carry that part. + +--- + +## Extraction procedure + +1. Read the retro's **Surprises**, **Deferred follow-ups**, and **Constitution exceptions** + sections first. That is where lessons concentrate. +2. Skip auto-generated content. A retro line matching `Status: X -> Y. Reason: ...` was + written by `/sd:spec status` or `/sd:release`. It carries no lesson. A retro consisting + only of such lines yields zero lessons - that is a correct outcome, not a failure. +3. For each candidate, ask: **would this have helped someone on a different codebase?** + If no, it is a note, not a lesson. Drop it. +4. Strip identifiers. Restate as an imperative rule. Re-read it as a stranger. +5. Tag, rate severity, pick the narrowest true scope. +6. Run the validator before proposing the line. + +--- + +## Anti-patterns + +- **Tagging without abstracting.** Copying the retro sentence and prefixing a tag. The + result leaks, and it teaches nothing outside this repo. +- **Restating the obvious.** "Write tests before merging" is not a lesson, it is a slogan. + A lesson names a specific trap someone actually fell into. +- **`scope: all` by default.** It defeats selection; every run gets every lesson. +- **One lesson per task.** A spec with twelve tasks does not produce twelve lessons. Most + specs produce zero to two. Zero is a normal, healthy outcome. +- **Inventing a tag** because none of the ten felt perfect. Pick the closest, or open a PR. +- **Escalating on repeat.** A lesson that reappears gets a count, never a higher severity. + Severity describes the trap; frequency is a separate axis and is already recorded. diff --git a/skills/sd-severity-taxonomy/SKILL.md b/skills/sd-severity-taxonomy/SKILL.md index 4b842be..5a5cff0 100644 --- a/skills/sd-severity-taxonomy/SKILL.md +++ b/skills/sd-severity-taxonomy/SKILL.md @@ -16,9 +16,23 @@ Every finding must carry exactly one severity marker. Severities are NOT interch --- +## Anchors + +Every BLOCK or WARN must cite an anchor. **No anchor = no BLOCK/WARN.** Which anchors are legal +depends on what is being reviewed โ€” they are not interchangeable, and a caller may use only the +row that matches its target: + +| Target | Legal anchors | Used by | +|---|---|---| +| Code | A constitution `ยงN.M` section OR a spec acceptance criterion | `sd-reviewer` | +| The `.specs/` tree itself | A lint rule ID (e.g. `SL003`) from the rule table in `/sd:spec validate` | `/sd:spec validate` | + +The code row is the strict one and stays strict: reviewing code against a lint rule ID is not a +thing, and "the spec tree has its own anchors" is never a reason to relax it. A code finding with +no `ยงN.M` and no acceptance criterion is still not a finding. + ## Rules -- Every BLOCK or WARN must cite a constitution `ยงN.M` section OR a spec acceptance criterion. No anchor = no BLOCK/WARN. - SUGGEST is "if you have time". WARN is "we should address this". Never conflate them. - Style preferences are WARN at most. Constitution-mandated style is the only exception that can be BLOCK. - PASS findings are short (one-liner) and used sparingly to flag non-obvious compliance. @@ -70,5 +84,5 @@ If a section has zero findings, write `_No findings._` โ€” do not omit the secti - Conflating SUGGEST with WARN. - Marking style preferences as BLOCK (unless constitution-mandated). -- Issuing BLOCK without a `ยงN.M` reference or spec acceptance criterion. +- Issuing BLOCK without a legal anchor for the target (see "Anchors"). - Omitting PASS notes on non-obvious compliant areas (reviewer reports are also a positive signal). diff --git a/skills/sd-spec-templates/SKILL.md b/skills/sd-spec-templates/SKILL.md index 7aa90a4..0b17421 100644 --- a/skills/sd-spec-templates/SKILL.md +++ b/skills/sd-spec-templates/SKILL.md @@ -9,7 +9,11 @@ Each template has a dedicated section below. Read only the section matching the - Output goes to the **file**, not to the prose response. Response = one-paragraph summary + file path. - Follow the template structure **exactly** โ€” sections, order, headings. Reordering breaks downstream agents. -- Cross-phase fields marked `TBD - Phase N fills` must be left empty. Do not pre-fill them. +- Cross-phase fields carry a `<>` token. Leave the token **verbatim** โ€” do + not pre-fill it, do not delete it. Phase N of the owning workflow replaces it with measured + evidence. `/sd:spec validate` fails a `draft` or `approved` spec whose phase-deferred tokens are + already filled, so pre-filling is caught, not just discouraged. +- Author-fill fields use the plain `<>` token and must be replaced before `approved`. - `created` = current UTC date in `YYYY-MM-DD`. - If anything is genuinely uncertain, surface as an **Open question** โ€” never silently allow. @@ -17,13 +21,17 @@ Each template has a dedicated section below. Read only the section matching the | Type | Fields | |---|---| -| All | `id`, `type`, `status: draft`, `created` | -| Feature | `jira` (or `none`) | +| All | `id`, `type`, `status: draft`, `created`, `linked_specs` | +| Feature | `jira` (or `none`), `complexity` (`S` \| `M` \| `L`) | | Bug | `severity` (P0โ€“P3), `jira` | | Refactor | `smell` | | Perf | `target_metric` | | RCA | `severity`, `incident_started`, `incident_resolved` | +`linked_specs` is always `[]` at authoring time. Cross-references are written later by +`/sd:spec link`, which maintains the inverse entry on the other spec โ€” never hand-write the list, +and never add a "Linked specs" body section. A one-sided link fails `/sd:spec validate`. + --- ## feature.template.md @@ -34,9 +42,71 @@ Fill: - **Success criteria** โ€” concrete and checkable (observable test outcome, not "works correctly"). - **Out of scope** โ€” explicit list; prevents scope creep disputes. - **Open questions** โ€” real ambiguities only; don't pad. +- **`complexity` frontmatter** โ€” the whole-spec size estimate, `S` | `M` | `L`, with a one-line + rationale in the trailing comment. See "Complexity estimate" below. + +- Scenario headings use stable IDs: `### SC-: `. IDs are sequential from SC-1 and are + never renumbered or reused after a scenario is deleted - downstream `Covers` fields and + `/sd:verify` reports reference them. +- Success criteria use stable IDs: `- [ ] AC-: `. Same stability rule as SC IDs. +- Every SC and AC ID must be covered by at least one task's `Covers` field in `02-tasks.md` + before `/sd:verify` can pass (see sd-atomic-task-format). Constitution check: list applicable `ยงN.M` references. Flag any potential violation as an Open question. +### Complexity estimate (feature only) + +The `complexity` frontmatter field is the architect's whole-spec size estimate. It exists to route +oversized work into the regime the engine handles well: at Gate 2, `/sd:feature` measures the real +plan against the thresholds below and, if the plan exceeds them, refuses a single oversized plan and +forces a decompose into medium child specs (see `commands/feature.md`, Gate Complexity). + +**`complexity` is a spec-level field. It is NOT the same as a task's `Estimated complexity` +field in `02-tasks.md`.** The task field sizes one line item; this field sizes the whole feature. +They share the `S` | `M` | `L` vocabulary on purpose (one house currency) but answer different +questions. Never conflate them. + +**Estimate at create, measure at plan.** At `create` the architect has only Why / What / SC / AC / +Open questions โ€” no plan yet โ€” so `complexity` is an honest *estimate*, written with a one-line +rationale. It is not a measured field, so it is a plain author-fill token, not a `<>` +token, and it is filled at create time. Phase 3 is where the plan is *measured* against the +thresholds; the create-time estimate does not have to be re-derived, but it must be a genuine +judgement, not always `M`. + +Rubric for the estimate: + +| Value | Guideline (estimate) | +|---|---| +| `S` | One layer, a handful of tasks (โ‰ˆ1-4), single subsystem, no unresolved Open questions. | +| `M` | Up to 2 layers, โ‰ˆ5-8 tasks, one boundary crossing. The regime the engine plans well. | +| `L` | Spans > 2 production layers/subsystems, likely > 8 tasks, or carries unresolved Open questions at plan time. Candidate for decomposition. | + +**Decompose thresholds (measured at Gate 2).** A plan is over-threshold when **any** of these hold: + +- estimated/authored tasks **> 8** +- spans **> 2** production layers/subsystems โ€” count the distinct `Layer` values across tasks, but + **exclude `Tests` and `Config`**: they cross-cut nearly every change, so counting them would make + an ordinary 2-layer feature (e.g. `Application` + `Domain` + `Tests`) read as 3 and over-fire +- impact surface **> 8** files (from `03-decisions.md`) +- **any** unresolved Open question remains at plan time + +The `> 8` task line is set from the only live corpus (`asian-sportsbook-v2`): real feature specs +cluster at 3-4 tasks (medium) and 10-12 tasks (the two specs a human had already hand-split into a +parent + child), with nothing in between โ€” `> 8` sits in that canyon, so mediums pass untouched and +the genuinely large trip the gate. The Tests/Config exclusion is set from the same corpus: the +3-4-task mediums touch `Application` + `Domain` + `Tests`, so a naive layer count would have tripped +the gate on exactly the specs that must pass with zero friction. + +**Count tasks with the tolerant grammar, never a naive regex.** When measuring the task count, use +the **Field label / heading grammar** the way `sd-atomic-task-format` describes โ€” live specs write +task headings as `### T01`, `### โœ… T01`, and other drifted forms. A counter that matches only +`^### T` undercounts real specs and the gate silently never fires. Match tolerantly: a task +heading is an H3 (or deeper) whose text contains a `T` token. + +**A create-time estimate of `L` also escalates models** (aliases only) โ€” `/sd:feature` bumps the +impact explorer and the planning architect a tier. That is a workflow action, documented in +`commands/feature.md`; the architect only writes the estimate. + --- ## bug.template.md diff --git a/specwright.manifest.json b/specwright.manifest.json new file mode 100644 index 0000000..35b4d29 --- /dev/null +++ b/specwright.manifest.json @@ -0,0 +1,224 @@ +{ + "$comment": [ + "Canonical inventory contract for specwright. Read by scripts/validate.ps1 and", + "scripts/validate.sh (Check 7: docs consistency).", + "", + "This file stores NO counts. Counts are derived from the globs below at runtime, so", + "adding a command/agent/skill means adding the file and nothing else. A manifest that", + "hardcoded numbers would become a third place to update and would reintroduce exactly", + "the drift this check exists to prevent.", + "", + "Each area declares EITHER a 'glob' or an explicit 'files' list, never both.", + "Glob semantics: matches FILES only (directories are never counted), non-recursive,", + "with '*' matching one path segment. Supported by bash globbing and PowerShell", + "Get-ChildItem alike - do not introduce '**' without teaching both validators.", + "", + "Regex semantics: patterns must be valid in BOTH POSIX ERE (bash [[ =~ ]]) and .NET", + "(PowerShell Select-String). Use [0-9] not \\d, no lookarounds, and do not escape", + "backticks. Exactly one capture group, wrapping the number under test." + ], + + "areas": { + "commands": { + "glob": "commands/*.md", + "installTo": "commands/sd/", + "description": "Slash command workflow definitions" + }, + "agents": { + "glob": "agents/*.md", + "installTo": "agents/sd/", + "description": "Subagent prompt files" + }, + "skills": { + "glob": "skills/*/SKILL.md", + "installTo": "skills/sd/", + "description": "Reusable rule packs, one folder per skill" + }, + "hooksPowerShell": { + "glob": "hooks/powershell/*.ps1", + "installTo": "hooks/sd/", + "description": "Hook scripts installed on Windows" + }, + "hooksBash": { + "glob": "hooks/bash/*.sh", + "installTo": "hooks/sd/", + "description": "Hook scripts installed on Unix. Hook-pair parity (Check 3) already asserts this equals hooksPowerShell." + }, + "templatesSetup": { + "glob": "templates/*", + "installTo": "templates/sd/", + "description": "Top-level setup templates. NOT a *.md glob - 2 of the 4 are .json." + }, + "templatesSpec": { + "glob": "templates/specs/*", + "installTo": "templates/sd/specs/", + "description": "Per-workflow spec templates" + }, + "workflowCommands": { + "files": [ + "commands/feature.md", + "commands/bug.md", + "commands/rca.md", + "commands/refactor.md", + "commands/perf.md" + ], + "installTo": "commands/sd/", + "description": "Spec-producing pipelines - a SUBSET of commands (the rest are utilities: spec, explore, review, setup, release, adr, verify, status). Docs cite this subset count separately from the total, so it needs its own quantity. Declared as an explicit list because no glob expresses 'these 5 of the 13'; the validators also assert each file exists." + } + }, + + "derived": { + "templatesTotal": ["templatesSetup", "templatesSpec"], + "installTotal": [ + "commands", + "agents", + "skills", + "hooksPowerShell", + "templatesSetup", + "templatesSpec" + ] + }, + + "historicalExclusions": [ + "docs/history/", + "docs/superpowers/", + "CHANGELOG.md", + "_bmad/", + "_bmad-output/", + "design-artifacts/", + "node_modules/", + ".claude/", + ".superpowers/" + ], + + "$exclusionsComment": [ + "These paths intentionally contain superseded counts ('10 commands / 5 agents') as a", + "past-state record. They are excluded from the undeclared-claim scan and must never be", + "'fixed' to match current disk state. CHANGELOG.md is historical by definition.", + "The remaining entries are untracked/vendored noise, not documentation." + ], + + "claimPhrases": [ + "([Oo]ne|[Tt]wo|[Tt]hree|[Ff]our|[Ff]ive|[Ss]ix|[Ss]even|[Ee]ight|[Nn]ine|[Tt]en|[Ee]leven|[Tt]welve|[Tt]hirteen|[Ff]ourteen|[Ff]ifteen|[Ss]ixteen|[Ss]eventeen|[Ee]ighteen|[Nn]ineteen|[Tt]wenty|[0-9]+) slash commands", + "([Oo]ne|[Tt]wo|[Tt]hree|[Ff]our|[Ff]ive|[Ss]ix|[Ss]even|[Ee]ight|[Nn]ine|[Tt]en|[Ee]leven|[Tt]welve|[Tt]hirteen|[Ff]ourteen|[Ff]ifteen|[Ss]ixteen|[Ss]eventeen|[Ee]ighteen|[Nn]ineteen|[Tt]wenty|[0-9]+) workflow (commands|definitions)", + "([Oo]ne|[Tt]wo|[Tt]hree|[Ff]our|[Ff]ive|[Ss]ix|[Ss]even|[Ee]ight|[Nn]ine|[Tt]en|[Ee]leven|[Tt]welve|[Tt]hirteen|[Ff]ourteen|[Ff]ifteen|[Ss]ixteen|[Ss]eventeen|[Ee]ighteen|[Nn]ineteen|[Tt]wenty|[0-9]+) commands", + "([Oo]ne|[Tt]wo|[Tt]hree|[Ff]our|[Ff]ive|[Ss]ix|[Ss]even|[Ee]ight|[Nn]ine|[Tt]en|[Ee]leven|[Tt]welve|[Tt]hirteen|[Ff]ourteen|[Ff]ifteen|[Ss]ixteen|[Ss]eventeen|[Ee]ighteen|[Nn]ineteen|[Tt]wenty|[0-9]+) (specialized )?subagents", + "([Oo]ne|[Tt]wo|[Tt]hree|[Ff]our|[Ff]ive|[Ss]ix|[Ss]even|[Ee]ight|[Nn]ine|[Tt]en|[Ee]leven|[Tt]welve|[Tt]hirteen|[Ff]ourteen|[Ff]ifteen|[Ss]ixteen|[Ss]eventeen|[Ee]ighteen|[Nn]ineteen|[Tt]wenty|[0-9]+) subagent (definitions|prompt files)", + "([Oo]ne|[Tt]wo|[Tt]hree|[Ff]our|[Ff]ive|[Ss]ix|[Ss]even|[Ee]ight|[Nn]ine|[Tt]en|[Ee]leven|[Tt]welve|[Tt]hirteen|[Ff]ourteen|[Ff]ifteen|[Ss]ixteen|[Ss]eventeen|[Ee]ighteen|[Nn]ineteen|[Tt]wenty|[0-9]+) (specialist )?agents", + "([Oo]ne|[Tt]wo|[Tt]hree|[Ff]our|[Ff]ive|[Ss]ix|[Ss]even|[Ee]ight|[Nn]ine|[Tt]en|[Ee]leven|[Tt]welve|[Tt]hirteen|[Ff]ourteen|[Ff]ifteen|[Ss]ixteen|[Ss]eventeen|[Ee]ighteen|[Nn]ineteen|[Tt]wenty|[0-9]+) (cross-platform |guard-rail )?hooks", + "([Oo]ne|[Tt]wo|[Tt]hree|[Ff]our|[Ff]ive|[Ss]ix|[Ss]even|[Ee]ight|[Nn]ine|[Tt]en|[Ee]leven|[Tt]welve|[Tt]hirteen|[Ff]ourteen|[Ff]ifteen|[Ss]ixteen|[Ss]eventeen|[Ee]ighteen|[Nn]ineteen|[Tt]wenty|[0-9]+) (PowerShell|bash) hooks", + "([Oo]ne|[Tt]wo|[Tt]hree|[Ff]our|[Ff]ive|[Ss]ix|[Ss]even|[Ee]ight|[Nn]ine|[Tt]en|[Ee]leven|[Tt]welve|[Tt]hirteen|[Ff]ourteen|[Ff]ifteen|[Ss]ixteen|[Ss]eventeen|[Ee]ighteen|[Nn]ineteen|[Tt]wenty|[0-9]+) (cross-platform )?hook scripts", + "([Oo]ne|[Tt]wo|[Tt]hree|[Ff]our|[Ff]ive|[Ss]ix|[Ss]even|[Ee]ight|[Nn]ine|[Tt]en|[Ee]leven|[Tt]welve|[Tt]hirteen|[Ff]ourteen|[Ff]ifteen|[Ss]ixteen|[Ss]eventeen|[Ee]ighteen|[Nn]ineteen|[Tt]wenty|[0-9]+) (setup|spec) templates", + "([Oo]ne|[Tt]wo|[Tt]hree|[Ff]our|[Ff]ive|[Ss]ix|[Ss]even|[Ee]ight|[Nn]ine|[Tt]en|[Ee]leven|[Tt]welve|[Tt]hirteen|[Ff]ourteen|[Ff]ifteen|[Ss]ixteen|[Ss]eventeen|[Ee]ighteen|[Nn]ineteen|[Tt]wenty|[0-9]+) templates", + "([Oo]ne|[Tt]wo|[Tt]hree|[Ff]our|[Ff]ive|[Ss]ix|[Ss]even|[Ee]ight|[Nn]ine|[Tt]en|[Ee]leven|[Tt]welve|[Tt]hirteen|[Ff]ourteen|[Ff]ifteen|[Ss]ixteen|[Ss]eventeen|[Ee]ighteen|[Nn]ineteen|[Tt]wenty|[0-9]+) (reusable )?skills", + "([Oo]ne|[Tt]wo|[Tt]hree|[Ff]our|[Ff]ive|[Ss]ix|[Ss]even|[Ee]ight|[Nn]ine|[Tt]en|[Ee]leven|[Tt]welve|[Tt]hirteen|[Ff]ourteen|[Ff]ifteen|[Ss]ixteen|[Ss]eventeen|[Ee]ighteen|[Nn]ineteen|[Tt]wenty|[0-9]+) (reusable )?rule packs" + ], + + "$claimPhrasesComment": [ + "Vocabulary of what an inventory claim looks like. Any line in a tracked .md file that", + "matches one of these but is NOT covered by a docClaims entry below fails Check 7 as an", + "undeclared claim. This is what keeps docClaims canonical: a new doc that publishes a", + "number cannot silently escape the check. Files under historicalExclusions are skipped.", + "", + "The leading alternation is DETECTION vocabulary, not validation input. A docClaims", + "entry compares its captured group against an integer derived from disk, so a spelled-out", + "word can never be validated - only detected, and therefore only rejected as undeclared.", + "That is the intended pressure: see the policy below.", + "", + "Three escapes were found and closed on 2026-07-22 (SW-24). Each had let a real, wrong", + "claim sit in a tracked doc through many green runs:", + " 1. Spelled-out numbers. Every pattern was anchored on [0-9]+, so 'seven reusable", + " skills' in README.md was invisible from the moment an eighth skill shipped.", + " 2. Capitalisation. Adding a lowercase word alternation is NOT enough: a spelled-out", + " number is usually SENTENCE-INITIAL, which is exactly where it is capitalised.", + " 'Three hooks ship in ...' escaped a lowercase-only fix. POSIX ERE (bash [[ =~ ]])", + " has no inline case flag, so each word carries an explicit [Tt]-style class rather", + " than relying on a flag one engine supports and the other does not.", + " 3. Bare nouns. Only decorated forms were listed ('slash commands', 'workflow", + " commands'), so 'Five commands invoke no subagent' matched nothing. Bare 'commands'", + " and 'agents' are now in the vocabulary.", + "", + "The alternation is written out inline on every line rather than factored into a shared", + "key. Factoring it would require BOTH validators to learn how to splice it, and a", + "PowerShell/bash pair that must agree on string assembly is precisely the divergence this", + "repo keeps getting bitten by. The manifest stays declarative; the validators stay dumb." + ], + + "$claimPolicyComment": [ + "POLICY for writing an inventory number in a tracked doc (SW-24):", + "", + " If the number is derivable from an 'areas' or 'derived' quantity, write it in DIGITS", + " and add a docClaims entry. Drift then fails the build.", + "", + " If it is not derivable, do not publish a number at all - name the things instead.", + " A count with no source of truth is a value waiting to rot, and the names beside it", + " usually already carry the meaning.", + "", + "Worked example from the same commit: docs/architecture.md said 'Five commands invoke no", + "subagent at all' and then listed all five by name. The number added nothing the list did", + "not already say, and no area derives it - so the number was removed rather than a new", + "area invented to guard it. By contrast 'Three hooks ship in cross-platform pairs' IS", + "hooksPowerShell, so it became '3 hooks ...' with a docClaims entry." + ], + + "docClaims": [ + { "file": "README.md", "pattern": "^> ([0-9]+) slash commands, ", "equals": "commands" }, + { "file": "README.md", "pattern": "^> [0-9]+ slash commands, ([0-9]+) specialized subagents", "equals": "agents" }, + { "file": "README.md", "pattern": "specialized subagents, ([0-9]+) guard-rail hooks", "equals": "hooksPowerShell" }, + { "file": "README.md", "pattern": "guard-rail hooks, ([0-9]+) templates", "equals": "templatesTotal" }, + { "file": "README.md", "pattern": "guard-rail hooks, [0-9]+ templates, ([0-9]+) reusable skills", "equals": "skills" }, + + { "file": "README.md", "pattern": "^\\| \\*\\*([0-9]+) slash commands\\*\\* \\|", "equals": "commands" }, + { "file": "README.md", "pattern": "^\\| \\*\\*([0-9]+) specialized subagents\\*\\* \\|", "equals": "agents" }, + { "file": "README.md", "pattern": "^\\| \\*\\*([0-9]+) cross-platform hooks\\*\\* \\|", "equals": "hooksPowerShell" }, + { "file": "README.md", "pattern": "^\\| \\*\\*([0-9]+) templates\\*\\* \\|", "equals": "templatesTotal" }, + { "file": "README.md", "pattern": "templates\\*\\* \\| ([0-9]+) setup templates", "equals": "templatesSetup" }, + { "file": "README.md", "pattern": "setup templates \\+ ([0-9]+) spec templates", "equals": "templatesSpec" }, + { "file": "README.md", "pattern": "^\\| \\*\\*([0-9]+) reusable skills\\*\\* \\|", "equals": "skills" }, + + { "file": "CLAUDE.md", "pattern": "commands/sd/` \\| ([0-9]+) slash commands", "equals": "commands" }, + { "file": "CLAUDE.md", "pattern": "agents/sd/` \\| ([0-9]+) subagents", "equals": "agents" }, + { "file": "CLAUDE.md", "pattern": "hooks/sd/` \\| ([0-9]+) hooks ", "equals": "hooksPowerShell" }, + { "file": "CLAUDE.md", "pattern": "templates/sd/` \\| ([0-9]+) setup templates", "equals": "templatesSetup" }, + { "file": "CLAUDE.md", "pattern": "setup templates \\+ ([0-9]+) spec templates in", "equals": "templatesSpec" }, + { "file": "CLAUDE.md", "pattern": "skills/sd/` \\| ([0-9]+) rule packs", "equals": "skills" }, + + { "file": "CONTRIBUTING.md", "pattern": "^ +commands/ +# ([0-9]+) slash commands", "equals": "commands" }, + { "file": "CONTRIBUTING.md", "pattern": "^ +agents/ +# ([0-9]+) subagent definitions", "equals": "agents" }, + { "file": "CONTRIBUTING.md", "pattern": "^ +powershell/ +# ([0-9]+) PowerShell hooks", "equals": "hooksPowerShell" }, + { "file": "CONTRIBUTING.md", "pattern": "^ +bash/ +# ([0-9]+) bash hooks", "equals": "hooksBash" }, + { "file": "CONTRIBUTING.md", "pattern": "^ +templates/ +# ([0-9]+) setup templates", "equals": "templatesSetup" }, + { "file": "CONTRIBUTING.md", "pattern": "^ +specs/ +# ([0-9]+) spec templates", "equals": "templatesSpec" }, + + { "file": "install/README.md", "pattern": "commands/sd/ +([0-9]+) slash commands", "equals": "commands" }, + { "file": "install/README.md", "pattern": "agents/sd/ +([0-9]+) subagent definitions", "equals": "agents" }, + { "file": "install/README.md", "pattern": "hooks/sd/ +([0-9]+) hook scripts", "equals": "hooksPowerShell" }, + { "file": "install/README.md", "pattern": "templates/sd/ +([0-9]+) templates", "equals": "templatesTotal" }, + { "file": "install/README.md", "pattern": "templates \\(([0-9]+) setup", "equals": "templatesSetup" }, + { "file": "install/README.md", "pattern": "setup \\+ ([0-9]+) spec\\)", "equals": "templatesSpec" }, + { "file": "install/README.md", "pattern": "skills/sd/ +([0-9]+) skills", "equals": "skills" }, + { "file": "install/README.md", "pattern": "^\\| `commands/` \\| `commands/sd/` \\| ([0-9]+) \\|", "equals": "commands" }, + { "file": "install/README.md", "pattern": "^\\| `agents/` \\| `agents/sd/` \\| ([0-9]+) \\|", "equals": "agents" }, + { "file": "install/README.md", "pattern": "^\\| `hooks/powershell/`.*\\| ([0-9]+) \\|", "equals": "hooksPowerShell" }, + { "file": "install/README.md", "pattern": "^\\| `hooks/bash/`.*\\| ([0-9]+) \\|", "equals": "hooksBash" }, + { "file": "install/README.md", "pattern": "`templates/sd/` \\| ([0-9]+) \\+ [0-9]+ \\|", "equals": "templatesSetup" }, + { "file": "install/README.md", "pattern": "`templates/sd/` \\| [0-9]+ \\+ ([0-9]+) \\|", "equals": "templatesSpec" }, + { "file": "install/README.md", "pattern": "^\\| `skills/` \\| `skills/sd/` \\| ([0-9]+) \\|", "equals": "skills" }, + { "file": "install/README.md", "pattern": "\\*\\*Total\\*\\*: ([0-9]+) files per OS", "equals": "installTotal" }, + + { "file": "docs/architecture.md", "pattern": "commands/sd/ +([0-9]+) workflow definitions", "equals": "commands" }, + { "file": "docs/architecture.md", "pattern": "agents/sd/ +([0-9]+) subagent prompt files", "equals": "agents" }, + { "file": "docs/architecture.md", "pattern": "hooks/sd/ +([0-9]+) cross-platform hook scripts", "equals": "hooksPowerShell" }, + { "file": "docs/architecture.md", "pattern": "templates/sd/ +([0-9]+) setup \\+", "equals": "templatesSetup" }, + { "file": "docs/architecture.md", "pattern": "setup \\+ ([0-9]+) spec templates", "equals": "templatesSpec" }, + { "file": "docs/architecture.md", "pattern": "skills/sd/ +([0-9]+) reusable rule packs", "equals": "skills" }, + { "file": "docs/architecture.md", "pattern": "lists all ([0-9]+) commands", "equals": "commands" }, + { "file": "docs/architecture.md", "pattern": "^([0-9]+) hooks ship in cross-platform pairs", "equals": "hooksPowerShell" }, + { "file": "docs/architecture.md", "pattern": "^The ([0-9]+) workflow commands have these gate counts", "equals": "workflowCommands" }, + + { "file": "commands/setup.md", "pattern": "commands/sd/ +\\(([0-9]+) workflow commands\\)", "equals": "commands" }, + { "file": "commands/setup.md", "pattern": "agents/sd/ +\\(([0-9]+) specialist agents\\)", "equals": "agents" }, + { "file": "commands/setup.md", "pattern": "hooks/sd/ +\\(([0-9]+) hooks\\)", "equals": "hooksPowerShell" }, + { "file": "commands/setup.md", "pattern": "skills/sd/ +\\(([0-9]+) skills:", "equals": "skills" } + ] +} diff --git a/templates/project-config.template.json b/templates/project-config.template.json index d4c5b2b..945f2ea 100644 --- a/templates/project-config.template.json +++ b/templates/project-config.template.json @@ -123,13 +123,26 @@ "enabled": true, "mode": "warn", "_modes": "block = refuse Edit/Write on code without in-progress spec; warn = log to stderr only; off = skip", - "_use": "Guard rail blocking code edits when no spec is in-progress" + "_use": "Guard rail blocking code edits when no spec is in-progress", + "verifyGate": true, + "_verifyGate_use": "Scoped to FEAT- (feature-spec) rows only - other types (bug, refactor, perf, rca) have no /sd:verify integration yet. true: a FEAT- row may transition to done only when //06-verify.md records 'result: pass' (written by /sd:verify); non-FEAT rows and any false setting keep index.md fully protected as before SW-6." }, "subagentRetro": { "enabled": true, "retroStaleMinutes": 30, "debounceMinutes": 10, - "_use": "Reminder to update stale retros after subagent runs" + "_use": "Reminder to update stale retros after subagent runs", + "injectLessons": true, + "maxLessons": 3, + "_injectLessons_use": "Surface lessons from /_lessons/lessons.md when a subagent finishes work on an in-progress spec. Selection is by workflow type - a FEAT- spec pulls feature-scoped lessons - so there is no ranking. Emitted regardless of retro staleness and of debounceMinutes, which gate only the stale-retro reminder.", + "_maxLessons_use": "Cap on NEW lessons surfaced per subagent stop. Already-surfaced lessons are recorded per session, so a session converges to silence rather than repeating itself. Set to 0 to surface none without disabling the reminder." + }, + "metrics": { + "enabled": true, + "path": ".specs/_metrics/events.jsonl", + "maxSizeKb": 1024, + "_use": "Append-only, metadata-only event log (no code content, no file paths) consumed by the retro loop. Set enabled=false to opt out entirely.", + "_maxSizeKb_use": "Soft byte cap (KB) for events.jsonl. When the live file reaches the cap, the next write rolls it to events.jsonl.1 (single generation, previous roll overwritten) and starts a fresh log. Default 1024 (~1MB, a guess - not tuned against a measured corpus); absent key also means 1024, so pre-existing configs stay bounded. Set to 0 to disable rotation and let the log grow unbounded. Rotation is best-effort: any failure (locked file, read-only dir) is a silent no-op and the hook never stops appending. events.jsonl.1 is a grace buffer, not part of any read contract - a consumer reads the live file only." } } } diff --git a/templates/specs/bug.template.md b/templates/specs/bug.template.md index ca4d383..c4a733f 100644 --- a/templates/specs/bug.template.md +++ b/templates/specs/bug.template.md @@ -5,6 +5,7 @@ severity: <> status: draft jira: <> created: <> +linked_specs: [] --- # <> @@ -54,9 +55,9 @@ created: <> **Status**: TBD - filled by Phase 3 investigation. -<> +<> -**Why this is root cause, not a symptom**: <> +**Why this is root cause, not a symptom**: <> ## Fix approach @@ -66,8 +67,8 @@ created: <> **Status**: TBD - filled after root cause confirmed. -- <> -- <> +- <> +- <> **Scope discipline check**: - [ ] Fix touches only files implicated by root cause diff --git a/templates/specs/feature.template.md b/templates/specs/feature.template.md index af7e358..6cc7fdc 100644 --- a/templates/specs/feature.template.md +++ b/templates/specs/feature.template.md @@ -4,8 +4,19 @@ type: feature status: draft jira: <> created: <> +complexity: <> # <> +linked_specs: [] --- + + + # <> ## Why @@ -18,21 +29,24 @@ created: <> ## What - + -### Scenario 1: <> +### SC-1: <> - **Given** <> - **When** <> - **Then** <> -### Scenario 2: <> +### SC-2: <> - **Given** <> - **When** <> - **Then** <> -### Scenario 3: <> +### SC-3: <> - **Given** <> - **When** <> @@ -40,14 +54,15 @@ created: <> ## Success criteria - + -- [ ] <> -- [ ] <> -- [ ] <> -- [ ] <> -- [ ] Unit + integration tests cover all scenarios above -- [ ] No new constitution exceptions +- [ ] AC-1: <> +- [ ] AC-2: <> +- [ ] AC-3: <> +- [ ] AC-4: <> +- [ ] AC-5: Unit + integration tests cover all scenarios above +- [ ] AC-6: No new constitution exceptions ## Out of scope @@ -73,10 +88,9 @@ created: <> - **ยง3 Quality bars**: <> - **Risk of violation**: <> -## Linked specs + - -- Depends on: <> -- Related to: <> -- Spawns: <> diff --git a/templates/specs/perf.template.md b/templates/specs/perf.template.md index c42efbd..50f24f2 100644 --- a/templates/specs/perf.template.md +++ b/templates/specs/perf.template.md @@ -4,6 +4,7 @@ type: perf status: draft target_metric: <> created: <> +linked_specs: [] --- # <> @@ -13,7 +14,7 @@ created: <> | Field | Value | |---|---| | **Metric** | <> | -| **Current observed** | <> | +| **Current observed** | <> | | **Goal (SLA)** | <> | | **Environment** | <> | | **Load profile** | <> | @@ -57,7 +58,7 @@ created: <> **Status**: TBD - filled by Phase 3 hotspot analysis. -<> +<> ## Results log @@ -77,7 +78,7 @@ created: <> **Status**: TBD - filled at close-out. -- <> +- <> ## Constitution check diff --git a/templates/specs/rca.template.md b/templates/specs/rca.template.md index 182a715..f4e3995 100644 --- a/templates/specs/rca.template.md +++ b/templates/specs/rca.template.md @@ -6,6 +6,7 @@ severity: <> incident_started: <> incident_resolved: <> created: <> +linked_specs: [] --- # RCA: <> @@ -64,15 +65,15 @@ Artifacts: see `04-artifacts/` for logs, screenshots, query results. **Status**: TBD - filled by Phase 2 enumeration. -<> +<> ### Verification results (Phase 3) -- <

>: <> - <> -- <

>: <> - <> +- <>: <> - <> +- <>: <> - <> ## Root cause @@ -81,7 +82,7 @@ Artifacts: see `04-artifacts/` for logs, screenshots, query results. **Status**: TBD - filled when Gate 3 (Root cause confirmed) passes. -<> +<> ## Affected components diff --git a/templates/specs/refactor.template.md b/templates/specs/refactor.template.md index db7ff84..8dff0e9 100644 --- a/templates/specs/refactor.template.md +++ b/templates/specs/refactor.template.md @@ -4,6 +4,7 @@ type: refactor smell: <> status: draft created: <> +linked_specs: [] --- # <> @@ -26,7 +27,7 @@ created: <> - **Primary file**: <> (<> lines) - **Structure**: <> - **Test coverage**: <> -- **Used by**: <> +- **Used by**: <> ## Target state @@ -61,7 +62,7 @@ created: <> **Status**: TBD - filled by Phase 2 impact mapping. -<> +<> ## Test coverage prerequisite @@ -69,9 +70,9 @@ created: <> - **Threshold**: >= <<80>>% line coverage on files in "Current state" -- **Current measured**: <> -- **Gap**: <> -- **Plan to close gap** (if any): <> +- **Current measured**: <> +- **Gap**: <> +- **Plan to close gap** (if any): <> **Gate 2 (Coverage threshold)** is HARD. If coverage is below threshold, characterization tests are written FIRST, then re-measured, then proceed. diff --git a/tests/hooks/fixtures/prompt-router/enabled-absent-emits/expected.json b/tests/hooks/fixtures/prompt-router/enabled-absent-emits/expected.json new file mode 100644 index 0000000..0c6ac13 --- /dev/null +++ b/tests/hooks/fixtures/prompt-router/enabled-absent-emits/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"emitted":true,"workflows":["bug"],"ticketIds":[],"specFolders":[],"inProgress":[],"stderr":"","events":[]} diff --git a/tests/hooks/fixtures/prompt-router/enabled-absent-emits/input.json b/tests/hooks/fixtures/prompt-router/enabled-absent-emits/input.json new file mode 100644 index 0000000..34f467d --- /dev/null +++ b/tests/hooks/fixtures/prompt-router/enabled-absent-emits/input.json @@ -0,0 +1 @@ +{"prompt":"please fix this bug","cwd":"{{CWD}}"} diff --git a/tests/hooks/fixtures/prompt-router/enabled-absent-emits/workspace/.claude/project-config.json b/tests/hooks/fixtures/prompt-router/enabled-absent-emits/workspace/.claude/project-config.json new file mode 100644 index 0000000..cf4233b --- /dev/null +++ b/tests/hooks/fixtures/prompt-router/enabled-absent-emits/workspace/.claude/project-config.json @@ -0,0 +1,5 @@ +{ + "spec": { "dir": ".specs", "indexFile": ".specs/index.md" }, + "ticket": { "pattern": "^[A-Z]+-[0-9]+$" }, + "hooks": { "userPromptRouter": { } } +} diff --git a/tests/hooks/fixtures/prompt-router/enabled-absent-emits/workspace/.specs/index.md b/tests/hooks/fixtures/prompt-router/enabled-absent-emits/workspace/.specs/index.md new file mode 100644 index 0000000..d6f75b2 --- /dev/null +++ b/tests/hooks/fixtures/prompt-router/enabled-absent-emits/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status (in-progress = active work) | Title | +|---|---|---|---| +| FEAT-DONE-002 | feature | done | Finished feature | diff --git a/tests/hooks/fixtures/prompt-router/in-progress-surfaced/expected.json b/tests/hooks/fixtures/prompt-router/in-progress-surfaced/expected.json new file mode 100644 index 0000000..0e6112c --- /dev/null +++ b/tests/hooks/fixtures/prompt-router/in-progress-surfaced/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"emitted":true,"workflows":[],"ticketIds":[],"specFolders":[],"inProgress":["FEAT-TEST-001"],"stderr":"","events":[]} \ No newline at end of file diff --git a/tests/hooks/fixtures/prompt-router/in-progress-surfaced/input.json b/tests/hooks/fixtures/prompt-router/in-progress-surfaced/input.json new file mode 100644 index 0000000..301d5b1 --- /dev/null +++ b/tests/hooks/fixtures/prompt-router/in-progress-surfaced/input.json @@ -0,0 +1 @@ +{"prompt":"hello there","cwd":"{{CWD}}"} \ No newline at end of file diff --git a/tests/hooks/fixtures/prompt-router/in-progress-surfaced/workspace/.claude/project-config.json b/tests/hooks/fixtures/prompt-router/in-progress-surfaced/workspace/.claude/project-config.json new file mode 100644 index 0000000..d2f25e0 --- /dev/null +++ b/tests/hooks/fixtures/prompt-router/in-progress-surfaced/workspace/.claude/project-config.json @@ -0,0 +1,5 @@ +{ + "spec": { "dir": ".specs", "indexFile": ".specs/index.md" }, + "ticket": { "pattern": "^[A-Z]+-[0-9]+$" }, + "hooks": { "userPromptRouter": { "enabled": true } } +} diff --git a/tests/hooks/fixtures/prompt-router/in-progress-surfaced/workspace/.specs/index.md b/tests/hooks/fixtures/prompt-router/in-progress-surfaced/workspace/.specs/index.md new file mode 100644 index 0000000..bbe4179 --- /dev/null +++ b/tests/hooks/fixtures/prompt-router/in-progress-surfaced/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status | Title | +|---|---|---|---| +| FEAT-TEST-001 | feature | in-progress | Conformance fixture feature | diff --git a/tests/hooks/fixtures/prompt-router/route-multi-workflow/expected.json b/tests/hooks/fixtures/prompt-router/route-multi-workflow/expected.json new file mode 100644 index 0000000..60c71e2 --- /dev/null +++ b/tests/hooks/fixtures/prompt-router/route-multi-workflow/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"emitted":true,"workflows":["feature","perf"],"ticketIds":[],"specFolders":[],"inProgress":[],"stderr":"","events":[]} \ No newline at end of file diff --git a/tests/hooks/fixtures/prompt-router/route-multi-workflow/input.json b/tests/hooks/fixtures/prompt-router/route-multi-workflow/input.json new file mode 100644 index 0000000..44bbbf5 --- /dev/null +++ b/tests/hooks/fixtures/prompt-router/route-multi-workflow/input.json @@ -0,0 +1 @@ +{"prompt":"implement this feature, performance is slow","cwd":"{{CWD}}"} \ No newline at end of file diff --git a/tests/hooks/fixtures/prompt-router/route-multi-workflow/workspace/.claude/project-config.json b/tests/hooks/fixtures/prompt-router/route-multi-workflow/workspace/.claude/project-config.json new file mode 100644 index 0000000..d2f25e0 --- /dev/null +++ b/tests/hooks/fixtures/prompt-router/route-multi-workflow/workspace/.claude/project-config.json @@ -0,0 +1,5 @@ +{ + "spec": { "dir": ".specs", "indexFile": ".specs/index.md" }, + "ticket": { "pattern": "^[A-Z]+-[0-9]+$" }, + "hooks": { "userPromptRouter": { "enabled": true } } +} diff --git a/tests/hooks/fixtures/prompt-router/route-multi-workflow/workspace/.specs/index.md b/tests/hooks/fixtures/prompt-router/route-multi-workflow/workspace/.specs/index.md new file mode 100644 index 0000000..d6f75b2 --- /dev/null +++ b/tests/hooks/fixtures/prompt-router/route-multi-workflow/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status (in-progress = active work) | Title | +|---|---|---|---| +| FEAT-DONE-002 | feature | done | Finished feature | diff --git a/tests/hooks/fixtures/prompt-router/route-single-keyword/expected.json b/tests/hooks/fixtures/prompt-router/route-single-keyword/expected.json new file mode 100644 index 0000000..6ef1b20 --- /dev/null +++ b/tests/hooks/fixtures/prompt-router/route-single-keyword/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"emitted":true,"workflows":["bug"],"ticketIds":[],"specFolders":[],"inProgress":[],"stderr":"","events":[]} \ No newline at end of file diff --git a/tests/hooks/fixtures/prompt-router/route-single-keyword/input.json b/tests/hooks/fixtures/prompt-router/route-single-keyword/input.json new file mode 100644 index 0000000..908ba0c --- /dev/null +++ b/tests/hooks/fixtures/prompt-router/route-single-keyword/input.json @@ -0,0 +1 @@ +{"prompt":"please fix this bug","cwd":"{{CWD}}"} \ No newline at end of file diff --git a/tests/hooks/fixtures/prompt-router/route-single-keyword/workspace/.claude/project-config.json b/tests/hooks/fixtures/prompt-router/route-single-keyword/workspace/.claude/project-config.json new file mode 100644 index 0000000..d2f25e0 --- /dev/null +++ b/tests/hooks/fixtures/prompt-router/route-single-keyword/workspace/.claude/project-config.json @@ -0,0 +1,5 @@ +{ + "spec": { "dir": ".specs", "indexFile": ".specs/index.md" }, + "ticket": { "pattern": "^[A-Z]+-[0-9]+$" }, + "hooks": { "userPromptRouter": { "enabled": true } } +} diff --git a/tests/hooks/fixtures/prompt-router/route-single-keyword/workspace/.specs/index.md b/tests/hooks/fixtures/prompt-router/route-single-keyword/workspace/.specs/index.md new file mode 100644 index 0000000..d6f75b2 --- /dev/null +++ b/tests/hooks/fixtures/prompt-router/route-single-keyword/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status (in-progress = active work) | Title | +|---|---|---|---| +| FEAT-DONE-002 | feature | done | Finished feature | diff --git a/tests/hooks/fixtures/prompt-router/silent-disabled/expected.json b/tests/hooks/fixtures/prompt-router/silent-disabled/expected.json new file mode 100644 index 0000000..d18610a --- /dev/null +++ b/tests/hooks/fixtures/prompt-router/silent-disabled/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"emitted":false,"workflows":[],"ticketIds":[],"specFolders":[],"inProgress":[],"stderr":"","events":[]} \ No newline at end of file diff --git a/tests/hooks/fixtures/prompt-router/silent-disabled/input.json b/tests/hooks/fixtures/prompt-router/silent-disabled/input.json new file mode 100644 index 0000000..908ba0c --- /dev/null +++ b/tests/hooks/fixtures/prompt-router/silent-disabled/input.json @@ -0,0 +1 @@ +{"prompt":"please fix this bug","cwd":"{{CWD}}"} \ No newline at end of file diff --git a/tests/hooks/fixtures/prompt-router/silent-disabled/workspace/.claude/project-config.json b/tests/hooks/fixtures/prompt-router/silent-disabled/workspace/.claude/project-config.json new file mode 100644 index 0000000..5cad0dd --- /dev/null +++ b/tests/hooks/fixtures/prompt-router/silent-disabled/workspace/.claude/project-config.json @@ -0,0 +1,5 @@ +{ + "spec": { "dir": ".specs", "indexFile": ".specs/index.md" }, + "ticket": { "pattern": "^[A-Z]+-[0-9]+$" }, + "hooks": { "userPromptRouter": { "enabled": false } } +} diff --git a/tests/hooks/fixtures/prompt-router/silent-disabled/workspace/.specs/index.md b/tests/hooks/fixtures/prompt-router/silent-disabled/workspace/.specs/index.md new file mode 100644 index 0000000..d6f75b2 --- /dev/null +++ b/tests/hooks/fixtures/prompt-router/silent-disabled/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status (in-progress = active work) | Title | +|---|---|---|---| +| FEAT-DONE-002 | feature | done | Finished feature | diff --git a/tests/hooks/fixtures/prompt-router/silent-no-hints/expected.json b/tests/hooks/fixtures/prompt-router/silent-no-hints/expected.json new file mode 100644 index 0000000..d18610a --- /dev/null +++ b/tests/hooks/fixtures/prompt-router/silent-no-hints/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"emitted":false,"workflows":[],"ticketIds":[],"specFolders":[],"inProgress":[],"stderr":"","events":[]} \ No newline at end of file diff --git a/tests/hooks/fixtures/prompt-router/silent-no-hints/input.json b/tests/hooks/fixtures/prompt-router/silent-no-hints/input.json new file mode 100644 index 0000000..301d5b1 --- /dev/null +++ b/tests/hooks/fixtures/prompt-router/silent-no-hints/input.json @@ -0,0 +1 @@ +{"prompt":"hello there","cwd":"{{CWD}}"} \ No newline at end of file diff --git a/tests/hooks/fixtures/prompt-router/silent-no-hints/workspace/.claude/project-config.json b/tests/hooks/fixtures/prompt-router/silent-no-hints/workspace/.claude/project-config.json new file mode 100644 index 0000000..d2f25e0 --- /dev/null +++ b/tests/hooks/fixtures/prompt-router/silent-no-hints/workspace/.claude/project-config.json @@ -0,0 +1,5 @@ +{ + "spec": { "dir": ".specs", "indexFile": ".specs/index.md" }, + "ticket": { "pattern": "^[A-Z]+-[0-9]+$" }, + "hooks": { "userPromptRouter": { "enabled": true } } +} diff --git a/tests/hooks/fixtures/prompt-router/silent-no-hints/workspace/.specs/index.md b/tests/hooks/fixtures/prompt-router/silent-no-hints/workspace/.specs/index.md new file mode 100644 index 0000000..d6f75b2 --- /dev/null +++ b/tests/hooks/fixtures/prompt-router/silent-no-hints/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status (in-progress = active work) | Title | +|---|---|---|---| +| FEAT-DONE-002 | feature | done | Finished feature | diff --git a/tests/hooks/fixtures/prompt-router/ticket-with-spec-folder/expected.json b/tests/hooks/fixtures/prompt-router/ticket-with-spec-folder/expected.json new file mode 100644 index 0000000..5e260ad --- /dev/null +++ b/tests/hooks/fixtures/prompt-router/ticket-with-spec-folder/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"emitted":true,"workflows":[],"ticketIds":["INV-2501"],"specFolders":["FEAT-INV-2501-payment"],"inProgress":[],"stderr":"","events":[]} \ No newline at end of file diff --git a/tests/hooks/fixtures/prompt-router/ticket-with-spec-folder/input.json b/tests/hooks/fixtures/prompt-router/ticket-with-spec-folder/input.json new file mode 100644 index 0000000..aa9548f --- /dev/null +++ b/tests/hooks/fixtures/prompt-router/ticket-with-spec-folder/input.json @@ -0,0 +1 @@ +{"prompt":"continue INV-2501","cwd":"{{CWD}}"} \ No newline at end of file diff --git a/tests/hooks/fixtures/prompt-router/ticket-with-spec-folder/workspace/.claude/project-config.json b/tests/hooks/fixtures/prompt-router/ticket-with-spec-folder/workspace/.claude/project-config.json new file mode 100644 index 0000000..d2f25e0 --- /dev/null +++ b/tests/hooks/fixtures/prompt-router/ticket-with-spec-folder/workspace/.claude/project-config.json @@ -0,0 +1,5 @@ +{ + "spec": { "dir": ".specs", "indexFile": ".specs/index.md" }, + "ticket": { "pattern": "^[A-Z]+-[0-9]+$" }, + "hooks": { "userPromptRouter": { "enabled": true } } +} diff --git a/tests/hooks/fixtures/prompt-router/ticket-with-spec-folder/workspace/.specs/FEAT-INV-2501-payment/.gitkeep b/tests/hooks/fixtures/prompt-router/ticket-with-spec-folder/workspace/.specs/FEAT-INV-2501-payment/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/hooks/fixtures/prompt-router/ticket-with-spec-folder/workspace/.specs/index.md b/tests/hooks/fixtures/prompt-router/ticket-with-spec-folder/workspace/.specs/index.md new file mode 100644 index 0000000..d6f75b2 --- /dev/null +++ b/tests/hooks/fixtures/prompt-router/ticket-with-spec-folder/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status (in-progress = active work) | Title | +|---|---|---|---| +| FEAT-DONE-002 | feature | done | Finished feature | diff --git a/tests/hooks/fixtures/prompt-router/ticket-without-spec-folder/expected.json b/tests/hooks/fixtures/prompt-router/ticket-without-spec-folder/expected.json new file mode 100644 index 0000000..1367eb3 --- /dev/null +++ b/tests/hooks/fixtures/prompt-router/ticket-without-spec-folder/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"emitted":true,"workflows":[],"ticketIds":["INV-9999"],"specFolders":[],"inProgress":[],"stderr":"","events":[]} \ No newline at end of file diff --git a/tests/hooks/fixtures/prompt-router/ticket-without-spec-folder/input.json b/tests/hooks/fixtures/prompt-router/ticket-without-spec-folder/input.json new file mode 100644 index 0000000..2bfabdf --- /dev/null +++ b/tests/hooks/fixtures/prompt-router/ticket-without-spec-folder/input.json @@ -0,0 +1 @@ +{"prompt":"continue INV-9999","cwd":"{{CWD}}"} \ No newline at end of file diff --git a/tests/hooks/fixtures/prompt-router/ticket-without-spec-folder/workspace/.claude/project-config.json b/tests/hooks/fixtures/prompt-router/ticket-without-spec-folder/workspace/.claude/project-config.json new file mode 100644 index 0000000..d2f25e0 --- /dev/null +++ b/tests/hooks/fixtures/prompt-router/ticket-without-spec-folder/workspace/.claude/project-config.json @@ -0,0 +1,5 @@ +{ + "spec": { "dir": ".specs", "indexFile": ".specs/index.md" }, + "ticket": { "pattern": "^[A-Z]+-[0-9]+$" }, + "hooks": { "userPromptRouter": { "enabled": true } } +} diff --git a/tests/hooks/fixtures/prompt-router/ticket-without-spec-folder/workspace/.specs/index.md b/tests/hooks/fixtures/prompt-router/ticket-without-spec-folder/workspace/.specs/index.md new file mode 100644 index 0000000..d6f75b2 --- /dev/null +++ b/tests/hooks/fixtures/prompt-router/ticket-without-spec-folder/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status (in-progress = active work) | Title | +|---|---|---|---| +| FEAT-DONE-002 | feature | done | Finished feature | diff --git a/tests/hooks/fixtures/spec-gate/allow-benign-dotdot-code-file/expected.json b/tests/hooks/fixtures/spec-gate/allow-benign-dotdot-code-file/expected.json new file mode 100644 index 0000000..7f4b5b4 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/allow-benign-dotdot-code-file/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"decision":"allow","permissionDecision":null,"reason":null,"stderr":"","events":[{"ts":"","spec_id":"FEAT-TEST-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".py"}]} \ No newline at end of file diff --git a/tests/hooks/fixtures/spec-gate/allow-benign-dotdot-code-file/input.json b/tests/hooks/fixtures/spec-gate/allow-benign-dotdot-code-file/input.json new file mode 100644 index 0000000..4787289 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/allow-benign-dotdot-code-file/input.json @@ -0,0 +1 @@ +{"tool_name":"Edit","cwd":"{{CWD}}","tool_input":{"file_path":"{{CWD}}/src/../lib/Foo.py"}} diff --git a/tests/hooks/fixtures/spec-gate/allow-benign-dotdot-code-file/workspace/.claude/project-config.json b/tests/hooks/fixtures/spec-gate/allow-benign-dotdot-code-file/workspace/.claude/project-config.json new file mode 100644 index 0000000..a139f1d --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/allow-benign-dotdot-code-file/workspace/.claude/project-config.json @@ -0,0 +1,5 @@ +{ + "spec": { "dir": ".specs", "indexFile": ".specs/index.md" }, + "paths": { "protected": [".specs/constitution.md"] }, + "hooks": { "specGate": { "enabled": true, "mode": "block" } } +} diff --git a/tests/hooks/fixtures/spec-gate/allow-benign-dotdot-code-file/workspace/.specs/index.md b/tests/hooks/fixtures/spec-gate/allow-benign-dotdot-code-file/workspace/.specs/index.md new file mode 100644 index 0000000..bbe4179 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/allow-benign-dotdot-code-file/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status | Title | +|---|---|---|---| +| FEAT-TEST-001 | feature | in-progress | Conformance fixture feature | diff --git a/tests/hooks/fixtures/spec-gate/allow-dir-case-insensitive/expected.json b/tests/hooks/fixtures/spec-gate/allow-dir-case-insensitive/expected.json new file mode 100644 index 0000000..9ed9876 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/allow-dir-case-insensitive/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"decision":"allow","permissionDecision":null,"reason":null,"stderr":"","events":[]} \ No newline at end of file diff --git a/tests/hooks/fixtures/spec-gate/allow-dir-case-insensitive/input.json b/tests/hooks/fixtures/spec-gate/allow-dir-case-insensitive/input.json new file mode 100644 index 0000000..256cf96 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/allow-dir-case-insensitive/input.json @@ -0,0 +1 @@ +{"tool_name":"Edit","cwd":"{{CWD}}","tool_input":{"file_path":"{{CWD}}/Docs/Guide.py"}} diff --git a/tests/hooks/fixtures/spec-gate/allow-dir-case-insensitive/workspace/.claude/project-config.json b/tests/hooks/fixtures/spec-gate/allow-dir-case-insensitive/workspace/.claude/project-config.json new file mode 100644 index 0000000..a139f1d --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/allow-dir-case-insensitive/workspace/.claude/project-config.json @@ -0,0 +1,5 @@ +{ + "spec": { "dir": ".specs", "indexFile": ".specs/index.md" }, + "paths": { "protected": [".specs/constitution.md"] }, + "hooks": { "specGate": { "enabled": true, "mode": "block" } } +} diff --git a/tests/hooks/fixtures/spec-gate/allow-dir-case-insensitive/workspace/.specs/index.md b/tests/hooks/fixtures/spec-gate/allow-dir-case-insensitive/workspace/.specs/index.md new file mode 100644 index 0000000..d6f75b2 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/allow-dir-case-insensitive/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status (in-progress = active work) | Title | +|---|---|---|---| +| FEAT-DONE-002 | feature | done | Finished feature | diff --git a/tests/hooks/fixtures/spec-gate/allow-disabled/expected.json b/tests/hooks/fixtures/spec-gate/allow-disabled/expected.json new file mode 100644 index 0000000..9ed9876 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/allow-disabled/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"decision":"allow","permissionDecision":null,"reason":null,"stderr":"","events":[]} \ No newline at end of file diff --git a/tests/hooks/fixtures/spec-gate/allow-disabled/input.json b/tests/hooks/fixtures/spec-gate/allow-disabled/input.json new file mode 100644 index 0000000..4eef248 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/allow-disabled/input.json @@ -0,0 +1 @@ +{"tool_name":"Edit","cwd":"{{CWD}}","tool_input":{"file_path":"{{CWD}}/src/Foo.py"}} diff --git a/tests/hooks/fixtures/spec-gate/allow-disabled/workspace/.claude/project-config.json b/tests/hooks/fixtures/spec-gate/allow-disabled/workspace/.claude/project-config.json new file mode 100644 index 0000000..8fa4b2b --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/allow-disabled/workspace/.claude/project-config.json @@ -0,0 +1,5 @@ +{ + "spec": { "dir": ".specs", "indexFile": ".specs/index.md" }, + "paths": { "protected": [".specs/constitution.md"] }, + "hooks": { "specGate": { "enabled": false, "mode": "block" } } +} diff --git a/tests/hooks/fixtures/spec-gate/allow-disabled/workspace/.specs/index.md b/tests/hooks/fixtures/spec-gate/allow-disabled/workspace/.specs/index.md new file mode 100644 index 0000000..d6f75b2 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/allow-disabled/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status (in-progress = active work) | Title | +|---|---|---|---| +| FEAT-DONE-002 | feature | done | Finished feature | diff --git a/tests/hooks/fixtures/spec-gate/allow-doc-edit/expected.json b/tests/hooks/fixtures/spec-gate/allow-doc-edit/expected.json new file mode 100644 index 0000000..9ed9876 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/allow-doc-edit/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"decision":"allow","permissionDecision":null,"reason":null,"stderr":"","events":[]} \ No newline at end of file diff --git a/tests/hooks/fixtures/spec-gate/allow-doc-edit/input.json b/tests/hooks/fixtures/spec-gate/allow-doc-edit/input.json new file mode 100644 index 0000000..b1b0cce --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/allow-doc-edit/input.json @@ -0,0 +1 @@ +{"tool_name":"Edit","cwd":"{{CWD}}","tool_input":{"file_path":"{{CWD}}/docs/guide.md"}} diff --git a/tests/hooks/fixtures/spec-gate/allow-doc-edit/workspace/.claude/project-config.json b/tests/hooks/fixtures/spec-gate/allow-doc-edit/workspace/.claude/project-config.json new file mode 100644 index 0000000..a139f1d --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/allow-doc-edit/workspace/.claude/project-config.json @@ -0,0 +1,5 @@ +{ + "spec": { "dir": ".specs", "indexFile": ".specs/index.md" }, + "paths": { "protected": [".specs/constitution.md"] }, + "hooks": { "specGate": { "enabled": true, "mode": "block" } } +} diff --git a/tests/hooks/fixtures/spec-gate/allow-doc-edit/workspace/.specs/index.md b/tests/hooks/fixtures/spec-gate/allow-doc-edit/workspace/.specs/index.md new file mode 100644 index 0000000..d6f75b2 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/allow-doc-edit/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status (in-progress = active work) | Title | +|---|---|---|---| +| FEAT-DONE-002 | feature | done | Finished feature | diff --git a/tests/hooks/fixtures/spec-gate/allow-in-progress-spec/expected.json b/tests/hooks/fixtures/spec-gate/allow-in-progress-spec/expected.json new file mode 100644 index 0000000..7f4b5b4 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/allow-in-progress-spec/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"decision":"allow","permissionDecision":null,"reason":null,"stderr":"","events":[{"ts":"","spec_id":"FEAT-TEST-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".py"}]} \ No newline at end of file diff --git a/tests/hooks/fixtures/spec-gate/allow-in-progress-spec/input.json b/tests/hooks/fixtures/spec-gate/allow-in-progress-spec/input.json new file mode 100644 index 0000000..4eef248 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/allow-in-progress-spec/input.json @@ -0,0 +1 @@ +{"tool_name":"Edit","cwd":"{{CWD}}","tool_input":{"file_path":"{{CWD}}/src/Foo.py"}} diff --git a/tests/hooks/fixtures/spec-gate/allow-in-progress-spec/workspace/.claude/project-config.json b/tests/hooks/fixtures/spec-gate/allow-in-progress-spec/workspace/.claude/project-config.json new file mode 100644 index 0000000..a139f1d --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/allow-in-progress-spec/workspace/.claude/project-config.json @@ -0,0 +1,5 @@ +{ + "spec": { "dir": ".specs", "indexFile": ".specs/index.md" }, + "paths": { "protected": [".specs/constitution.md"] }, + "hooks": { "specGate": { "enabled": true, "mode": "block" } } +} diff --git a/tests/hooks/fixtures/spec-gate/allow-in-progress-spec/workspace/.specs/index.md b/tests/hooks/fixtures/spec-gate/allow-in-progress-spec/workspace/.specs/index.md new file mode 100644 index 0000000..bbe4179 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/allow-in-progress-spec/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status | Title | +|---|---|---|---| +| FEAT-TEST-001 | feature | in-progress | Conformance fixture feature | diff --git a/tests/hooks/fixtures/spec-gate/allow-index-done-with-verify-bundled-edit/expected.json b/tests/hooks/fixtures/spec-gate/allow-index-done-with-verify-bundled-edit/expected.json new file mode 100644 index 0000000..3f38f1e --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/allow-index-done-with-verify-bundled-edit/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"decision":"allow","permissionDecision":null,"reason":null,"stderr":"","events":[{"ts":"","spec_id":"FEAT-001","phase":"done","event":"gate","gate":"verify","decision":"allow"},{"ts":"","spec_id":"FEAT-001","phase":"done","event":"spec_transition","from":"in-progress","decision":"allow"}]} \ No newline at end of file diff --git a/tests/hooks/fixtures/spec-gate/allow-index-done-with-verify-bundled-edit/input.json b/tests/hooks/fixtures/spec-gate/allow-index-done-with-verify-bundled-edit/input.json new file mode 100644 index 0000000..38aa3a2 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/allow-index-done-with-verify-bundled-edit/input.json @@ -0,0 +1 @@ +{"tool_name":"Write","cwd":"{{CWD}}","tool_input":{"file_path":"{{CWD}}/.specs/index.md","content":"| ID | Type | Status | Title |\n|---|---|---|---|\n| FEAT-001 | feature | done | Demo feature |\n| FEAT-002 | feature | in-progress | Renamed other feature |\n"}} diff --git a/tests/hooks/fixtures/spec-gate/allow-index-done-with-verify-bundled-edit/workspace/.specs/FEAT-001/06-verify.md b/tests/hooks/fixtures/spec-gate/allow-index-done-with-verify-bundled-edit/workspace/.specs/FEAT-001/06-verify.md new file mode 100644 index 0000000..879f439 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/allow-index-done-with-verify-bundled-edit/workspace/.specs/FEAT-001/06-verify.md @@ -0,0 +1,8 @@ +--- +spec: FEAT-001 +result: pass +date: 2026-07-20 +failures: 0 +--- + +# Verification report - FEAT-001 diff --git a/tests/hooks/fixtures/spec-gate/allow-index-done-with-verify-bundled-edit/workspace/.specs/index.md b/tests/hooks/fixtures/spec-gate/allow-index-done-with-verify-bundled-edit/workspace/.specs/index.md new file mode 100644 index 0000000..dcc30b0 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/allow-index-done-with-verify-bundled-edit/workspace/.specs/index.md @@ -0,0 +1,4 @@ +| ID | Type | Status | Title | +|---|---|---|---| +| FEAT-001 | feature | in-progress | Demo feature | +| FEAT-002 | feature | in-progress | Other feature | diff --git a/tests/hooks/fixtures/spec-gate/allow-index-done-with-verify/expected.json b/tests/hooks/fixtures/spec-gate/allow-index-done-with-verify/expected.json new file mode 100644 index 0000000..3f38f1e --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/allow-index-done-with-verify/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"decision":"allow","permissionDecision":null,"reason":null,"stderr":"","events":[{"ts":"","spec_id":"FEAT-001","phase":"done","event":"gate","gate":"verify","decision":"allow"},{"ts":"","spec_id":"FEAT-001","phase":"done","event":"spec_transition","from":"in-progress","decision":"allow"}]} \ No newline at end of file diff --git a/tests/hooks/fixtures/spec-gate/allow-index-done-with-verify/input.json b/tests/hooks/fixtures/spec-gate/allow-index-done-with-verify/input.json new file mode 100644 index 0000000..1d183c0 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/allow-index-done-with-verify/input.json @@ -0,0 +1 @@ +{"tool_name":"Edit","cwd":"{{CWD}}","tool_input":{"file_path":"{{CWD}}/.specs/index.md","old_string":"| FEAT-001 | feature | in-progress | Demo feature |","new_string":"| FEAT-001 | feature | done | Demo feature |"}} diff --git a/tests/hooks/fixtures/spec-gate/allow-index-done-with-verify/workspace/.specs/FEAT-001/06-verify.md b/tests/hooks/fixtures/spec-gate/allow-index-done-with-verify/workspace/.specs/FEAT-001/06-verify.md new file mode 100644 index 0000000..879f439 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/allow-index-done-with-verify/workspace/.specs/FEAT-001/06-verify.md @@ -0,0 +1,8 @@ +--- +spec: FEAT-001 +result: pass +date: 2026-07-20 +failures: 0 +--- + +# Verification report - FEAT-001 diff --git a/tests/hooks/fixtures/spec-gate/allow-index-done-with-verify/workspace/.specs/index.md b/tests/hooks/fixtures/spec-gate/allow-index-done-with-verify/workspace/.specs/index.md new file mode 100644 index 0000000..4b0a87b --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/allow-index-done-with-verify/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status | Title | +|---|---|---|---| +| FEAT-001 | feature | in-progress | Demo feature | diff --git a/tests/hooks/fixtures/spec-gate/allow-mode-off/expected.json b/tests/hooks/fixtures/spec-gate/allow-mode-off/expected.json new file mode 100644 index 0000000..9ed9876 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/allow-mode-off/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"decision":"allow","permissionDecision":null,"reason":null,"stderr":"","events":[]} \ No newline at end of file diff --git a/tests/hooks/fixtures/spec-gate/allow-mode-off/input.json b/tests/hooks/fixtures/spec-gate/allow-mode-off/input.json new file mode 100644 index 0000000..4eef248 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/allow-mode-off/input.json @@ -0,0 +1 @@ +{"tool_name":"Edit","cwd":"{{CWD}}","tool_input":{"file_path":"{{CWD}}/src/Foo.py"}} diff --git a/tests/hooks/fixtures/spec-gate/allow-mode-off/workspace/.claude/project-config.json b/tests/hooks/fixtures/spec-gate/allow-mode-off/workspace/.claude/project-config.json new file mode 100644 index 0000000..5dc0ea1 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/allow-mode-off/workspace/.claude/project-config.json @@ -0,0 +1,5 @@ +{ + "spec": { "dir": ".specs", "indexFile": ".specs/index.md" }, + "paths": { "protected": [".specs/constitution.md"] }, + "hooks": { "specGate": { "enabled": true, "mode": "off" } } +} diff --git a/tests/hooks/fixtures/spec-gate/allow-mode-off/workspace/.specs/index.md b/tests/hooks/fixtures/spec-gate/allow-mode-off/workspace/.specs/index.md new file mode 100644 index 0000000..d6f75b2 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/allow-mode-off/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status (in-progress = active work) | Title | +|---|---|---|---| +| FEAT-DONE-002 | feature | done | Finished feature | diff --git a/tests/hooks/fixtures/spec-gate/allow-other-tool/expected.json b/tests/hooks/fixtures/spec-gate/allow-other-tool/expected.json new file mode 100644 index 0000000..9ed9876 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/allow-other-tool/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"decision":"allow","permissionDecision":null,"reason":null,"stderr":"","events":[]} \ No newline at end of file diff --git a/tests/hooks/fixtures/spec-gate/allow-other-tool/input.json b/tests/hooks/fixtures/spec-gate/allow-other-tool/input.json new file mode 100644 index 0000000..3468d64 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/allow-other-tool/input.json @@ -0,0 +1 @@ +{"tool_name":"Read","cwd":"{{CWD}}","tool_input":{"file_path":"{{CWD}}/src/Foo.py"}} diff --git a/tests/hooks/fixtures/spec-gate/allow-other-tool/workspace/.claude/project-config.json b/tests/hooks/fixtures/spec-gate/allow-other-tool/workspace/.claude/project-config.json new file mode 100644 index 0000000..a139f1d --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/allow-other-tool/workspace/.claude/project-config.json @@ -0,0 +1,5 @@ +{ + "spec": { "dir": ".specs", "indexFile": ".specs/index.md" }, + "paths": { "protected": [".specs/constitution.md"] }, + "hooks": { "specGate": { "enabled": true, "mode": "block" } } +} diff --git a/tests/hooks/fixtures/spec-gate/allow-other-tool/workspace/.specs/index.md b/tests/hooks/fixtures/spec-gate/allow-other-tool/workspace/.specs/index.md new file mode 100644 index 0000000..d6f75b2 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/allow-other-tool/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status (in-progress = active work) | Title | +|---|---|---|---| +| FEAT-DONE-002 | feature | done | Finished feature | diff --git a/tests/hooks/fixtures/spec-gate/allow-readme-no-ext/expected.json b/tests/hooks/fixtures/spec-gate/allow-readme-no-ext/expected.json new file mode 100644 index 0000000..9ed9876 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/allow-readme-no-ext/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"decision":"allow","permissionDecision":null,"reason":null,"stderr":"","events":[]} \ No newline at end of file diff --git a/tests/hooks/fixtures/spec-gate/allow-readme-no-ext/input.json b/tests/hooks/fixtures/spec-gate/allow-readme-no-ext/input.json new file mode 100644 index 0000000..7ff0344 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/allow-readme-no-ext/input.json @@ -0,0 +1 @@ +{"tool_name":"Edit","cwd":"{{CWD}}","tool_input":{"file_path":"{{CWD}}/README"}} diff --git a/tests/hooks/fixtures/spec-gate/allow-readme-no-ext/workspace/.claude/project-config.json b/tests/hooks/fixtures/spec-gate/allow-readme-no-ext/workspace/.claude/project-config.json new file mode 100644 index 0000000..a139f1d --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/allow-readme-no-ext/workspace/.claude/project-config.json @@ -0,0 +1,5 @@ +{ + "spec": { "dir": ".specs", "indexFile": ".specs/index.md" }, + "paths": { "protected": [".specs/constitution.md"] }, + "hooks": { "specGate": { "enabled": true, "mode": "block" } } +} diff --git a/tests/hooks/fixtures/spec-gate/allow-readme-no-ext/workspace/.specs/index.md b/tests/hooks/fixtures/spec-gate/allow-readme-no-ext/workspace/.specs/index.md new file mode 100644 index 0000000..d6f75b2 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/allow-readme-no-ext/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status (in-progress = active work) | Title | +|---|---|---|---| +| FEAT-DONE-002 | feature | done | Finished feature | diff --git a/tests/hooks/fixtures/spec-gate/allow-relative-dotdot-code-file/expected.json b/tests/hooks/fixtures/spec-gate/allow-relative-dotdot-code-file/expected.json new file mode 100644 index 0000000..7f4b5b4 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/allow-relative-dotdot-code-file/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"decision":"allow","permissionDecision":null,"reason":null,"stderr":"","events":[{"ts":"","spec_id":"FEAT-TEST-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".py"}]} \ No newline at end of file diff --git a/tests/hooks/fixtures/spec-gate/allow-relative-dotdot-code-file/input.json b/tests/hooks/fixtures/spec-gate/allow-relative-dotdot-code-file/input.json new file mode 100644 index 0000000..b7e76c9 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/allow-relative-dotdot-code-file/input.json @@ -0,0 +1 @@ +{"tool_name":"Edit","cwd":"{{CWD}}","tool_input":{"file_path":"src/../lib/Foo.py"}} diff --git a/tests/hooks/fixtures/spec-gate/allow-relative-dotdot-code-file/workspace/.claude/project-config.json b/tests/hooks/fixtures/spec-gate/allow-relative-dotdot-code-file/workspace/.claude/project-config.json new file mode 100644 index 0000000..a139f1d --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/allow-relative-dotdot-code-file/workspace/.claude/project-config.json @@ -0,0 +1,5 @@ +{ + "spec": { "dir": ".specs", "indexFile": ".specs/index.md" }, + "paths": { "protected": [".specs/constitution.md"] }, + "hooks": { "specGate": { "enabled": true, "mode": "block" } } +} diff --git a/tests/hooks/fixtures/spec-gate/allow-relative-dotdot-code-file/workspace/.specs/index.md b/tests/hooks/fixtures/spec-gate/allow-relative-dotdot-code-file/workspace/.specs/index.md new file mode 100644 index 0000000..bbe4179 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/allow-relative-dotdot-code-file/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status | Title | +|---|---|---|---| +| FEAT-TEST-001 | feature | in-progress | Conformance fixture feature | diff --git a/tests/hooks/fixtures/spec-gate/block-code-no-spec/expected.json b/tests/hooks/fixtures/spec-gate/block-code-no-spec/expected.json new file mode 100644 index 0000000..04de40b --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-code-no-spec/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"decision":"block","permissionDecision":"deny","reason":"spec-gate: editing code file 'src/Foo.py' but no in-progress spec is recorded in .specs/index.md. Run /sd:feature, /sd:bug, /sd:refactor, or /sd:perf first to create a spec, or set hooks.specGate.mode='off' in .claude/project-config.json to disable.","stderr":"","events":[{"ts":"","spec_id":"-","phase":"-","event":"gate","gate":"code-edit","decision":"block","ext":".py"}]} \ No newline at end of file diff --git a/tests/hooks/fixtures/spec-gate/block-code-no-spec/input.json b/tests/hooks/fixtures/spec-gate/block-code-no-spec/input.json new file mode 100644 index 0000000..4eef248 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-code-no-spec/input.json @@ -0,0 +1 @@ +{"tool_name":"Edit","cwd":"{{CWD}}","tool_input":{"file_path":"{{CWD}}/src/Foo.py"}} diff --git a/tests/hooks/fixtures/spec-gate/block-code-no-spec/workspace/.claude/project-config.json b/tests/hooks/fixtures/spec-gate/block-code-no-spec/workspace/.claude/project-config.json new file mode 100644 index 0000000..a139f1d --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-code-no-spec/workspace/.claude/project-config.json @@ -0,0 +1,5 @@ +{ + "spec": { "dir": ".specs", "indexFile": ".specs/index.md" }, + "paths": { "protected": [".specs/constitution.md"] }, + "hooks": { "specGate": { "enabled": true, "mode": "block" } } +} diff --git a/tests/hooks/fixtures/spec-gate/block-code-no-spec/workspace/.specs/index.md b/tests/hooks/fixtures/spec-gate/block-code-no-spec/workspace/.specs/index.md new file mode 100644 index 0000000..d6f75b2 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-code-no-spec/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status (in-progress = active work) | Title | +|---|---|---|---| +| FEAT-DONE-002 | feature | done | Finished feature | diff --git a/tests/hooks/fixtures/spec-gate/block-index-done-bug-row-protected/expected.json b/tests/hooks/fixtures/spec-gate/block-index-done-bug-row-protected/expected.json new file mode 100644 index 0000000..83051c3 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-index-done-bug-row-protected/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"decision":"block","permissionDecision":"deny","reason":"spec-gate: '.specs/index.md' is listed under paths.protected in .claude/project-config.json. Update via /sd:refactor or an ADR; never edit directly.","stderr":"","events":[{"ts":"","spec_id":"-","phase":"-","event":"gate","gate":"protected","decision":"block"},{"ts":"","spec_id":"BUG-002","phase":"done","event":"spec_transition","from":"in-progress","decision":"block"}]} \ No newline at end of file diff --git a/tests/hooks/fixtures/spec-gate/block-index-done-bug-row-protected/input.json b/tests/hooks/fixtures/spec-gate/block-index-done-bug-row-protected/input.json new file mode 100644 index 0000000..cc9b260 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-index-done-bug-row-protected/input.json @@ -0,0 +1 @@ +{"tool_name":"Edit","cwd":"{{CWD}}","tool_input":{"file_path":"{{CWD}}/.specs/index.md","old_string":"| BUG-002 | bug | in-progress | Demo bug |","new_string":"| BUG-002 | bug | done | Demo bug |"}} diff --git a/tests/hooks/fixtures/spec-gate/block-index-done-bug-row-protected/workspace/.specs/BUG-002/06-verify.md b/tests/hooks/fixtures/spec-gate/block-index-done-bug-row-protected/workspace/.specs/BUG-002/06-verify.md new file mode 100644 index 0000000..70b8af6 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-index-done-bug-row-protected/workspace/.specs/BUG-002/06-verify.md @@ -0,0 +1,8 @@ +--- +spec: BUG-002 +result: pass +date: 2026-07-20 +failures: 0 +--- + +# Verification report - BUG-002 diff --git a/tests/hooks/fixtures/spec-gate/block-index-done-bug-row-protected/workspace/.specs/index.md b/tests/hooks/fixtures/spec-gate/block-index-done-bug-row-protected/workspace/.specs/index.md new file mode 100644 index 0000000..d0d684b --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-index-done-bug-row-protected/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status | Title | +|---|---|---|---| +| BUG-002 | bug | in-progress | Demo bug | diff --git a/tests/hooks/fixtures/spec-gate/block-index-done-gate-disabled/expected.json b/tests/hooks/fixtures/spec-gate/block-index-done-gate-disabled/expected.json new file mode 100644 index 0000000..6328cdf --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-index-done-gate-disabled/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"decision":"block","permissionDecision":"deny","reason":"spec-gate: '.specs/index.md' is listed under paths.protected in .claude/project-config.json. Update via /sd:refactor or an ADR; never edit directly.","stderr":"","events":[{"ts":"","spec_id":"-","phase":"-","event":"gate","gate":"protected","decision":"block"},{"ts":"","spec_id":"FEAT-001","phase":"done","event":"spec_transition","from":"in-progress","decision":"block"}]} \ No newline at end of file diff --git a/tests/hooks/fixtures/spec-gate/block-index-done-gate-disabled/input.json b/tests/hooks/fixtures/spec-gate/block-index-done-gate-disabled/input.json new file mode 100644 index 0000000..1d183c0 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-index-done-gate-disabled/input.json @@ -0,0 +1 @@ +{"tool_name":"Edit","cwd":"{{CWD}}","tool_input":{"file_path":"{{CWD}}/.specs/index.md","old_string":"| FEAT-001 | feature | in-progress | Demo feature |","new_string":"| FEAT-001 | feature | done | Demo feature |"}} diff --git a/tests/hooks/fixtures/spec-gate/block-index-done-gate-disabled/workspace/.claude/project-config.json b/tests/hooks/fixtures/spec-gate/block-index-done-gate-disabled/workspace/.claude/project-config.json new file mode 100644 index 0000000..e2b170b --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-index-done-gate-disabled/workspace/.claude/project-config.json @@ -0,0 +1,5 @@ +{ + "spec": { "dir": ".specs", "indexFile": ".specs/index.md" }, + "paths": { "protected": [".specs/constitution.md", ".specs/index.md", "LICENSE"] }, + "hooks": { "specGate": { "enabled": true, "mode": "warn", "verifyGate": false } } +} diff --git a/tests/hooks/fixtures/spec-gate/block-index-done-gate-disabled/workspace/.specs/FEAT-001/06-verify.md b/tests/hooks/fixtures/spec-gate/block-index-done-gate-disabled/workspace/.specs/FEAT-001/06-verify.md new file mode 100644 index 0000000..879f439 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-index-done-gate-disabled/workspace/.specs/FEAT-001/06-verify.md @@ -0,0 +1,8 @@ +--- +spec: FEAT-001 +result: pass +date: 2026-07-20 +failures: 0 +--- + +# Verification report - FEAT-001 diff --git a/tests/hooks/fixtures/spec-gate/block-index-done-gate-disabled/workspace/.specs/index.md b/tests/hooks/fixtures/spec-gate/block-index-done-gate-disabled/workspace/.specs/index.md new file mode 100644 index 0000000..4b0a87b --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-index-done-gate-disabled/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status | Title | +|---|---|---|---| +| FEAT-001 | feature | in-progress | Demo feature | diff --git a/tests/hooks/fixtures/spec-gate/block-index-done-multiedit-no-verify/expected.json b/tests/hooks/fixtures/spec-gate/block-index-done-multiedit-no-verify/expected.json new file mode 100644 index 0000000..b7b7759 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-index-done-multiedit-no-verify/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"decision":"block","permissionDecision":"deny","reason":"spec-gate: index row(s) [FEAT-001] -> done but no passing /sd:verify artifact. Run /sd:verify ; close-out is allowed only after .specs//06-verify.md records 'result: pass'.","stderr":"","events":[{"ts":"","spec_id":"FEAT-001","phase":"done","event":"gate","gate":"verify","decision":"block"},{"ts":"","spec_id":"FEAT-001","phase":"done","event":"spec_transition","from":"in-progress","decision":"block"}]} \ No newline at end of file diff --git a/tests/hooks/fixtures/spec-gate/block-index-done-multiedit-no-verify/input.json b/tests/hooks/fixtures/spec-gate/block-index-done-multiedit-no-verify/input.json new file mode 100644 index 0000000..8d43be2 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-index-done-multiedit-no-verify/input.json @@ -0,0 +1 @@ +{"tool_name":"MultiEdit","cwd":"{{CWD}}","tool_input":{"file_path":"{{CWD}}/.specs/index.md","edits":[{"old_string":"| FEAT-001 | feature | in-progress | Demo feature |","new_string":"| FEAT-001 | feature | done | Demo feature |"}]}} diff --git a/tests/hooks/fixtures/spec-gate/block-index-done-multiedit-no-verify/workspace/.specs/index.md b/tests/hooks/fixtures/spec-gate/block-index-done-multiedit-no-verify/workspace/.specs/index.md new file mode 100644 index 0000000..4b0a87b --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-index-done-multiedit-no-verify/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status | Title | +|---|---|---|---| +| FEAT-001 | feature | in-progress | Demo feature | diff --git a/tests/hooks/fixtures/spec-gate/block-index-done-no-verify/expected.json b/tests/hooks/fixtures/spec-gate/block-index-done-no-verify/expected.json new file mode 100644 index 0000000..b7b7759 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-index-done-no-verify/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"decision":"block","permissionDecision":"deny","reason":"spec-gate: index row(s) [FEAT-001] -> done but no passing /sd:verify artifact. Run /sd:verify ; close-out is allowed only after .specs//06-verify.md records 'result: pass'.","stderr":"","events":[{"ts":"","spec_id":"FEAT-001","phase":"done","event":"gate","gate":"verify","decision":"block"},{"ts":"","spec_id":"FEAT-001","phase":"done","event":"spec_transition","from":"in-progress","decision":"block"}]} \ No newline at end of file diff --git a/tests/hooks/fixtures/spec-gate/block-index-done-no-verify/input.json b/tests/hooks/fixtures/spec-gate/block-index-done-no-verify/input.json new file mode 100644 index 0000000..1d183c0 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-index-done-no-verify/input.json @@ -0,0 +1 @@ +{"tool_name":"Edit","cwd":"{{CWD}}","tool_input":{"file_path":"{{CWD}}/.specs/index.md","old_string":"| FEAT-001 | feature | in-progress | Demo feature |","new_string":"| FEAT-001 | feature | done | Demo feature |"}} diff --git a/tests/hooks/fixtures/spec-gate/block-index-done-no-verify/workspace/.specs/index.md b/tests/hooks/fixtures/spec-gate/block-index-done-no-verify/workspace/.specs/index.md new file mode 100644 index 0000000..4b0a87b --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-index-done-no-verify/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status | Title | +|---|---|---|---| +| FEAT-001 | feature | in-progress | Demo feature | diff --git a/tests/hooks/fixtures/spec-gate/block-index-done-title-ref-no-verify/expected.json b/tests/hooks/fixtures/spec-gate/block-index-done-title-ref-no-verify/expected.json new file mode 100644 index 0000000..b7b7759 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-index-done-title-ref-no-verify/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"decision":"block","permissionDecision":"deny","reason":"spec-gate: index row(s) [FEAT-001] -> done but no passing /sd:verify artifact. Run /sd:verify ; close-out is allowed only after .specs//06-verify.md records 'result: pass'.","stderr":"","events":[{"ts":"","spec_id":"FEAT-001","phase":"done","event":"gate","gate":"verify","decision":"block"},{"ts":"","spec_id":"FEAT-001","phase":"done","event":"spec_transition","from":"in-progress","decision":"block"}]} \ No newline at end of file diff --git a/tests/hooks/fixtures/spec-gate/block-index-done-title-ref-no-verify/input.json b/tests/hooks/fixtures/spec-gate/block-index-done-title-ref-no-verify/input.json new file mode 100644 index 0000000..cbe25f2 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-index-done-title-ref-no-verify/input.json @@ -0,0 +1 @@ +{"tool_name":"Edit","cwd":"{{CWD}}","tool_input":{"file_path":"{{CWD}}/.specs/index.md","old_string":"| FEAT-001 | feature | in-progress | Follow-up to BUG-002 |","new_string":"| FEAT-001 | feature | done | Follow-up to BUG-002 |"}} diff --git a/tests/hooks/fixtures/spec-gate/block-index-done-title-ref-no-verify/workspace/.specs/index.md b/tests/hooks/fixtures/spec-gate/block-index-done-title-ref-no-verify/workspace/.specs/index.md new file mode 100644 index 0000000..ce633a9 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-index-done-title-ref-no-verify/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status | Title | +|---|---|---|---| +| FEAT-001 | feature | in-progress | Follow-up to BUG-002 | diff --git a/tests/hooks/fixtures/spec-gate/block-index-done-verify-fail/expected.json b/tests/hooks/fixtures/spec-gate/block-index-done-verify-fail/expected.json new file mode 100644 index 0000000..b7b7759 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-index-done-verify-fail/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"decision":"block","permissionDecision":"deny","reason":"spec-gate: index row(s) [FEAT-001] -> done but no passing /sd:verify artifact. Run /sd:verify ; close-out is allowed only after .specs//06-verify.md records 'result: pass'.","stderr":"","events":[{"ts":"","spec_id":"FEAT-001","phase":"done","event":"gate","gate":"verify","decision":"block"},{"ts":"","spec_id":"FEAT-001","phase":"done","event":"spec_transition","from":"in-progress","decision":"block"}]} \ No newline at end of file diff --git a/tests/hooks/fixtures/spec-gate/block-index-done-verify-fail/input.json b/tests/hooks/fixtures/spec-gate/block-index-done-verify-fail/input.json new file mode 100644 index 0000000..1d183c0 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-index-done-verify-fail/input.json @@ -0,0 +1 @@ +{"tool_name":"Edit","cwd":"{{CWD}}","tool_input":{"file_path":"{{CWD}}/.specs/index.md","old_string":"| FEAT-001 | feature | in-progress | Demo feature |","new_string":"| FEAT-001 | feature | done | Demo feature |"}} diff --git a/tests/hooks/fixtures/spec-gate/block-index-done-verify-fail/workspace/.specs/FEAT-001/06-verify.md b/tests/hooks/fixtures/spec-gate/block-index-done-verify-fail/workspace/.specs/FEAT-001/06-verify.md new file mode 100644 index 0000000..915d76f --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-index-done-verify-fail/workspace/.specs/FEAT-001/06-verify.md @@ -0,0 +1,8 @@ +--- +spec: FEAT-001 +result: fail +date: 2026-07-20 +failures: 2 +--- + +# Verification report - FEAT-001 diff --git a/tests/hooks/fixtures/spec-gate/block-index-done-verify-fail/workspace/.specs/index.md b/tests/hooks/fixtures/spec-gate/block-index-done-verify-fail/workspace/.specs/index.md new file mode 100644 index 0000000..4b0a87b --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-index-done-verify-fail/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status | Title | +|---|---|---|---| +| FEAT-001 | feature | in-progress | Demo feature | diff --git a/tests/hooks/fixtures/spec-gate/block-index-done-verifygate-string-false/expected.json b/tests/hooks/fixtures/spec-gate/block-index-done-verifygate-string-false/expected.json new file mode 100644 index 0000000..b7b7759 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-index-done-verifygate-string-false/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"decision":"block","permissionDecision":"deny","reason":"spec-gate: index row(s) [FEAT-001] -> done but no passing /sd:verify artifact. Run /sd:verify ; close-out is allowed only after .specs//06-verify.md records 'result: pass'.","stderr":"","events":[{"ts":"","spec_id":"FEAT-001","phase":"done","event":"gate","gate":"verify","decision":"block"},{"ts":"","spec_id":"FEAT-001","phase":"done","event":"spec_transition","from":"in-progress","decision":"block"}]} \ No newline at end of file diff --git a/tests/hooks/fixtures/spec-gate/block-index-done-verifygate-string-false/input.json b/tests/hooks/fixtures/spec-gate/block-index-done-verifygate-string-false/input.json new file mode 100644 index 0000000..1d183c0 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-index-done-verifygate-string-false/input.json @@ -0,0 +1 @@ +{"tool_name":"Edit","cwd":"{{CWD}}","tool_input":{"file_path":"{{CWD}}/.specs/index.md","old_string":"| FEAT-001 | feature | in-progress | Demo feature |","new_string":"| FEAT-001 | feature | done | Demo feature |"}} diff --git a/tests/hooks/fixtures/spec-gate/block-index-done-verifygate-string-false/workspace/.claude/project-config.json b/tests/hooks/fixtures/spec-gate/block-index-done-verifygate-string-false/workspace/.claude/project-config.json new file mode 100644 index 0000000..5bffd93 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-index-done-verifygate-string-false/workspace/.claude/project-config.json @@ -0,0 +1,5 @@ +{ + "spec": { "dir": ".specs", "indexFile": ".specs/index.md" }, + "paths": { "protected": [".specs/constitution.md", ".specs/index.md", "LICENSE"] }, + "hooks": { "specGate": { "enabled": true, "mode": "warn", "verifyGate": "false" } } +} diff --git a/tests/hooks/fixtures/spec-gate/block-index-done-verifygate-string-false/workspace/.specs/index.md b/tests/hooks/fixtures/spec-gate/block-index-done-verifygate-string-false/workspace/.specs/index.md new file mode 100644 index 0000000..4b0a87b --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-index-done-verifygate-string-false/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status | Title | +|---|---|---|---| +| FEAT-001 | feature | in-progress | Demo feature | diff --git a/tests/hooks/fixtures/spec-gate/block-index-done-write-no-verify/expected.json b/tests/hooks/fixtures/spec-gate/block-index-done-write-no-verify/expected.json new file mode 100644 index 0000000..b7b7759 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-index-done-write-no-verify/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"decision":"block","permissionDecision":"deny","reason":"spec-gate: index row(s) [FEAT-001] -> done but no passing /sd:verify artifact. Run /sd:verify ; close-out is allowed only after .specs//06-verify.md records 'result: pass'.","stderr":"","events":[{"ts":"","spec_id":"FEAT-001","phase":"done","event":"gate","gate":"verify","decision":"block"},{"ts":"","spec_id":"FEAT-001","phase":"done","event":"spec_transition","from":"in-progress","decision":"block"}]} \ No newline at end of file diff --git a/tests/hooks/fixtures/spec-gate/block-index-done-write-no-verify/input.json b/tests/hooks/fixtures/spec-gate/block-index-done-write-no-verify/input.json new file mode 100644 index 0000000..403db81 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-index-done-write-no-verify/input.json @@ -0,0 +1 @@ +{"tool_name":"Write","cwd":"{{CWD}}","tool_input":{"file_path":"{{CWD}}/.specs/index.md","content":"| ID | Type | Status | Title |\n|---|---|---|---|\n| FEAT-001 | feature | done | Demo feature |\n"}} diff --git a/tests/hooks/fixtures/spec-gate/block-index-done-write-no-verify/workspace/.specs/index.md b/tests/hooks/fixtures/spec-gate/block-index-done-write-no-verify/workspace/.specs/index.md new file mode 100644 index 0000000..4b0a87b --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-index-done-write-no-verify/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status | Title | +|---|---|---|---| +| FEAT-001 | feature | in-progress | Demo feature | diff --git a/tests/hooks/fixtures/spec-gate/block-index-nondone-edit/expected.json b/tests/hooks/fixtures/spec-gate/block-index-nondone-edit/expected.json new file mode 100644 index 0000000..e542487 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-index-nondone-edit/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"decision":"block","permissionDecision":"deny","reason":"spec-gate: '.specs/index.md' is listed under paths.protected in .claude/project-config.json. Update via /sd:refactor or an ADR; never edit directly.","stderr":"","events":[{"ts":"","spec_id":"-","phase":"-","event":"gate","gate":"protected","decision":"block"}]} \ No newline at end of file diff --git a/tests/hooks/fixtures/spec-gate/block-index-nondone-edit/input.json b/tests/hooks/fixtures/spec-gate/block-index-nondone-edit/input.json new file mode 100644 index 0000000..780556b --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-index-nondone-edit/input.json @@ -0,0 +1 @@ +{"tool_name":"Edit","cwd":"{{CWD}}","tool_input":{"file_path":"{{CWD}}/.specs/index.md","old_string":"| FEAT-001 | feature | in-progress | Demo feature |","new_string":"| FEAT-001 | feature | in-progress | Renamed demo feature |"}} diff --git a/tests/hooks/fixtures/spec-gate/block-index-nondone-edit/workspace/.specs/index.md b/tests/hooks/fixtures/spec-gate/block-index-nondone-edit/workspace/.specs/index.md new file mode 100644 index 0000000..4b0a87b --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-index-nondone-edit/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status | Title | +|---|---|---|---| +| FEAT-001 | feature | in-progress | Demo feature | diff --git a/tests/hooks/fixtures/spec-gate/block-protected-case-insensitive/expected.json b/tests/hooks/fixtures/spec-gate/block-protected-case-insensitive/expected.json new file mode 100644 index 0000000..6cdfbab --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-protected-case-insensitive/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"decision":"block","permissionDecision":"deny","reason":"spec-gate: '.specs/CONSTITUTION.md' is listed under paths.protected in .claude/project-config.json. Update via /sd:refactor or an ADR; never edit directly.","stderr":"","events":[{"ts":"","spec_id":"-","phase":"-","event":"gate","gate":"protected","decision":"block"}]} \ No newline at end of file diff --git a/tests/hooks/fixtures/spec-gate/block-protected-case-insensitive/input.json b/tests/hooks/fixtures/spec-gate/block-protected-case-insensitive/input.json new file mode 100644 index 0000000..94fe562 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-protected-case-insensitive/input.json @@ -0,0 +1 @@ +{"tool_name":"Edit","cwd":"{{CWD}}","tool_input":{"file_path":"{{CWD}}/.specs/CONSTITUTION.md"}} diff --git a/tests/hooks/fixtures/spec-gate/block-protected-case-insensitive/workspace/.claude/project-config.json b/tests/hooks/fixtures/spec-gate/block-protected-case-insensitive/workspace/.claude/project-config.json new file mode 100644 index 0000000..53e2903 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-protected-case-insensitive/workspace/.claude/project-config.json @@ -0,0 +1,5 @@ +{ + "spec": { "dir": ".specs", "indexFile": ".specs/index.md" }, + "paths": { "protected": [".specs/Constitution.md"] }, + "hooks": { "specGate": { "enabled": true, "mode": "block" } } +} diff --git a/tests/hooks/fixtures/spec-gate/block-protected-case-insensitive/workspace/.specs/index.md b/tests/hooks/fixtures/spec-gate/block-protected-case-insensitive/workspace/.specs/index.md new file mode 100644 index 0000000..d6f75b2 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-protected-case-insensitive/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status (in-progress = active work) | Title | +|---|---|---|---| +| FEAT-DONE-002 | feature | done | Finished feature | diff --git a/tests/hooks/fixtures/spec-gate/block-protected-path-trailing-slash/expected.json b/tests/hooks/fixtures/spec-gate/block-protected-path-trailing-slash/expected.json new file mode 100644 index 0000000..00e6953 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-protected-path-trailing-slash/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"decision":"block","permissionDecision":"deny","reason":"spec-gate: '.specs/constitution.md' is listed under paths.protected in .claude/project-config.json. Update via /sd:refactor or an ADR; never edit directly.","stderr":"","events":[{"ts":"","spec_id":"-","phase":"-","event":"gate","gate":"protected","decision":"block"}]} \ No newline at end of file diff --git a/tests/hooks/fixtures/spec-gate/block-protected-path-trailing-slash/input.json b/tests/hooks/fixtures/spec-gate/block-protected-path-trailing-slash/input.json new file mode 100644 index 0000000..23187fb --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-protected-path-trailing-slash/input.json @@ -0,0 +1 @@ +{"tool_name":"Edit","cwd":"{{CWD}}","tool_input":{"file_path":"{{CWD}}/.specs/constitution.md/"}} diff --git a/tests/hooks/fixtures/spec-gate/block-protected-path-trailing-slash/workspace/.claude/project-config.json b/tests/hooks/fixtures/spec-gate/block-protected-path-trailing-slash/workspace/.claude/project-config.json new file mode 100644 index 0000000..a139f1d --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-protected-path-trailing-slash/workspace/.claude/project-config.json @@ -0,0 +1,5 @@ +{ + "spec": { "dir": ".specs", "indexFile": ".specs/index.md" }, + "paths": { "protected": [".specs/constitution.md"] }, + "hooks": { "specGate": { "enabled": true, "mode": "block" } } +} diff --git a/tests/hooks/fixtures/spec-gate/block-protected-path-trailing-slash/workspace/.specs/constitution.md b/tests/hooks/fixtures/spec-gate/block-protected-path-trailing-slash/workspace/.specs/constitution.md new file mode 100644 index 0000000..aa4840a --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-protected-path-trailing-slash/workspace/.specs/constitution.md @@ -0,0 +1,3 @@ +# Constitution + +Placeholder constitution content for the conformance fixture. diff --git a/tests/hooks/fixtures/spec-gate/block-protected-path-trailing-slash/workspace/.specs/index.md b/tests/hooks/fixtures/spec-gate/block-protected-path-trailing-slash/workspace/.specs/index.md new file mode 100644 index 0000000..bbe4179 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-protected-path-trailing-slash/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status | Title | +|---|---|---|---| +| FEAT-TEST-001 | feature | in-progress | Conformance fixture feature | diff --git a/tests/hooks/fixtures/spec-gate/block-protected-path-traversal-dot/expected.json b/tests/hooks/fixtures/spec-gate/block-protected-path-traversal-dot/expected.json new file mode 100644 index 0000000..00e6953 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-protected-path-traversal-dot/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"decision":"block","permissionDecision":"deny","reason":"spec-gate: '.specs/constitution.md' is listed under paths.protected in .claude/project-config.json. Update via /sd:refactor or an ADR; never edit directly.","stderr":"","events":[{"ts":"","spec_id":"-","phase":"-","event":"gate","gate":"protected","decision":"block"}]} \ No newline at end of file diff --git a/tests/hooks/fixtures/spec-gate/block-protected-path-traversal-dot/input.json b/tests/hooks/fixtures/spec-gate/block-protected-path-traversal-dot/input.json new file mode 100644 index 0000000..1f80eb4 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-protected-path-traversal-dot/input.json @@ -0,0 +1 @@ +{"tool_name":"Edit","cwd":"{{CWD}}","tool_input":{"file_path":"{{CWD}}/./.specs/constitution.md"}} diff --git a/tests/hooks/fixtures/spec-gate/block-protected-path-traversal-dot/workspace/.claude/project-config.json b/tests/hooks/fixtures/spec-gate/block-protected-path-traversal-dot/workspace/.claude/project-config.json new file mode 100644 index 0000000..a139f1d --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-protected-path-traversal-dot/workspace/.claude/project-config.json @@ -0,0 +1,5 @@ +{ + "spec": { "dir": ".specs", "indexFile": ".specs/index.md" }, + "paths": { "protected": [".specs/constitution.md"] }, + "hooks": { "specGate": { "enabled": true, "mode": "block" } } +} diff --git a/tests/hooks/fixtures/spec-gate/block-protected-path-traversal-dot/workspace/.specs/constitution.md b/tests/hooks/fixtures/spec-gate/block-protected-path-traversal-dot/workspace/.specs/constitution.md new file mode 100644 index 0000000..aa4840a --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-protected-path-traversal-dot/workspace/.specs/constitution.md @@ -0,0 +1,3 @@ +# Constitution + +Placeholder constitution content for the conformance fixture. diff --git a/tests/hooks/fixtures/spec-gate/block-protected-path-traversal-dot/workspace/.specs/index.md b/tests/hooks/fixtures/spec-gate/block-protected-path-traversal-dot/workspace/.specs/index.md new file mode 100644 index 0000000..9852a5d --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-protected-path-traversal-dot/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status | Title | +|---|---|---|---| +| FEAT-DONE-002 | feature | done | Finished feature | diff --git a/tests/hooks/fixtures/spec-gate/block-protected-path-traversal-dotdot/expected.json b/tests/hooks/fixtures/spec-gate/block-protected-path-traversal-dotdot/expected.json new file mode 100644 index 0000000..00e6953 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-protected-path-traversal-dotdot/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"decision":"block","permissionDecision":"deny","reason":"spec-gate: '.specs/constitution.md' is listed under paths.protected in .claude/project-config.json. Update via /sd:refactor or an ADR; never edit directly.","stderr":"","events":[{"ts":"","spec_id":"-","phase":"-","event":"gate","gate":"protected","decision":"block"}]} \ No newline at end of file diff --git a/tests/hooks/fixtures/spec-gate/block-protected-path-traversal-dotdot/input.json b/tests/hooks/fixtures/spec-gate/block-protected-path-traversal-dotdot/input.json new file mode 100644 index 0000000..aba9cfb --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-protected-path-traversal-dotdot/input.json @@ -0,0 +1 @@ +{"tool_name":"Edit","cwd":"{{CWD}}","tool_input":{"file_path":"{{CWD}}/src/../.specs/constitution.md"}} diff --git a/tests/hooks/fixtures/spec-gate/block-protected-path-traversal-dotdot/workspace/.specs/constitution.md b/tests/hooks/fixtures/spec-gate/block-protected-path-traversal-dotdot/workspace/.specs/constitution.md new file mode 100644 index 0000000..45ac7be --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-protected-path-traversal-dotdot/workspace/.specs/constitution.md @@ -0,0 +1 @@ +# Constitution diff --git a/tests/hooks/fixtures/spec-gate/block-protected-path-traversal-dotdot/workspace/.specs/index.md b/tests/hooks/fixtures/spec-gate/block-protected-path-traversal-dotdot/workspace/.specs/index.md new file mode 100644 index 0000000..9852a5d --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-protected-path-traversal-dotdot/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status | Title | +|---|---|---|---| +| FEAT-DONE-002 | feature | done | Finished feature | diff --git a/tests/hooks/fixtures/spec-gate/block-protected-path-traversal-relative/expected.json b/tests/hooks/fixtures/spec-gate/block-protected-path-traversal-relative/expected.json new file mode 100644 index 0000000..00e6953 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-protected-path-traversal-relative/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"decision":"block","permissionDecision":"deny","reason":"spec-gate: '.specs/constitution.md' is listed under paths.protected in .claude/project-config.json. Update via /sd:refactor or an ADR; never edit directly.","stderr":"","events":[{"ts":"","spec_id":"-","phase":"-","event":"gate","gate":"protected","decision":"block"}]} \ No newline at end of file diff --git a/tests/hooks/fixtures/spec-gate/block-protected-path-traversal-relative/input.json b/tests/hooks/fixtures/spec-gate/block-protected-path-traversal-relative/input.json new file mode 100644 index 0000000..61d03a1 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-protected-path-traversal-relative/input.json @@ -0,0 +1 @@ +{"tool_name":"Edit","cwd":"{{CWD}}","tool_input":{"file_path":"src/../.specs/constitution.md"}} diff --git a/tests/hooks/fixtures/spec-gate/block-protected-path-traversal-relative/workspace/.specs/constitution.md b/tests/hooks/fixtures/spec-gate/block-protected-path-traversal-relative/workspace/.specs/constitution.md new file mode 100644 index 0000000..45ac7be --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-protected-path-traversal-relative/workspace/.specs/constitution.md @@ -0,0 +1 @@ +# Constitution diff --git a/tests/hooks/fixtures/spec-gate/block-protected-path-traversal-relative/workspace/.specs/index.md b/tests/hooks/fixtures/spec-gate/block-protected-path-traversal-relative/workspace/.specs/index.md new file mode 100644 index 0000000..9852a5d --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-protected-path-traversal-relative/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status | Title | +|---|---|---|---| +| FEAT-DONE-002 | feature | done | Finished feature | diff --git a/tests/hooks/fixtures/spec-gate/block-protected-path/expected.json b/tests/hooks/fixtures/spec-gate/block-protected-path/expected.json new file mode 100644 index 0000000..00e6953 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-protected-path/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"decision":"block","permissionDecision":"deny","reason":"spec-gate: '.specs/constitution.md' is listed under paths.protected in .claude/project-config.json. Update via /sd:refactor or an ADR; never edit directly.","stderr":"","events":[{"ts":"","spec_id":"-","phase":"-","event":"gate","gate":"protected","decision":"block"}]} \ No newline at end of file diff --git a/tests/hooks/fixtures/spec-gate/block-protected-path/input.json b/tests/hooks/fixtures/spec-gate/block-protected-path/input.json new file mode 100644 index 0000000..c245920 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-protected-path/input.json @@ -0,0 +1 @@ +{"tool_name":"Edit","cwd":"{{CWD}}","tool_input":{"file_path":"{{CWD}}/.specs/constitution.md"}} diff --git a/tests/hooks/fixtures/spec-gate/block-protected-path/workspace/.claude/project-config.json b/tests/hooks/fixtures/spec-gate/block-protected-path/workspace/.claude/project-config.json new file mode 100644 index 0000000..a139f1d --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-protected-path/workspace/.claude/project-config.json @@ -0,0 +1,5 @@ +{ + "spec": { "dir": ".specs", "indexFile": ".specs/index.md" }, + "paths": { "protected": [".specs/constitution.md"] }, + "hooks": { "specGate": { "enabled": true, "mode": "block" } } +} diff --git a/tests/hooks/fixtures/spec-gate/block-protected-path/workspace/.specs/constitution.md b/tests/hooks/fixtures/spec-gate/block-protected-path/workspace/.specs/constitution.md new file mode 100644 index 0000000..aa4840a --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-protected-path/workspace/.specs/constitution.md @@ -0,0 +1,3 @@ +# Constitution + +Placeholder constitution content for the conformance fixture. diff --git a/tests/hooks/fixtures/spec-gate/block-protected-path/workspace/.specs/index.md b/tests/hooks/fixtures/spec-gate/block-protected-path/workspace/.specs/index.md new file mode 100644 index 0000000..bbe4179 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-protected-path/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status | Title | +|---|---|---|---| +| FEAT-TEST-001 | feature | in-progress | Conformance fixture feature | diff --git a/tests/hooks/fixtures/spec-gate/block-readme-code-ext/expected.json b/tests/hooks/fixtures/spec-gate/block-readme-code-ext/expected.json new file mode 100644 index 0000000..6c5c0e3 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-readme-code-ext/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"decision":"block","permissionDecision":"deny","reason":"spec-gate: editing code file 'README.old.py' but no in-progress spec is recorded in .specs/index.md. Run /sd:feature, /sd:bug, /sd:refactor, or /sd:perf first to create a spec, or set hooks.specGate.mode='off' in .claude/project-config.json to disable.","stderr":"","events":[{"ts":"","spec_id":"-","phase":"-","event":"gate","gate":"code-edit","decision":"block","ext":".py"}]} \ No newline at end of file diff --git a/tests/hooks/fixtures/spec-gate/block-readme-code-ext/input.json b/tests/hooks/fixtures/spec-gate/block-readme-code-ext/input.json new file mode 100644 index 0000000..fd61c15 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-readme-code-ext/input.json @@ -0,0 +1 @@ +{"tool_name":"Edit","cwd":"{{CWD}}","tool_input":{"file_path":"{{CWD}}/README.old.py"}} diff --git a/tests/hooks/fixtures/spec-gate/block-readme-code-ext/workspace/.claude/project-config.json b/tests/hooks/fixtures/spec-gate/block-readme-code-ext/workspace/.claude/project-config.json new file mode 100644 index 0000000..a139f1d --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-readme-code-ext/workspace/.claude/project-config.json @@ -0,0 +1,5 @@ +{ + "spec": { "dir": ".specs", "indexFile": ".specs/index.md" }, + "paths": { "protected": [".specs/constitution.md"] }, + "hooks": { "specGate": { "enabled": true, "mode": "block" } } +} diff --git a/tests/hooks/fixtures/spec-gate/block-readme-code-ext/workspace/.specs/index.md b/tests/hooks/fixtures/spec-gate/block-readme-code-ext/workspace/.specs/index.md new file mode 100644 index 0000000..d6f75b2 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/block-readme-code-ext/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status (in-progress = active work) | Title | +|---|---|---|---| +| FEAT-DONE-002 | feature | done | Finished feature | diff --git a/tests/hooks/fixtures/spec-gate/enabled-absent-warns/expected.json b/tests/hooks/fixtures/spec-gate/enabled-absent-warns/expected.json new file mode 100644 index 0000000..87b2689 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/enabled-absent-warns/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"decision":"warn","permissionDecision":null,"reason":null,"stderr":"[WARN] spec-gate: editing code file 'src/Foo.py' but no in-progress spec is recorded in .specs/index.md. Run /sd:feature, /sd:bug, /sd:refactor, or /sd:perf first to create a spec, or set hooks.specGate.mode='off' in .claude/project-config.json to disable.","events":[{"ts":"","spec_id":"-","phase":"-","event":"gate","gate":"code-edit","decision":"warn","ext":".py"}]} diff --git a/tests/hooks/fixtures/spec-gate/enabled-absent-warns/input.json b/tests/hooks/fixtures/spec-gate/enabled-absent-warns/input.json new file mode 100644 index 0000000..4eef248 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/enabled-absent-warns/input.json @@ -0,0 +1 @@ +{"tool_name":"Edit","cwd":"{{CWD}}","tool_input":{"file_path":"{{CWD}}/src/Foo.py"}} diff --git a/tests/hooks/fixtures/spec-gate/enabled-absent-warns/workspace/.claude/project-config.json b/tests/hooks/fixtures/spec-gate/enabled-absent-warns/workspace/.claude/project-config.json new file mode 100644 index 0000000..6afb100 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/enabled-absent-warns/workspace/.claude/project-config.json @@ -0,0 +1,5 @@ +{ + "spec": { "dir": ".specs", "indexFile": ".specs/index.md" }, + "paths": { "protected": [".specs/constitution.md"] }, + "hooks": { "specGate": { "mode": "warn" } } +} diff --git a/tests/hooks/fixtures/spec-gate/enabled-absent-warns/workspace/.specs/index.md b/tests/hooks/fixtures/spec-gate/enabled-absent-warns/workspace/.specs/index.md new file mode 100644 index 0000000..d6f75b2 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/enabled-absent-warns/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status (in-progress = active work) | Title | +|---|---|---|---| +| FEAT-DONE-002 | feature | done | Finished feature | diff --git a/tests/hooks/fixtures/spec-gate/malformed-config-protected-default/expected.json b/tests/hooks/fixtures/spec-gate/malformed-config-protected-default/expected.json new file mode 100644 index 0000000..00e6953 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/malformed-config-protected-default/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"decision":"block","permissionDecision":"deny","reason":"spec-gate: '.specs/constitution.md' is listed under paths.protected in .claude/project-config.json. Update via /sd:refactor or an ADR; never edit directly.","stderr":"","events":[{"ts":"","spec_id":"-","phase":"-","event":"gate","gate":"protected","decision":"block"}]} \ No newline at end of file diff --git a/tests/hooks/fixtures/spec-gate/malformed-config-protected-default/input.json b/tests/hooks/fixtures/spec-gate/malformed-config-protected-default/input.json new file mode 100644 index 0000000..c245920 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/malformed-config-protected-default/input.json @@ -0,0 +1 @@ +{"tool_name":"Edit","cwd":"{{CWD}}","tool_input":{"file_path":"{{CWD}}/.specs/constitution.md"}} diff --git a/tests/hooks/fixtures/spec-gate/malformed-config-protected-default/workspace/.claude/project-config.json b/tests/hooks/fixtures/spec-gate/malformed-config-protected-default/workspace/.claude/project-config.json new file mode 100644 index 0000000..531aee1 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/malformed-config-protected-default/workspace/.claude/project-config.json @@ -0,0 +1 @@ +{ "spec": { "dir": ".specs" },,, not json at all diff --git a/tests/hooks/fixtures/spec-gate/malformed-config-protected-default/workspace/.specs/constitution.md b/tests/hooks/fixtures/spec-gate/malformed-config-protected-default/workspace/.specs/constitution.md new file mode 100644 index 0000000..45ac7be --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/malformed-config-protected-default/workspace/.specs/constitution.md @@ -0,0 +1 @@ +# Constitution diff --git a/tests/hooks/fixtures/spec-gate/malformed-config-protected-default/workspace/.specs/index.md b/tests/hooks/fixtures/spec-gate/malformed-config-protected-default/workspace/.specs/index.md new file mode 100644 index 0000000..d6f75b2 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/malformed-config-protected-default/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status (in-progress = active work) | Title | +|---|---|---|---| +| FEAT-DONE-002 | feature | done | Finished feature | diff --git a/tests/hooks/fixtures/spec-gate/metrics-code-edit-allow/expected.json b/tests/hooks/fixtures/spec-gate/metrics-code-edit-allow/expected.json new file mode 100644 index 0000000..a3a6f73 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/metrics-code-edit-allow/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"decision":"allow","permissionDecision":null,"reason":null,"stderr":"","events":[{"ts":"","spec_id":"FEAT-TEST-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".ts"}]} \ No newline at end of file diff --git a/tests/hooks/fixtures/spec-gate/metrics-code-edit-allow/input.json b/tests/hooks/fixtures/spec-gate/metrics-code-edit-allow/input.json new file mode 100644 index 0000000..88602b0 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/metrics-code-edit-allow/input.json @@ -0,0 +1 @@ +{"tool_name":"Edit","cwd":"{{CWD}}","tool_input":{"file_path":"{{CWD}}/src/nested/dir/Foo.ts"}} \ No newline at end of file diff --git a/tests/hooks/fixtures/spec-gate/metrics-code-edit-allow/workspace/.claude/project-config.json b/tests/hooks/fixtures/spec-gate/metrics-code-edit-allow/workspace/.claude/project-config.json new file mode 100644 index 0000000..a139f1d --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/metrics-code-edit-allow/workspace/.claude/project-config.json @@ -0,0 +1,5 @@ +{ + "spec": { "dir": ".specs", "indexFile": ".specs/index.md" }, + "paths": { "protected": [".specs/constitution.md"] }, + "hooks": { "specGate": { "enabled": true, "mode": "block" } } +} diff --git a/tests/hooks/fixtures/spec-gate/metrics-code-edit-allow/workspace/.specs/index.md b/tests/hooks/fixtures/spec-gate/metrics-code-edit-allow/workspace/.specs/index.md new file mode 100644 index 0000000..bbe4179 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/metrics-code-edit-allow/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status | Title | +|---|---|---|---| +| FEAT-TEST-001 | feature | in-progress | Conformance fixture feature | diff --git a/tests/hooks/fixtures/spec-gate/metrics-custom-path/expected.json b/tests/hooks/fixtures/spec-gate/metrics-custom-path/expected.json new file mode 100644 index 0000000..7f4b5b4 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/metrics-custom-path/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"decision":"allow","permissionDecision":null,"reason":null,"stderr":"","events":[{"ts":"","spec_id":"FEAT-TEST-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".py"}]} \ No newline at end of file diff --git a/tests/hooks/fixtures/spec-gate/metrics-custom-path/input.json b/tests/hooks/fixtures/spec-gate/metrics-custom-path/input.json new file mode 100644 index 0000000..f22bac4 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/metrics-custom-path/input.json @@ -0,0 +1 @@ +{"tool_name":"Edit","cwd":"{{CWD}}","tool_input":{"file_path":"{{CWD}}/src/Foo.py"}} \ No newline at end of file diff --git a/tests/hooks/fixtures/spec-gate/metrics-custom-path/workspace/.claude/project-config.json b/tests/hooks/fixtures/spec-gate/metrics-custom-path/workspace/.claude/project-config.json new file mode 100644 index 0000000..e3e34f0 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/metrics-custom-path/workspace/.claude/project-config.json @@ -0,0 +1,5 @@ +{ + "spec": { "dir": ".specs", "indexFile": ".specs/index.md" }, + "paths": { "protected": [".specs/constitution.md"] }, + "hooks": { "specGate": { "enabled": true, "mode": "block" }, "metrics": { "enabled": true, "path": ".specs/_custom/my-metrics.jsonl" } } +} diff --git a/tests/hooks/fixtures/spec-gate/metrics-custom-path/workspace/.specs/index.md b/tests/hooks/fixtures/spec-gate/metrics-custom-path/workspace/.specs/index.md new file mode 100644 index 0000000..bbe4179 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/metrics-custom-path/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status | Title | +|---|---|---|---| +| FEAT-TEST-001 | feature | in-progress | Conformance fixture feature | diff --git a/tests/hooks/fixtures/spec-gate/metrics-disabled-no-write/expected.json b/tests/hooks/fixtures/spec-gate/metrics-disabled-no-write/expected.json new file mode 100644 index 0000000..9ed9876 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/metrics-disabled-no-write/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"decision":"allow","permissionDecision":null,"reason":null,"stderr":"","events":[]} \ No newline at end of file diff --git a/tests/hooks/fixtures/spec-gate/metrics-disabled-no-write/input.json b/tests/hooks/fixtures/spec-gate/metrics-disabled-no-write/input.json new file mode 100644 index 0000000..f22bac4 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/metrics-disabled-no-write/input.json @@ -0,0 +1 @@ +{"tool_name":"Edit","cwd":"{{CWD}}","tool_input":{"file_path":"{{CWD}}/src/Foo.py"}} \ No newline at end of file diff --git a/tests/hooks/fixtures/spec-gate/metrics-disabled-no-write/workspace/.claude/project-config.json b/tests/hooks/fixtures/spec-gate/metrics-disabled-no-write/workspace/.claude/project-config.json new file mode 100644 index 0000000..5281c3c --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/metrics-disabled-no-write/workspace/.claude/project-config.json @@ -0,0 +1,5 @@ +{ + "spec": { "dir": ".specs", "indexFile": ".specs/index.md" }, + "paths": { "protected": [".specs/constitution.md"] }, + "hooks": { "specGate": { "enabled": true, "mode": "block" }, "metrics": { "enabled": false } } +} diff --git a/tests/hooks/fixtures/spec-gate/metrics-disabled-no-write/workspace/.specs/index.md b/tests/hooks/fixtures/spec-gate/metrics-disabled-no-write/workspace/.specs/index.md new file mode 100644 index 0000000..bbe4179 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/metrics-disabled-no-write/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status | Title | +|---|---|---|---| +| FEAT-TEST-001 | feature | in-progress | Conformance fixture feature | diff --git a/tests/hooks/fixtures/spec-gate/metrics-rotates-at-cap/expected.json b/tests/hooks/fixtures/spec-gate/metrics-rotates-at-cap/expected.json new file mode 100644 index 0000000..a3a6f73 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/metrics-rotates-at-cap/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"decision":"allow","permissionDecision":null,"reason":null,"stderr":"","events":[{"ts":"","spec_id":"FEAT-TEST-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".ts"}]} \ No newline at end of file diff --git a/tests/hooks/fixtures/spec-gate/metrics-rotates-at-cap/input.json b/tests/hooks/fixtures/spec-gate/metrics-rotates-at-cap/input.json new file mode 100644 index 0000000..88602b0 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/metrics-rotates-at-cap/input.json @@ -0,0 +1 @@ +{"tool_name":"Edit","cwd":"{{CWD}}","tool_input":{"file_path":"{{CWD}}/src/nested/dir/Foo.ts"}} \ No newline at end of file diff --git a/tests/hooks/fixtures/spec-gate/metrics-rotates-at-cap/rotated-expected.json b/tests/hooks/fixtures/spec-gate/metrics-rotates-at-cap/rotated-expected.json new file mode 100644 index 0000000..0550b1c --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/metrics-rotates-at-cap/rotated-expected.json @@ -0,0 +1 @@ +{"rotated":[{"ts":"","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".ts"},{"ts":"","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".py"},{"ts":"","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".go"},{"ts":"","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".rs"},{"ts":"","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".js"},{"ts":"","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".cs"},{"ts":"","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".rb"},{"ts":"","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".java"},{"ts":"","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".kt"}]} \ No newline at end of file diff --git a/tests/hooks/fixtures/spec-gate/metrics-rotates-at-cap/workspace/.claude/project-config.json b/tests/hooks/fixtures/spec-gate/metrics-rotates-at-cap/workspace/.claude/project-config.json new file mode 100644 index 0000000..e0a17ef --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/metrics-rotates-at-cap/workspace/.claude/project-config.json @@ -0,0 +1,5 @@ +{ + "spec": { "dir": ".specs", "indexFile": ".specs/index.md" }, + "paths": { "protected": [".specs/constitution.md"] }, + "hooks": { "specGate": { "enabled": true, "mode": "block" }, "metrics": { "enabled": true, "maxSizeKb": 1 } } +} \ No newline at end of file diff --git a/tests/hooks/fixtures/spec-gate/metrics-rotates-at-cap/workspace/.specs/_metrics/events.jsonl b/tests/hooks/fixtures/spec-gate/metrics-rotates-at-cap/workspace/.specs/_metrics/events.jsonl new file mode 100644 index 0000000..2886754 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/metrics-rotates-at-cap/workspace/.specs/_metrics/events.jsonl @@ -0,0 +1,9 @@ +{"ts":"2026-01-01T00:00:00Z","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".ts"} +{"ts":"2026-01-01T00:00:00Z","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".py"} +{"ts":"2026-01-01T00:00:00Z","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".go"} +{"ts":"2026-01-01T00:00:00Z","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".rs"} +{"ts":"2026-01-01T00:00:00Z","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".js"} +{"ts":"2026-01-01T00:00:00Z","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".cs"} +{"ts":"2026-01-01T00:00:00Z","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".rb"} +{"ts":"2026-01-01T00:00:00Z","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".java"} +{"ts":"2026-01-01T00:00:00Z","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".kt"} diff --git a/tests/hooks/fixtures/spec-gate/metrics-rotates-at-cap/workspace/.specs/index.md b/tests/hooks/fixtures/spec-gate/metrics-rotates-at-cap/workspace/.specs/index.md new file mode 100644 index 0000000..bbe4179 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/metrics-rotates-at-cap/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status | Title | +|---|---|---|---| +| FEAT-TEST-001 | feature | in-progress | Conformance fixture feature | diff --git a/tests/hooks/fixtures/spec-gate/metrics-rotation-off/expected.json b/tests/hooks/fixtures/spec-gate/metrics-rotation-off/expected.json new file mode 100644 index 0000000..5bfa291 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/metrics-rotation-off/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"decision":"allow","permissionDecision":null,"reason":null,"stderr":"","events":[{"ts":"","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".ts"},{"ts":"","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".py"},{"ts":"","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".go"},{"ts":"","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".rs"},{"ts":"","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".js"},{"ts":"","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".cs"},{"ts":"","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".rb"},{"ts":"","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".java"},{"ts":"","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".kt"},{"ts":"","spec_id":"FEAT-TEST-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".ts"}]} \ No newline at end of file diff --git a/tests/hooks/fixtures/spec-gate/metrics-rotation-off/input.json b/tests/hooks/fixtures/spec-gate/metrics-rotation-off/input.json new file mode 100644 index 0000000..88602b0 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/metrics-rotation-off/input.json @@ -0,0 +1 @@ +{"tool_name":"Edit","cwd":"{{CWD}}","tool_input":{"file_path":"{{CWD}}/src/nested/dir/Foo.ts"}} \ No newline at end of file diff --git a/tests/hooks/fixtures/spec-gate/metrics-rotation-off/workspace/.claude/project-config.json b/tests/hooks/fixtures/spec-gate/metrics-rotation-off/workspace/.claude/project-config.json new file mode 100644 index 0000000..1f032e1 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/metrics-rotation-off/workspace/.claude/project-config.json @@ -0,0 +1,5 @@ +{ + "spec": { "dir": ".specs", "indexFile": ".specs/index.md" }, + "paths": { "protected": [".specs/constitution.md"] }, + "hooks": { "specGate": { "enabled": true, "mode": "block" }, "metrics": { "enabled": true, "maxSizeKb": 0 } } +} \ No newline at end of file diff --git a/tests/hooks/fixtures/spec-gate/metrics-rotation-off/workspace/.specs/_metrics/events.jsonl b/tests/hooks/fixtures/spec-gate/metrics-rotation-off/workspace/.specs/_metrics/events.jsonl new file mode 100644 index 0000000..2886754 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/metrics-rotation-off/workspace/.specs/_metrics/events.jsonl @@ -0,0 +1,9 @@ +{"ts":"2026-01-01T00:00:00Z","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".ts"} +{"ts":"2026-01-01T00:00:00Z","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".py"} +{"ts":"2026-01-01T00:00:00Z","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".go"} +{"ts":"2026-01-01T00:00:00Z","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".rs"} +{"ts":"2026-01-01T00:00:00Z","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".js"} +{"ts":"2026-01-01T00:00:00Z","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".cs"} +{"ts":"2026-01-01T00:00:00Z","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".rb"} +{"ts":"2026-01-01T00:00:00Z","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".java"} +{"ts":"2026-01-01T00:00:00Z","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".kt"} diff --git a/tests/hooks/fixtures/spec-gate/metrics-rotation-off/workspace/.specs/index.md b/tests/hooks/fixtures/spec-gate/metrics-rotation-off/workspace/.specs/index.md new file mode 100644 index 0000000..bbe4179 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/metrics-rotation-off/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status | Title | +|---|---|---|---| +| FEAT-TEST-001 | feature | in-progress | Conformance fixture feature | diff --git a/tests/hooks/fixtures/spec-gate/metrics-transition-event/expected.json b/tests/hooks/fixtures/spec-gate/metrics-transition-event/expected.json new file mode 100644 index 0000000..869158a --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/metrics-transition-event/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"decision":"block","permissionDecision":"deny","reason":"spec-gate: '.specs/index.md' is listed under paths.protected in .claude/project-config.json. Update via /sd:refactor or an ADR; never edit directly.","stderr":"","events":[{"ts":"","spec_id":"-","phase":"-","event":"gate","gate":"protected","decision":"block"},{"ts":"","spec_id":"FEAT-001","phase":"in-progress","event":"spec_transition","from":"approved","decision":"block"}]} \ No newline at end of file diff --git a/tests/hooks/fixtures/spec-gate/metrics-transition-event/input.json b/tests/hooks/fixtures/spec-gate/metrics-transition-event/input.json new file mode 100644 index 0000000..31304ce --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/metrics-transition-event/input.json @@ -0,0 +1 @@ +{"tool_name":"Edit","cwd":"{{CWD}}","tool_input":{"file_path":"{{CWD}}/.specs/index.md","old_string":"| FEAT-001 | feature | approved | Demo feature |","new_string":"| FEAT-001 | feature | in-progress | Demo feature |"}} \ No newline at end of file diff --git a/tests/hooks/fixtures/spec-gate/metrics-transition-event/workspace/.specs/index.md b/tests/hooks/fixtures/spec-gate/metrics-transition-event/workspace/.specs/index.md new file mode 100644 index 0000000..9257763 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/metrics-transition-event/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status | Title | +|---|---|---|---| +| FEAT-001 | feature | approved | Demo feature | diff --git a/tests/hooks/fixtures/spec-gate/no-config-protected-default/expected.json b/tests/hooks/fixtures/spec-gate/no-config-protected-default/expected.json new file mode 100644 index 0000000..00e6953 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/no-config-protected-default/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"decision":"block","permissionDecision":"deny","reason":"spec-gate: '.specs/constitution.md' is listed under paths.protected in .claude/project-config.json. Update via /sd:refactor or an ADR; never edit directly.","stderr":"","events":[{"ts":"","spec_id":"-","phase":"-","event":"gate","gate":"protected","decision":"block"}]} \ No newline at end of file diff --git a/tests/hooks/fixtures/spec-gate/no-config-protected-default/input.json b/tests/hooks/fixtures/spec-gate/no-config-protected-default/input.json new file mode 100644 index 0000000..c245920 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/no-config-protected-default/input.json @@ -0,0 +1 @@ +{"tool_name":"Edit","cwd":"{{CWD}}","tool_input":{"file_path":"{{CWD}}/.specs/constitution.md"}} diff --git a/tests/hooks/fixtures/spec-gate/no-config-protected-default/workspace/.specs/constitution.md b/tests/hooks/fixtures/spec-gate/no-config-protected-default/workspace/.specs/constitution.md new file mode 100644 index 0000000..45ac7be --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/no-config-protected-default/workspace/.specs/constitution.md @@ -0,0 +1 @@ +# Constitution diff --git a/tests/hooks/fixtures/spec-gate/no-config-protected-default/workspace/.specs/index.md b/tests/hooks/fixtures/spec-gate/no-config-protected-default/workspace/.specs/index.md new file mode 100644 index 0000000..d6f75b2 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/no-config-protected-default/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status (in-progress = active work) | Title | +|---|---|---|---| +| FEAT-DONE-002 | feature | done | Finished feature | diff --git a/tests/hooks/fixtures/spec-gate/no-config-warn-code/expected.json b/tests/hooks/fixtures/spec-gate/no-config-warn-code/expected.json new file mode 100644 index 0000000..7dd241c --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/no-config-warn-code/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"decision":"warn","permissionDecision":null,"reason":null,"stderr":"[WARN] spec-gate: editing code file 'src/Foo.py' but no in-progress spec is recorded in .specs/index.md. Run /sd:feature, /sd:bug, /sd:refactor, or /sd:perf first to create a spec, or set hooks.specGate.mode='off' in .claude/project-config.json to disable.","events":[{"ts":"","spec_id":"-","phase":"-","event":"gate","gate":"code-edit","decision":"warn","ext":".py"}]} \ No newline at end of file diff --git a/tests/hooks/fixtures/spec-gate/no-config-warn-code/input.json b/tests/hooks/fixtures/spec-gate/no-config-warn-code/input.json new file mode 100644 index 0000000..4eef248 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/no-config-warn-code/input.json @@ -0,0 +1 @@ +{"tool_name":"Edit","cwd":"{{CWD}}","tool_input":{"file_path":"{{CWD}}/src/Foo.py"}} diff --git a/tests/hooks/fixtures/spec-gate/no-config-warn-code/workspace/.specs/index.md b/tests/hooks/fixtures/spec-gate/no-config-warn-code/workspace/.specs/index.md new file mode 100644 index 0000000..d6f75b2 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/no-config-warn-code/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status (in-progress = active work) | Title | +|---|---|---|---| +| FEAT-DONE-002 | feature | done | Finished feature | diff --git a/tests/hooks/fixtures/spec-gate/warn-code-no-spec/expected.json b/tests/hooks/fixtures/spec-gate/warn-code-no-spec/expected.json new file mode 100644 index 0000000..7dd241c --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/warn-code-no-spec/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"decision":"warn","permissionDecision":null,"reason":null,"stderr":"[WARN] spec-gate: editing code file 'src/Foo.py' but no in-progress spec is recorded in .specs/index.md. Run /sd:feature, /sd:bug, /sd:refactor, or /sd:perf first to create a spec, or set hooks.specGate.mode='off' in .claude/project-config.json to disable.","events":[{"ts":"","spec_id":"-","phase":"-","event":"gate","gate":"code-edit","decision":"warn","ext":".py"}]} \ No newline at end of file diff --git a/tests/hooks/fixtures/spec-gate/warn-code-no-spec/input.json b/tests/hooks/fixtures/spec-gate/warn-code-no-spec/input.json new file mode 100644 index 0000000..4eef248 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/warn-code-no-spec/input.json @@ -0,0 +1 @@ +{"tool_name":"Edit","cwd":"{{CWD}}","tool_input":{"file_path":"{{CWD}}/src/Foo.py"}} diff --git a/tests/hooks/fixtures/spec-gate/warn-code-no-spec/workspace/.claude/project-config.json b/tests/hooks/fixtures/spec-gate/warn-code-no-spec/workspace/.claude/project-config.json new file mode 100644 index 0000000..05ff1ab --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/warn-code-no-spec/workspace/.claude/project-config.json @@ -0,0 +1,5 @@ +{ + "spec": { "dir": ".specs", "indexFile": ".specs/index.md" }, + "paths": { "protected": [".specs/constitution.md"] }, + "hooks": { "specGate": { "enabled": true, "mode": "warn" } } +} diff --git a/tests/hooks/fixtures/spec-gate/warn-code-no-spec/workspace/.specs/index.md b/tests/hooks/fixtures/spec-gate/warn-code-no-spec/workspace/.specs/index.md new file mode 100644 index 0000000..d6f75b2 --- /dev/null +++ b/tests/hooks/fixtures/spec-gate/warn-code-no-spec/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status (in-progress = active work) | Title | +|---|---|---|---| +| FEAT-DONE-002 | feature | done | Finished feature | diff --git a/tests/hooks/fixtures/subagent-retro/debounce-minutes-zero-honored/expected.json b/tests/hooks/fixtures/subagent-retro/debounce-minutes-zero-honored/expected.json new file mode 100644 index 0000000..60dd294 --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/debounce-minutes-zero-honored/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"emitted":true,"stale":[{"id":"FEAT-TEST-001","reason":"stale","ageMinutes":120,"thresholdMinutes":30}],"lessons":[],"stderr":"","events":[{"ts":"","spec_id":"FEAT-TEST-001","phase":"in-progress","event":"subagent_stop","stale":1}]} diff --git a/tests/hooks/fixtures/subagent-retro/debounce-minutes-zero-honored/input.json b/tests/hooks/fixtures/subagent-retro/debounce-minutes-zero-honored/input.json new file mode 100644 index 0000000..4e5c1fb --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/debounce-minutes-zero-honored/input.json @@ -0,0 +1 @@ +{"cwd":"{{CWD}}","session_id":"conformance-fixture"} diff --git a/tests/hooks/fixtures/subagent-retro/debounce-minutes-zero-honored/setup.json b/tests/hooks/fixtures/subagent-retro/debounce-minutes-zero-honored/setup.json new file mode 100644 index 0000000..6fe652e --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/debounce-minutes-zero-honored/setup.json @@ -0,0 +1 @@ +{ "touch": [ { "path": ".specs/FEAT-TEST-001/05-retro.md", "ageMinutes": 120 } ], "write": [ { "path": ".claude/.hookstate/subagent-retro-conformance-fixture.json", "content": "{\"lastReminderUtc\":\"{{UTCNOW-2M}}\"}" } ] } diff --git a/tests/hooks/fixtures/subagent-retro/debounce-minutes-zero-honored/workspace/.claude/project-config.json b/tests/hooks/fixtures/subagent-retro/debounce-minutes-zero-honored/workspace/.claude/project-config.json new file mode 100644 index 0000000..8b3dcdd --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/debounce-minutes-zero-honored/workspace/.claude/project-config.json @@ -0,0 +1,4 @@ +{ + "spec": { "dir": ".specs", "indexFile": ".specs/index.md" }, + "hooks": { "subagentRetro": { "enabled": true, "retroStaleMinutes": 30, "debounceMinutes": 0 } } +} diff --git a/tests/hooks/fixtures/subagent-retro/debounce-minutes-zero-honored/workspace/.specs/FEAT-TEST-001/05-retro.md b/tests/hooks/fixtures/subagent-retro/debounce-minutes-zero-honored/workspace/.specs/FEAT-TEST-001/05-retro.md new file mode 100644 index 0000000..57c282e --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/debounce-minutes-zero-honored/workspace/.specs/FEAT-TEST-001/05-retro.md @@ -0,0 +1 @@ +# Retro diff --git a/tests/hooks/fixtures/subagent-retro/debounce-minutes-zero-honored/workspace/.specs/index.md b/tests/hooks/fixtures/subagent-retro/debounce-minutes-zero-honored/workspace/.specs/index.md new file mode 100644 index 0000000..bbe4179 --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/debounce-minutes-zero-honored/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status | Title | +|---|---|---|---| +| FEAT-TEST-001 | feature | in-progress | Conformance fixture feature | diff --git a/tests/hooks/fixtures/subagent-retro/debounce-suppresses-reminder/expected.json b/tests/hooks/fixtures/subagent-retro/debounce-suppresses-reminder/expected.json new file mode 100644 index 0000000..74bf863 --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/debounce-suppresses-reminder/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"emitted":false,"stale":[],"lessons":[],"stderr":"","events":[{"ts":"","spec_id":"FEAT-TEST-001","phase":"in-progress","event":"subagent_stop","stale":1}]} \ No newline at end of file diff --git a/tests/hooks/fixtures/subagent-retro/debounce-suppresses-reminder/input.json b/tests/hooks/fixtures/subagent-retro/debounce-suppresses-reminder/input.json new file mode 100644 index 0000000..4e5c1fb --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/debounce-suppresses-reminder/input.json @@ -0,0 +1 @@ +{"cwd":"{{CWD}}","session_id":"conformance-fixture"} diff --git a/tests/hooks/fixtures/subagent-retro/debounce-suppresses-reminder/setup.json b/tests/hooks/fixtures/subagent-retro/debounce-suppresses-reminder/setup.json new file mode 100644 index 0000000..f7ef749 --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/debounce-suppresses-reminder/setup.json @@ -0,0 +1 @@ +{ "write": [ { "path": ".claude/.hookstate/subagent-retro-conformance-fixture.json", "content": "{\"lastReminderUtc\":\"{{UTCNOW-2M}}\"}" } ] } diff --git a/tests/hooks/fixtures/subagent-retro/debounce-suppresses-reminder/workspace/.claude/project-config.json b/tests/hooks/fixtures/subagent-retro/debounce-suppresses-reminder/workspace/.claude/project-config.json new file mode 100644 index 0000000..18b4b9e --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/debounce-suppresses-reminder/workspace/.claude/project-config.json @@ -0,0 +1,4 @@ +{ + "spec": { "dir": ".specs", "indexFile": ".specs/index.md" }, + "hooks": { "subagentRetro": { "enabled": true, "retroStaleMinutes": 30, "debounceMinutes": 10 } } +} diff --git a/tests/hooks/fixtures/subagent-retro/debounce-suppresses-reminder/workspace/.specs/index.md b/tests/hooks/fixtures/subagent-retro/debounce-suppresses-reminder/workspace/.specs/index.md new file mode 100644 index 0000000..bbe4179 --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/debounce-suppresses-reminder/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status | Title | +|---|---|---|---| +| FEAT-TEST-001 | feature | in-progress | Conformance fixture feature | diff --git a/tests/hooks/fixtures/subagent-retro/debounce-window-elapsed/expected.json b/tests/hooks/fixtures/subagent-retro/debounce-window-elapsed/expected.json new file mode 100644 index 0000000..5a32e3f --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/debounce-window-elapsed/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"emitted":true,"stale":[{"id":"FEAT-TEST-001","reason":"missing","ageMinutes":null,"thresholdMinutes":null}],"lessons":[],"stderr":"","events":[{"ts":"","spec_id":"FEAT-TEST-001","phase":"in-progress","event":"subagent_stop","stale":1}]} \ No newline at end of file diff --git a/tests/hooks/fixtures/subagent-retro/debounce-window-elapsed/input.json b/tests/hooks/fixtures/subagent-retro/debounce-window-elapsed/input.json new file mode 100644 index 0000000..4e5c1fb --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/debounce-window-elapsed/input.json @@ -0,0 +1 @@ +{"cwd":"{{CWD}}","session_id":"conformance-fixture"} diff --git a/tests/hooks/fixtures/subagent-retro/debounce-window-elapsed/setup.json b/tests/hooks/fixtures/subagent-retro/debounce-window-elapsed/setup.json new file mode 100644 index 0000000..3d65e8c --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/debounce-window-elapsed/setup.json @@ -0,0 +1 @@ +{ "write": [ { "path": ".claude/.hookstate/subagent-retro-conformance-fixture.json", "content": "{\"lastReminderUtc\":\"{{UTCNOW-45M}}\"}" } ] } diff --git a/tests/hooks/fixtures/subagent-retro/debounce-window-elapsed/workspace/.claude/project-config.json b/tests/hooks/fixtures/subagent-retro/debounce-window-elapsed/workspace/.claude/project-config.json new file mode 100644 index 0000000..18b4b9e --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/debounce-window-elapsed/workspace/.claude/project-config.json @@ -0,0 +1,4 @@ +{ + "spec": { "dir": ".specs", "indexFile": ".specs/index.md" }, + "hooks": { "subagentRetro": { "enabled": true, "retroStaleMinutes": 30, "debounceMinutes": 10 } } +} diff --git a/tests/hooks/fixtures/subagent-retro/debounce-window-elapsed/workspace/.specs/index.md b/tests/hooks/fixtures/subagent-retro/debounce-window-elapsed/workspace/.specs/index.md new file mode 100644 index 0000000..bbe4179 --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/debounce-window-elapsed/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status | Title | +|---|---|---|---| +| FEAT-TEST-001 | feature | in-progress | Conformance fixture feature | diff --git a/tests/hooks/fixtures/subagent-retro/enabled-absent-still-on/expected.json b/tests/hooks/fixtures/subagent-retro/enabled-absent-still-on/expected.json new file mode 100644 index 0000000..a22e5a1 --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/enabled-absent-still-on/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"emitted":true,"stale":[{"id":"FEAT-TEST-001","reason":"missing","ageMinutes":null,"thresholdMinutes":null}],"lessons":[],"stderr":"","events":[{"ts":"","spec_id":"FEAT-TEST-001","phase":"in-progress","event":"subagent_stop","stale":1},{"ts":"","spec_id":"RCA-2026-001","phase":"in-progress","event":"subagent_stop","stale":0}]} diff --git a/tests/hooks/fixtures/subagent-retro/enabled-absent-still-on/input.json b/tests/hooks/fixtures/subagent-retro/enabled-absent-still-on/input.json new file mode 100644 index 0000000..4e5c1fb --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/enabled-absent-still-on/input.json @@ -0,0 +1 @@ +{"cwd":"{{CWD}}","session_id":"conformance-fixture"} diff --git a/tests/hooks/fixtures/subagent-retro/enabled-absent-still-on/workspace/.claude/project-config.json b/tests/hooks/fixtures/subagent-retro/enabled-absent-still-on/workspace/.claude/project-config.json new file mode 100644 index 0000000..d6a349f --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/enabled-absent-still-on/workspace/.claude/project-config.json @@ -0,0 +1,4 @@ +{ + "spec": { "dir": ".specs", "indexFile": ".specs/index.md" }, + "hooks": { "subagentRetro": { "retroStaleMinutes": 30, "debounceMinutes": 10 } } +} diff --git a/tests/hooks/fixtures/subagent-retro/enabled-absent-still-on/workspace/.specs/index.md b/tests/hooks/fixtures/subagent-retro/enabled-absent-still-on/workspace/.specs/index.md new file mode 100644 index 0000000..f3524e8 --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/enabled-absent-still-on/workspace/.specs/index.md @@ -0,0 +1,4 @@ +| ID | Type | Status | Title | +|---|---|---|---| +| FEAT-TEST-001 | feature | in-progress | Conformance fixture feature | +| RCA-2026-001 | rca | in-progress | Incident writeup | diff --git a/tests/hooks/fixtures/subagent-retro/lessons-already-shown/expected.json b/tests/hooks/fixtures/subagent-retro/lessons-already-shown/expected.json new file mode 100644 index 0000000..dd18a30 --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/lessons-already-shown/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"emitted":false,"stale":[],"lessons":["- [missed-context] high/feature: Re-run impact analysis after any spec refinement, since a refined scope invalidates the earlier map.","- [baseline-attribution] low/all: Confirm pre-existing failures against a clean baseline before attributing or dismissing them. (2)"],"stderr":"","events":[{"ts":"","spec_id":"FEAT-TEST-001","phase":"in-progress","event":"subagent_stop","stale":0}]} \ No newline at end of file diff --git a/tests/hooks/fixtures/subagent-retro/lessons-already-shown/input.json b/tests/hooks/fixtures/subagent-retro/lessons-already-shown/input.json new file mode 100644 index 0000000..4e5c1fb --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/lessons-already-shown/input.json @@ -0,0 +1 @@ +{"cwd":"{{CWD}}","session_id":"conformance-fixture"} diff --git a/tests/hooks/fixtures/subagent-retro/lessons-already-shown/setup.json b/tests/hooks/fixtures/subagent-retro/lessons-already-shown/setup.json new file mode 100644 index 0000000..661b6a0 --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/lessons-already-shown/setup.json @@ -0,0 +1 @@ +{ "touch": [ { "path": ".specs/FEAT-TEST-001/05-retro.md", "ageMinutes": 5 } ] } diff --git a/tests/hooks/fixtures/subagent-retro/lessons-already-shown/workspace/.claude/.hookstate/subagent-retro-conformance-fixture.json b/tests/hooks/fixtures/subagent-retro/lessons-already-shown/workspace/.claude/.hookstate/subagent-retro-conformance-fixture.json new file mode 100644 index 0000000..a8a7895 --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/lessons-already-shown/workspace/.claude/.hookstate/subagent-retro-conformance-fixture.json @@ -0,0 +1 @@ +{"shownLessons":["- [sibling-repo-assumption] high/feature: When mirroring a sibling repository, verify the local shared helper matches before copying an attribute.","- [missed-context] high/all: A self-test that plants a hardcoded value stops testing the moment reality moves; derive the value it plants."]} \ No newline at end of file diff --git a/tests/hooks/fixtures/subagent-retro/lessons-already-shown/workspace/.claude/project-config.json b/tests/hooks/fixtures/subagent-retro/lessons-already-shown/workspace/.claude/project-config.json new file mode 100644 index 0000000..ecd1730 --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/lessons-already-shown/workspace/.claude/project-config.json @@ -0,0 +1,4 @@ +{ + "spec": { "dir": ".specs", "indexFile": ".specs/index.md" }, + "hooks": { "subagentRetro": { "enabled": true, "maxLessons": 2 } } +} diff --git a/tests/hooks/fixtures/subagent-retro/lessons-already-shown/workspace/.specs/FEAT-TEST-001/05-retro.md b/tests/hooks/fixtures/subagent-retro/lessons-already-shown/workspace/.specs/FEAT-TEST-001/05-retro.md new file mode 100644 index 0000000..57c282e --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/lessons-already-shown/workspace/.specs/FEAT-TEST-001/05-retro.md @@ -0,0 +1 @@ +# Retro diff --git a/tests/hooks/fixtures/subagent-retro/lessons-already-shown/workspace/.specs/_lessons/lessons.md b/tests/hooks/fixtures/subagent-retro/lessons-already-shown/workspace/.specs/_lessons/lessons.md new file mode 100644 index 0000000..52d24ef --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/lessons-already-shown/workspace/.specs/_lessons/lessons.md @@ -0,0 +1,37 @@ +# Lessons + +GENERATED FILE - do not edit by hand. Regenerate with +`scripts/aggregate-lessons.sh`; edits are lost on the next run. + +Every rule below is written to be free of identifiers - no paths, file names, +line numbers, class or variable names - so this file can be shared outside the +organisation as-is. That contract is enforced by `scripts/validate-lessons.*` +and is the reason a lesson reads as a general rule rather than a bug report. + +A trailing count is the number of retros a lesson was drawn from. Frequency +never raises severity. + +## sibling-repo-assumption + +- [sibling-repo-assumption] high/feature: When mirroring a sibling repository, verify the local shared helper matches before copying an attribute. + +## missed-context + +- [missed-context] high/all: A self-test that plants a hardcoded value stops testing the moment reality moves; derive the value it plants. +- [missed-context] high/feature: Re-run impact analysis after any spec refinement, since a refined scope invalidates the earlier map. + +## baseline-attribution + +- [baseline-attribution] low/all: Confirm pre-existing failures against a clean baseline before attributing or dismissing them. (2) + +## tooling-surprise + +- [tooling-surprise] low/all: Check the working tree state after any command that stashes or regenerates project metadata. + +## gate-friction + +- [gate-friction] medium/refactor: When waiving a coverage gate, record measured coverage, residual risk, and what would satisfy it later. + +## test-gap + +- [test-gap] medium/feature: Create the missing test project before the first task rather than midway, even under PowerShell tooling. diff --git a/tests/hooks/fixtures/subagent-retro/lessons-already-shown/workspace/.specs/index.md b/tests/hooks/fixtures/subagent-retro/lessons-already-shown/workspace/.specs/index.md new file mode 100644 index 0000000..bbe4179 --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/lessons-already-shown/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status | Title | +|---|---|---|---| +| FEAT-TEST-001 | feature | in-progress | Conformance fixture feature | diff --git a/tests/hooks/fixtures/subagent-retro/lessons-disabled/expected.json b/tests/hooks/fixtures/subagent-retro/lessons-disabled/expected.json new file mode 100644 index 0000000..b5b1598 --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/lessons-disabled/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"emitted":false,"stale":[],"lessons":[],"stderr":"","events":[{"ts":"","spec_id":"FEAT-TEST-001","phase":"in-progress","event":"subagent_stop","stale":0}]} \ No newline at end of file diff --git a/tests/hooks/fixtures/subagent-retro/lessons-disabled/input.json b/tests/hooks/fixtures/subagent-retro/lessons-disabled/input.json new file mode 100644 index 0000000..4e5c1fb --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/lessons-disabled/input.json @@ -0,0 +1 @@ +{"cwd":"{{CWD}}","session_id":"conformance-fixture"} diff --git a/tests/hooks/fixtures/subagent-retro/lessons-disabled/setup.json b/tests/hooks/fixtures/subagent-retro/lessons-disabled/setup.json new file mode 100644 index 0000000..661b6a0 --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/lessons-disabled/setup.json @@ -0,0 +1 @@ +{ "touch": [ { "path": ".specs/FEAT-TEST-001/05-retro.md", "ageMinutes": 5 } ] } diff --git a/tests/hooks/fixtures/subagent-retro/lessons-disabled/workspace/.claude/project-config.json b/tests/hooks/fixtures/subagent-retro/lessons-disabled/workspace/.claude/project-config.json new file mode 100644 index 0000000..d992644 --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/lessons-disabled/workspace/.claude/project-config.json @@ -0,0 +1,4 @@ +{ + "spec": { "dir": ".specs", "indexFile": ".specs/index.md" }, + "hooks": { "subagentRetro": { "enabled": true, "injectLessons": false } } +} diff --git a/tests/hooks/fixtures/subagent-retro/lessons-disabled/workspace/.specs/FEAT-TEST-001/05-retro.md b/tests/hooks/fixtures/subagent-retro/lessons-disabled/workspace/.specs/FEAT-TEST-001/05-retro.md new file mode 100644 index 0000000..57c282e --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/lessons-disabled/workspace/.specs/FEAT-TEST-001/05-retro.md @@ -0,0 +1 @@ +# Retro diff --git a/tests/hooks/fixtures/subagent-retro/lessons-disabled/workspace/.specs/_lessons/lessons.md b/tests/hooks/fixtures/subagent-retro/lessons-disabled/workspace/.specs/_lessons/lessons.md new file mode 100644 index 0000000..52d24ef --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/lessons-disabled/workspace/.specs/_lessons/lessons.md @@ -0,0 +1,37 @@ +# Lessons + +GENERATED FILE - do not edit by hand. Regenerate with +`scripts/aggregate-lessons.sh`; edits are lost on the next run. + +Every rule below is written to be free of identifiers - no paths, file names, +line numbers, class or variable names - so this file can be shared outside the +organisation as-is. That contract is enforced by `scripts/validate-lessons.*` +and is the reason a lesson reads as a general rule rather than a bug report. + +A trailing count is the number of retros a lesson was drawn from. Frequency +never raises severity. + +## sibling-repo-assumption + +- [sibling-repo-assumption] high/feature: When mirroring a sibling repository, verify the local shared helper matches before copying an attribute. + +## missed-context + +- [missed-context] high/all: A self-test that plants a hardcoded value stops testing the moment reality moves; derive the value it plants. +- [missed-context] high/feature: Re-run impact analysis after any spec refinement, since a refined scope invalidates the earlier map. + +## baseline-attribution + +- [baseline-attribution] low/all: Confirm pre-existing failures against a clean baseline before attributing or dismissing them. (2) + +## tooling-surprise + +- [tooling-surprise] low/all: Check the working tree state after any command that stashes or regenerates project metadata. + +## gate-friction + +- [gate-friction] medium/refactor: When waiving a coverage gate, record measured coverage, residual risk, and what would satisfy it later. + +## test-gap + +- [test-gap] medium/feature: Create the missing test project before the first task rather than midway, even under PowerShell tooling. diff --git a/tests/hooks/fixtures/subagent-retro/lessons-disabled/workspace/.specs/index.md b/tests/hooks/fixtures/subagent-retro/lessons-disabled/workspace/.specs/index.md new file mode 100644 index 0000000..bbe4179 --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/lessons-disabled/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status | Title | +|---|---|---|---| +| FEAT-TEST-001 | feature | in-progress | Conformance fixture feature | diff --git a/tests/hooks/fixtures/subagent-retro/lessons-scope-filter/expected.json b/tests/hooks/fixtures/subagent-retro/lessons-scope-filter/expected.json new file mode 100644 index 0000000..a2dbb8e --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/lessons-scope-filter/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"emitted":false,"stale":[],"lessons":["- [missed-context] high/all: A self-test that plants a hardcoded value stops testing the moment reality moves; derive the value it plants.","- [baseline-attribution] low/all: Confirm pre-existing failures against a clean baseline before attributing or dismissing them. (2)","- [tooling-surprise] low/all: Check the working tree state after any command that stashes or regenerates project metadata.","- [gate-friction] medium/refactor: When waiving a coverage gate, record measured coverage, residual risk, and what would satisfy it later."],"stderr":"","events":[{"ts":"","spec_id":"REF-TEST-002","phase":"in-progress","event":"subagent_stop","stale":0}]} \ No newline at end of file diff --git a/tests/hooks/fixtures/subagent-retro/lessons-scope-filter/input.json b/tests/hooks/fixtures/subagent-retro/lessons-scope-filter/input.json new file mode 100644 index 0000000..4e5c1fb --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/lessons-scope-filter/input.json @@ -0,0 +1 @@ +{"cwd":"{{CWD}}","session_id":"conformance-fixture"} diff --git a/tests/hooks/fixtures/subagent-retro/lessons-scope-filter/setup.json b/tests/hooks/fixtures/subagent-retro/lessons-scope-filter/setup.json new file mode 100644 index 0000000..8b47dcc --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/lessons-scope-filter/setup.json @@ -0,0 +1 @@ +{ "touch": [ { "path": ".specs/REF-TEST-002/05-retro.md", "ageMinutes": 5 } ] } diff --git a/tests/hooks/fixtures/subagent-retro/lessons-scope-filter/workspace/.claude/project-config.json b/tests/hooks/fixtures/subagent-retro/lessons-scope-filter/workspace/.claude/project-config.json new file mode 100644 index 0000000..1a39f05 --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/lessons-scope-filter/workspace/.claude/project-config.json @@ -0,0 +1,4 @@ +{ + "spec": { "dir": ".specs", "indexFile": ".specs/index.md" }, + "hooks": { "subagentRetro": { "enabled": true, "maxLessons": 5 } } +} diff --git a/tests/hooks/fixtures/subagent-retro/lessons-scope-filter/workspace/.specs/REF-TEST-002/05-retro.md b/tests/hooks/fixtures/subagent-retro/lessons-scope-filter/workspace/.specs/REF-TEST-002/05-retro.md new file mode 100644 index 0000000..57c282e --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/lessons-scope-filter/workspace/.specs/REF-TEST-002/05-retro.md @@ -0,0 +1 @@ +# Retro diff --git a/tests/hooks/fixtures/subagent-retro/lessons-scope-filter/workspace/.specs/_lessons/lessons.md b/tests/hooks/fixtures/subagent-retro/lessons-scope-filter/workspace/.specs/_lessons/lessons.md new file mode 100644 index 0000000..52d24ef --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/lessons-scope-filter/workspace/.specs/_lessons/lessons.md @@ -0,0 +1,37 @@ +# Lessons + +GENERATED FILE - do not edit by hand. Regenerate with +`scripts/aggregate-lessons.sh`; edits are lost on the next run. + +Every rule below is written to be free of identifiers - no paths, file names, +line numbers, class or variable names - so this file can be shared outside the +organisation as-is. That contract is enforced by `scripts/validate-lessons.*` +and is the reason a lesson reads as a general rule rather than a bug report. + +A trailing count is the number of retros a lesson was drawn from. Frequency +never raises severity. + +## sibling-repo-assumption + +- [sibling-repo-assumption] high/feature: When mirroring a sibling repository, verify the local shared helper matches before copying an attribute. + +## missed-context + +- [missed-context] high/all: A self-test that plants a hardcoded value stops testing the moment reality moves; derive the value it plants. +- [missed-context] high/feature: Re-run impact analysis after any spec refinement, since a refined scope invalidates the earlier map. + +## baseline-attribution + +- [baseline-attribution] low/all: Confirm pre-existing failures against a clean baseline before attributing or dismissing them. (2) + +## tooling-surprise + +- [tooling-surprise] low/all: Check the working tree state after any command that stashes or regenerates project metadata. + +## gate-friction + +- [gate-friction] medium/refactor: When waiving a coverage gate, record measured coverage, residual risk, and what would satisfy it later. + +## test-gap + +- [test-gap] medium/feature: Create the missing test project before the first task rather than midway, even under PowerShell tooling. diff --git a/tests/hooks/fixtures/subagent-retro/lessons-scope-filter/workspace/.specs/index.md b/tests/hooks/fixtures/subagent-retro/lessons-scope-filter/workspace/.specs/index.md new file mode 100644 index 0000000..dc53680 --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/lessons-scope-filter/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status | Title | +|---|---|---|---| +| REF-TEST-002 | refactor | in-progress | Conformance fixture refactor | diff --git a/tests/hooks/fixtures/subagent-retro/lessons-surfaced/expected.json b/tests/hooks/fixtures/subagent-retro/lessons-surfaced/expected.json new file mode 100644 index 0000000..8e1724b --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/lessons-surfaced/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"emitted":false,"stale":[],"lessons":["- [sibling-repo-assumption] high/feature: When mirroring a sibling repository, verify the local shared helper matches before copying an attribute.","- [missed-context] high/all: A self-test that plants a hardcoded value stops testing the moment reality moves; derive the value it plants."],"stderr":"","events":[{"ts":"","spec_id":"FEAT-TEST-001","phase":"in-progress","event":"subagent_stop","stale":0}]} \ No newline at end of file diff --git a/tests/hooks/fixtures/subagent-retro/lessons-surfaced/input.json b/tests/hooks/fixtures/subagent-retro/lessons-surfaced/input.json new file mode 100644 index 0000000..4e5c1fb --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/lessons-surfaced/input.json @@ -0,0 +1 @@ +{"cwd":"{{CWD}}","session_id":"conformance-fixture"} diff --git a/tests/hooks/fixtures/subagent-retro/lessons-surfaced/setup.json b/tests/hooks/fixtures/subagent-retro/lessons-surfaced/setup.json new file mode 100644 index 0000000..661b6a0 --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/lessons-surfaced/setup.json @@ -0,0 +1 @@ +{ "touch": [ { "path": ".specs/FEAT-TEST-001/05-retro.md", "ageMinutes": 5 } ] } diff --git a/tests/hooks/fixtures/subagent-retro/lessons-surfaced/workspace/.claude/project-config.json b/tests/hooks/fixtures/subagent-retro/lessons-surfaced/workspace/.claude/project-config.json new file mode 100644 index 0000000..ecd1730 --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/lessons-surfaced/workspace/.claude/project-config.json @@ -0,0 +1,4 @@ +{ + "spec": { "dir": ".specs", "indexFile": ".specs/index.md" }, + "hooks": { "subagentRetro": { "enabled": true, "maxLessons": 2 } } +} diff --git a/tests/hooks/fixtures/subagent-retro/lessons-surfaced/workspace/.specs/FEAT-TEST-001/05-retro.md b/tests/hooks/fixtures/subagent-retro/lessons-surfaced/workspace/.specs/FEAT-TEST-001/05-retro.md new file mode 100644 index 0000000..57c282e --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/lessons-surfaced/workspace/.specs/FEAT-TEST-001/05-retro.md @@ -0,0 +1 @@ +# Retro diff --git a/tests/hooks/fixtures/subagent-retro/lessons-surfaced/workspace/.specs/_lessons/lessons.md b/tests/hooks/fixtures/subagent-retro/lessons-surfaced/workspace/.specs/_lessons/lessons.md new file mode 100644 index 0000000..52d24ef --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/lessons-surfaced/workspace/.specs/_lessons/lessons.md @@ -0,0 +1,37 @@ +# Lessons + +GENERATED FILE - do not edit by hand. Regenerate with +`scripts/aggregate-lessons.sh`; edits are lost on the next run. + +Every rule below is written to be free of identifiers - no paths, file names, +line numbers, class or variable names - so this file can be shared outside the +organisation as-is. That contract is enforced by `scripts/validate-lessons.*` +and is the reason a lesson reads as a general rule rather than a bug report. + +A trailing count is the number of retros a lesson was drawn from. Frequency +never raises severity. + +## sibling-repo-assumption + +- [sibling-repo-assumption] high/feature: When mirroring a sibling repository, verify the local shared helper matches before copying an attribute. + +## missed-context + +- [missed-context] high/all: A self-test that plants a hardcoded value stops testing the moment reality moves; derive the value it plants. +- [missed-context] high/feature: Re-run impact analysis after any spec refinement, since a refined scope invalidates the earlier map. + +## baseline-attribution + +- [baseline-attribution] low/all: Confirm pre-existing failures against a clean baseline before attributing or dismissing them. (2) + +## tooling-surprise + +- [tooling-surprise] low/all: Check the working tree state after any command that stashes or regenerates project metadata. + +## gate-friction + +- [gate-friction] medium/refactor: When waiving a coverage gate, record measured coverage, residual risk, and what would satisfy it later. + +## test-gap + +- [test-gap] medium/feature: Create the missing test project before the first task rather than midway, even under PowerShell tooling. diff --git a/tests/hooks/fixtures/subagent-retro/lessons-surfaced/workspace/.specs/index.md b/tests/hooks/fixtures/subagent-retro/lessons-surfaced/workspace/.specs/index.md new file mode 100644 index 0000000..bbe4179 --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/lessons-surfaced/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status | Title | +|---|---|---|---| +| FEAT-TEST-001 | feature | in-progress | Conformance fixture feature | diff --git a/tests/hooks/fixtures/subagent-retro/metrics-disabled-no-write/expected.json b/tests/hooks/fixtures/subagent-retro/metrics-disabled-no-write/expected.json new file mode 100644 index 0000000..3165e98 --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/metrics-disabled-no-write/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"emitted":true,"stale":[{"id":"FEAT-TEST-001","reason":"missing","ageMinutes":null,"thresholdMinutes":null}],"lessons":[],"stderr":"","events":[]} \ No newline at end of file diff --git a/tests/hooks/fixtures/subagent-retro/metrics-disabled-no-write/input.json b/tests/hooks/fixtures/subagent-retro/metrics-disabled-no-write/input.json new file mode 100644 index 0000000..4749e6b --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/metrics-disabled-no-write/input.json @@ -0,0 +1 @@ +{"cwd":"{{CWD}}","session_id":"conformance-fixture"} \ No newline at end of file diff --git a/tests/hooks/fixtures/subagent-retro/metrics-disabled-no-write/workspace/.claude/project-config.json b/tests/hooks/fixtures/subagent-retro/metrics-disabled-no-write/workspace/.claude/project-config.json new file mode 100644 index 0000000..12e3fb4 --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/metrics-disabled-no-write/workspace/.claude/project-config.json @@ -0,0 +1,4 @@ +{ + "spec": { "dir": ".specs", "indexFile": ".specs/index.md" }, + "hooks": { "subagentRetro": { "enabled": true, "retroStaleMinutes": 30, "debounceMinutes": 10 }, "metrics": { "enabled": false } } +} diff --git a/tests/hooks/fixtures/subagent-retro/metrics-disabled-no-write/workspace/.specs/index.md b/tests/hooks/fixtures/subagent-retro/metrics-disabled-no-write/workspace/.specs/index.md new file mode 100644 index 0000000..bbe4179 --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/metrics-disabled-no-write/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status | Title | +|---|---|---|---| +| FEAT-TEST-001 | feature | in-progress | Conformance fixture feature | diff --git a/tests/hooks/fixtures/subagent-retro/metrics-emits-when-debounced/expected.json b/tests/hooks/fixtures/subagent-retro/metrics-emits-when-debounced/expected.json new file mode 100644 index 0000000..74bf863 --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/metrics-emits-when-debounced/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"emitted":false,"stale":[],"lessons":[],"stderr":"","events":[{"ts":"","spec_id":"FEAT-TEST-001","phase":"in-progress","event":"subagent_stop","stale":1}]} \ No newline at end of file diff --git a/tests/hooks/fixtures/subagent-retro/metrics-emits-when-debounced/input.json b/tests/hooks/fixtures/subagent-retro/metrics-emits-when-debounced/input.json new file mode 100644 index 0000000..4749e6b --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/metrics-emits-when-debounced/input.json @@ -0,0 +1 @@ +{"cwd":"{{CWD}}","session_id":"conformance-fixture"} \ No newline at end of file diff --git a/tests/hooks/fixtures/subagent-retro/metrics-emits-when-debounced/setup.json b/tests/hooks/fixtures/subagent-retro/metrics-emits-when-debounced/setup.json new file mode 100644 index 0000000..65e3a51 --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/metrics-emits-when-debounced/setup.json @@ -0,0 +1,4 @@ +{ + "touch": [ { "path": ".specs/FEAT-TEST-001/05-retro.md", "ageMinutes": 120 } ], + "write": [ { "path": ".claude/.hookstate/subagent-retro-conformance-fixture.json", "content": "{\"lastReminderUtc\":\"{{UTCNOW-2M}}\"}" } ] +} \ No newline at end of file diff --git a/tests/hooks/fixtures/subagent-retro/metrics-emits-when-debounced/workspace/.claude/project-config.json b/tests/hooks/fixtures/subagent-retro/metrics-emits-when-debounced/workspace/.claude/project-config.json new file mode 100644 index 0000000..18b4b9e --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/metrics-emits-when-debounced/workspace/.claude/project-config.json @@ -0,0 +1,4 @@ +{ + "spec": { "dir": ".specs", "indexFile": ".specs/index.md" }, + "hooks": { "subagentRetro": { "enabled": true, "retroStaleMinutes": 30, "debounceMinutes": 10 } } +} diff --git a/tests/hooks/fixtures/subagent-retro/metrics-emits-when-debounced/workspace/.specs/FEAT-TEST-001/05-retro.md b/tests/hooks/fixtures/subagent-retro/metrics-emits-when-debounced/workspace/.specs/FEAT-TEST-001/05-retro.md new file mode 100644 index 0000000..cc75bc3 --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/metrics-emits-when-debounced/workspace/.specs/FEAT-TEST-001/05-retro.md @@ -0,0 +1,3 @@ +# Retro - FEAT-TEST-001 + +placeholder retro content for conformance fixture. diff --git a/tests/hooks/fixtures/subagent-retro/metrics-emits-when-debounced/workspace/.specs/index.md b/tests/hooks/fixtures/subagent-retro/metrics-emits-when-debounced/workspace/.specs/index.md new file mode 100644 index 0000000..bbe4179 --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/metrics-emits-when-debounced/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status | Title | +|---|---|---|---| +| FEAT-TEST-001 | feature | in-progress | Conformance fixture feature | diff --git a/tests/hooks/fixtures/subagent-retro/metrics-rotates-at-cap/expected.json b/tests/hooks/fixtures/subagent-retro/metrics-rotates-at-cap/expected.json new file mode 100644 index 0000000..74bf863 --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/metrics-rotates-at-cap/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"emitted":false,"stale":[],"lessons":[],"stderr":"","events":[{"ts":"","spec_id":"FEAT-TEST-001","phase":"in-progress","event":"subagent_stop","stale":1}]} \ No newline at end of file diff --git a/tests/hooks/fixtures/subagent-retro/metrics-rotates-at-cap/input.json b/tests/hooks/fixtures/subagent-retro/metrics-rotates-at-cap/input.json new file mode 100644 index 0000000..4749e6b --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/metrics-rotates-at-cap/input.json @@ -0,0 +1 @@ +{"cwd":"{{CWD}}","session_id":"conformance-fixture"} \ No newline at end of file diff --git a/tests/hooks/fixtures/subagent-retro/metrics-rotates-at-cap/rotated-expected.json b/tests/hooks/fixtures/subagent-retro/metrics-rotates-at-cap/rotated-expected.json new file mode 100644 index 0000000..0550b1c --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/metrics-rotates-at-cap/rotated-expected.json @@ -0,0 +1 @@ +{"rotated":[{"ts":"","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".ts"},{"ts":"","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".py"},{"ts":"","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".go"},{"ts":"","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".rs"},{"ts":"","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".js"},{"ts":"","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".cs"},{"ts":"","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".rb"},{"ts":"","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".java"},{"ts":"","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".kt"}]} \ No newline at end of file diff --git a/tests/hooks/fixtures/subagent-retro/metrics-rotates-at-cap/setup.json b/tests/hooks/fixtures/subagent-retro/metrics-rotates-at-cap/setup.json new file mode 100644 index 0000000..65e3a51 --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/metrics-rotates-at-cap/setup.json @@ -0,0 +1,4 @@ +{ + "touch": [ { "path": ".specs/FEAT-TEST-001/05-retro.md", "ageMinutes": 120 } ], + "write": [ { "path": ".claude/.hookstate/subagent-retro-conformance-fixture.json", "content": "{\"lastReminderUtc\":\"{{UTCNOW-2M}}\"}" } ] +} \ No newline at end of file diff --git a/tests/hooks/fixtures/subagent-retro/metrics-rotates-at-cap/workspace/.claude/project-config.json b/tests/hooks/fixtures/subagent-retro/metrics-rotates-at-cap/workspace/.claude/project-config.json new file mode 100644 index 0000000..cecd481 --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/metrics-rotates-at-cap/workspace/.claude/project-config.json @@ -0,0 +1,4 @@ +{ + "spec": { "dir": ".specs", "indexFile": ".specs/index.md" }, + "hooks": { "subagentRetro": { "enabled": true, "retroStaleMinutes": 30, "debounceMinutes": 10 }, "metrics": { "enabled": true, "maxSizeKb": 1 } } +} \ No newline at end of file diff --git a/tests/hooks/fixtures/subagent-retro/metrics-rotates-at-cap/workspace/.specs/FEAT-TEST-001/05-retro.md b/tests/hooks/fixtures/subagent-retro/metrics-rotates-at-cap/workspace/.specs/FEAT-TEST-001/05-retro.md new file mode 100644 index 0000000..432e696 --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/metrics-rotates-at-cap/workspace/.specs/FEAT-TEST-001/05-retro.md @@ -0,0 +1,3 @@ +# Retro - FEAT-TEST-001 + +placeholder retro content for conformance fixture. \ No newline at end of file diff --git a/tests/hooks/fixtures/subagent-retro/metrics-rotates-at-cap/workspace/.specs/_metrics/events.jsonl b/tests/hooks/fixtures/subagent-retro/metrics-rotates-at-cap/workspace/.specs/_metrics/events.jsonl new file mode 100644 index 0000000..2886754 --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/metrics-rotates-at-cap/workspace/.specs/_metrics/events.jsonl @@ -0,0 +1,9 @@ +{"ts":"2026-01-01T00:00:00Z","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".ts"} +{"ts":"2026-01-01T00:00:00Z","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".py"} +{"ts":"2026-01-01T00:00:00Z","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".go"} +{"ts":"2026-01-01T00:00:00Z","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".rs"} +{"ts":"2026-01-01T00:00:00Z","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".js"} +{"ts":"2026-01-01T00:00:00Z","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".cs"} +{"ts":"2026-01-01T00:00:00Z","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".rb"} +{"ts":"2026-01-01T00:00:00Z","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".java"} +{"ts":"2026-01-01T00:00:00Z","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".kt"} diff --git a/tests/hooks/fixtures/subagent-retro/metrics-rotates-at-cap/workspace/.specs/index.md b/tests/hooks/fixtures/subagent-retro/metrics-rotates-at-cap/workspace/.specs/index.md new file mode 100644 index 0000000..bbe4179 --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/metrics-rotates-at-cap/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status | Title | +|---|---|---|---| +| FEAT-TEST-001 | feature | in-progress | Conformance fixture feature | diff --git a/tests/hooks/fixtures/subagent-retro/metrics-rotation-off/expected.json b/tests/hooks/fixtures/subagent-retro/metrics-rotation-off/expected.json new file mode 100644 index 0000000..0147ae3 --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/metrics-rotation-off/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"emitted":false,"stale":[],"lessons":[],"stderr":"","events":[{"ts":"","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".ts"},{"ts":"","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".py"},{"ts":"","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".go"},{"ts":"","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".rs"},{"ts":"","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".js"},{"ts":"","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".cs"},{"ts":"","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".rb"},{"ts":"","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".java"},{"ts":"","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".kt"},{"ts":"","spec_id":"FEAT-TEST-001","phase":"in-progress","event":"subagent_stop","stale":1}]} \ No newline at end of file diff --git a/tests/hooks/fixtures/subagent-retro/metrics-rotation-off/input.json b/tests/hooks/fixtures/subagent-retro/metrics-rotation-off/input.json new file mode 100644 index 0000000..4749e6b --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/metrics-rotation-off/input.json @@ -0,0 +1 @@ +{"cwd":"{{CWD}}","session_id":"conformance-fixture"} \ No newline at end of file diff --git a/tests/hooks/fixtures/subagent-retro/metrics-rotation-off/setup.json b/tests/hooks/fixtures/subagent-retro/metrics-rotation-off/setup.json new file mode 100644 index 0000000..65e3a51 --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/metrics-rotation-off/setup.json @@ -0,0 +1,4 @@ +{ + "touch": [ { "path": ".specs/FEAT-TEST-001/05-retro.md", "ageMinutes": 120 } ], + "write": [ { "path": ".claude/.hookstate/subagent-retro-conformance-fixture.json", "content": "{\"lastReminderUtc\":\"{{UTCNOW-2M}}\"}" } ] +} \ No newline at end of file diff --git a/tests/hooks/fixtures/subagent-retro/metrics-rotation-off/workspace/.claude/project-config.json b/tests/hooks/fixtures/subagent-retro/metrics-rotation-off/workspace/.claude/project-config.json new file mode 100644 index 0000000..3dab0a0 --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/metrics-rotation-off/workspace/.claude/project-config.json @@ -0,0 +1,4 @@ +{ + "spec": { "dir": ".specs", "indexFile": ".specs/index.md" }, + "hooks": { "subagentRetro": { "enabled": true, "retroStaleMinutes": 30, "debounceMinutes": 10 }, "metrics": { "enabled": true, "maxSizeKb": 0 } } +} \ No newline at end of file diff --git a/tests/hooks/fixtures/subagent-retro/metrics-rotation-off/workspace/.specs/FEAT-TEST-001/05-retro.md b/tests/hooks/fixtures/subagent-retro/metrics-rotation-off/workspace/.specs/FEAT-TEST-001/05-retro.md new file mode 100644 index 0000000..432e696 --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/metrics-rotation-off/workspace/.specs/FEAT-TEST-001/05-retro.md @@ -0,0 +1,3 @@ +# Retro - FEAT-TEST-001 + +placeholder retro content for conformance fixture. \ No newline at end of file diff --git a/tests/hooks/fixtures/subagent-retro/metrics-rotation-off/workspace/.specs/_metrics/events.jsonl b/tests/hooks/fixtures/subagent-retro/metrics-rotation-off/workspace/.specs/_metrics/events.jsonl new file mode 100644 index 0000000..2886754 --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/metrics-rotation-off/workspace/.specs/_metrics/events.jsonl @@ -0,0 +1,9 @@ +{"ts":"2026-01-01T00:00:00Z","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".ts"} +{"ts":"2026-01-01T00:00:00Z","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".py"} +{"ts":"2026-01-01T00:00:00Z","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".go"} +{"ts":"2026-01-01T00:00:00Z","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".rs"} +{"ts":"2026-01-01T00:00:00Z","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".js"} +{"ts":"2026-01-01T00:00:00Z","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".cs"} +{"ts":"2026-01-01T00:00:00Z","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".rb"} +{"ts":"2026-01-01T00:00:00Z","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".java"} +{"ts":"2026-01-01T00:00:00Z","spec_id":"FEAT-SEED-001","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".kt"} diff --git a/tests/hooks/fixtures/subagent-retro/metrics-rotation-off/workspace/.specs/index.md b/tests/hooks/fixtures/subagent-retro/metrics-rotation-off/workspace/.specs/index.md new file mode 100644 index 0000000..bbe4179 --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/metrics-rotation-off/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status | Title | +|---|---|---|---| +| FEAT-TEST-001 | feature | in-progress | Conformance fixture feature | diff --git a/tests/hooks/fixtures/subagent-retro/remind-missing-retro/expected.json b/tests/hooks/fixtures/subagent-retro/remind-missing-retro/expected.json new file mode 100644 index 0000000..6bb0db0 --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/remind-missing-retro/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"emitted":true,"stale":[{"id":"FEAT-TEST-001","reason":"missing","ageMinutes":null,"thresholdMinutes":null}],"lessons":[],"stderr":"","events":[{"ts":"","spec_id":"FEAT-TEST-001","phase":"in-progress","event":"subagent_stop","stale":1},{"ts":"","spec_id":"RCA-2026-001","phase":"in-progress","event":"subagent_stop","stale":0}]} \ No newline at end of file diff --git a/tests/hooks/fixtures/subagent-retro/remind-missing-retro/input.json b/tests/hooks/fixtures/subagent-retro/remind-missing-retro/input.json new file mode 100644 index 0000000..4e5c1fb --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/remind-missing-retro/input.json @@ -0,0 +1 @@ +{"cwd":"{{CWD}}","session_id":"conformance-fixture"} diff --git a/tests/hooks/fixtures/subagent-retro/remind-missing-retro/workspace/.claude/project-config.json b/tests/hooks/fixtures/subagent-retro/remind-missing-retro/workspace/.claude/project-config.json new file mode 100644 index 0000000..18b4b9e --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/remind-missing-retro/workspace/.claude/project-config.json @@ -0,0 +1,4 @@ +{ + "spec": { "dir": ".specs", "indexFile": ".specs/index.md" }, + "hooks": { "subagentRetro": { "enabled": true, "retroStaleMinutes": 30, "debounceMinutes": 10 } } +} diff --git a/tests/hooks/fixtures/subagent-retro/remind-missing-retro/workspace/.specs/index.md b/tests/hooks/fixtures/subagent-retro/remind-missing-retro/workspace/.specs/index.md new file mode 100644 index 0000000..f3524e8 --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/remind-missing-retro/workspace/.specs/index.md @@ -0,0 +1,4 @@ +| ID | Type | Status | Title | +|---|---|---|---| +| FEAT-TEST-001 | feature | in-progress | Conformance fixture feature | +| RCA-2026-001 | rca | in-progress | Incident writeup | diff --git a/tests/hooks/fixtures/subagent-retro/remind-stale-retro/expected.json b/tests/hooks/fixtures/subagent-retro/remind-stale-retro/expected.json new file mode 100644 index 0000000..97c4494 --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/remind-stale-retro/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"emitted":true,"stale":[{"id":"FEAT-TEST-001","reason":"stale","ageMinutes":120,"thresholdMinutes":30}],"lessons":[],"stderr":"","events":[{"ts":"","spec_id":"FEAT-TEST-001","phase":"in-progress","event":"subagent_stop","stale":1}]} \ No newline at end of file diff --git a/tests/hooks/fixtures/subagent-retro/remind-stale-retro/input.json b/tests/hooks/fixtures/subagent-retro/remind-stale-retro/input.json new file mode 100644 index 0000000..4e5c1fb --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/remind-stale-retro/input.json @@ -0,0 +1 @@ +{"cwd":"{{CWD}}","session_id":"conformance-fixture"} diff --git a/tests/hooks/fixtures/subagent-retro/remind-stale-retro/setup.json b/tests/hooks/fixtures/subagent-retro/remind-stale-retro/setup.json new file mode 100644 index 0000000..e25d76f --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/remind-stale-retro/setup.json @@ -0,0 +1 @@ +{ "touch": [ { "path": ".specs/FEAT-TEST-001/05-retro.md", "ageMinutes": 120 } ] } diff --git a/tests/hooks/fixtures/subagent-retro/remind-stale-retro/workspace/.claude/project-config.json b/tests/hooks/fixtures/subagent-retro/remind-stale-retro/workspace/.claude/project-config.json new file mode 100644 index 0000000..18b4b9e --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/remind-stale-retro/workspace/.claude/project-config.json @@ -0,0 +1,4 @@ +{ + "spec": { "dir": ".specs", "indexFile": ".specs/index.md" }, + "hooks": { "subagentRetro": { "enabled": true, "retroStaleMinutes": 30, "debounceMinutes": 10 } } +} diff --git a/tests/hooks/fixtures/subagent-retro/remind-stale-retro/workspace/.specs/FEAT-TEST-001/05-retro.md b/tests/hooks/fixtures/subagent-retro/remind-stale-retro/workspace/.specs/FEAT-TEST-001/05-retro.md new file mode 100644 index 0000000..57c282e --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/remind-stale-retro/workspace/.specs/FEAT-TEST-001/05-retro.md @@ -0,0 +1 @@ +# Retro diff --git a/tests/hooks/fixtures/subagent-retro/remind-stale-retro/workspace/.specs/index.md b/tests/hooks/fixtures/subagent-retro/remind-stale-retro/workspace/.specs/index.md new file mode 100644 index 0000000..bbe4179 --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/remind-stale-retro/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status | Title | +|---|---|---|---| +| FEAT-TEST-001 | feature | in-progress | Conformance fixture feature | diff --git a/tests/hooks/fixtures/subagent-retro/silent-disabled/expected.json b/tests/hooks/fixtures/subagent-retro/silent-disabled/expected.json new file mode 100644 index 0000000..c86b9b7 --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/silent-disabled/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"emitted":false,"stale":[],"lessons":[],"stderr":"","events":[]} \ No newline at end of file diff --git a/tests/hooks/fixtures/subagent-retro/silent-disabled/input.json b/tests/hooks/fixtures/subagent-retro/silent-disabled/input.json new file mode 100644 index 0000000..4e5c1fb --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/silent-disabled/input.json @@ -0,0 +1 @@ +{"cwd":"{{CWD}}","session_id":"conformance-fixture"} diff --git a/tests/hooks/fixtures/subagent-retro/silent-disabled/workspace/.claude/project-config.json b/tests/hooks/fixtures/subagent-retro/silent-disabled/workspace/.claude/project-config.json new file mode 100644 index 0000000..f0e1ba8 --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/silent-disabled/workspace/.claude/project-config.json @@ -0,0 +1,4 @@ +{ + "spec": { "dir": ".specs", "indexFile": ".specs/index.md" }, + "hooks": { "subagentRetro": { "enabled": false, "retroStaleMinutes": 30, "debounceMinutes": 10 } } +} diff --git a/tests/hooks/fixtures/subagent-retro/silent-disabled/workspace/.specs/index.md b/tests/hooks/fixtures/subagent-retro/silent-disabled/workspace/.specs/index.md new file mode 100644 index 0000000..bbe4179 --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/silent-disabled/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status | Title | +|---|---|---|---| +| FEAT-TEST-001 | feature | in-progress | Conformance fixture feature | diff --git a/tests/hooks/fixtures/subagent-retro/silent-done-only/expected.json b/tests/hooks/fixtures/subagent-retro/silent-done-only/expected.json new file mode 100644 index 0000000..c86b9b7 --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/silent-done-only/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"emitted":false,"stale":[],"lessons":[],"stderr":"","events":[]} \ No newline at end of file diff --git a/tests/hooks/fixtures/subagent-retro/silent-done-only/input.json b/tests/hooks/fixtures/subagent-retro/silent-done-only/input.json new file mode 100644 index 0000000..4e5c1fb --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/silent-done-only/input.json @@ -0,0 +1 @@ +{"cwd":"{{CWD}}","session_id":"conformance-fixture"} diff --git a/tests/hooks/fixtures/subagent-retro/silent-done-only/workspace/.claude/project-config.json b/tests/hooks/fixtures/subagent-retro/silent-done-only/workspace/.claude/project-config.json new file mode 100644 index 0000000..18b4b9e --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/silent-done-only/workspace/.claude/project-config.json @@ -0,0 +1,4 @@ +{ + "spec": { "dir": ".specs", "indexFile": ".specs/index.md" }, + "hooks": { "subagentRetro": { "enabled": true, "retroStaleMinutes": 30, "debounceMinutes": 10 } } +} diff --git a/tests/hooks/fixtures/subagent-retro/silent-done-only/workspace/.specs/index.md b/tests/hooks/fixtures/subagent-retro/silent-done-only/workspace/.specs/index.md new file mode 100644 index 0000000..d6f75b2 --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/silent-done-only/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status (in-progress = active work) | Title | +|---|---|---|---| +| FEAT-DONE-002 | feature | done | Finished feature | diff --git a/tests/hooks/fixtures/subagent-retro/silent-rca-only/expected.json b/tests/hooks/fixtures/subagent-retro/silent-rca-only/expected.json new file mode 100644 index 0000000..a7dc3dd --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/silent-rca-only/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"emitted":false,"stale":[],"lessons":[],"stderr":"","events":[{"ts":"","spec_id":"RCA-2026-001","phase":"in-progress","event":"subagent_stop","stale":0}]} \ No newline at end of file diff --git a/tests/hooks/fixtures/subagent-retro/silent-rca-only/input.json b/tests/hooks/fixtures/subagent-retro/silent-rca-only/input.json new file mode 100644 index 0000000..4e5c1fb --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/silent-rca-only/input.json @@ -0,0 +1 @@ +{"cwd":"{{CWD}}","session_id":"conformance-fixture"} diff --git a/tests/hooks/fixtures/subagent-retro/silent-rca-only/workspace/.claude/project-config.json b/tests/hooks/fixtures/subagent-retro/silent-rca-only/workspace/.claude/project-config.json new file mode 100644 index 0000000..18b4b9e --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/silent-rca-only/workspace/.claude/project-config.json @@ -0,0 +1,4 @@ +{ + "spec": { "dir": ".specs", "indexFile": ".specs/index.md" }, + "hooks": { "subagentRetro": { "enabled": true, "retroStaleMinutes": 30, "debounceMinutes": 10 } } +} diff --git a/tests/hooks/fixtures/subagent-retro/silent-rca-only/workspace/.specs/index.md b/tests/hooks/fixtures/subagent-retro/silent-rca-only/workspace/.specs/index.md new file mode 100644 index 0000000..aa760f1 --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/silent-rca-only/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status | Title | +|---|---|---|---| +| RCA-2026-001 | rca | in-progress | Incident writeup | diff --git a/tests/hooks/fixtures/subagent-retro/stale-minutes-zero-honored/expected.json b/tests/hooks/fixtures/subagent-retro/stale-minutes-zero-honored/expected.json new file mode 100644 index 0000000..7bfd246 --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/stale-minutes-zero-honored/expected.json @@ -0,0 +1 @@ +{"exitCode":0,"emitted":true,"stale":[{"id":"FEAT-TEST-001","reason":"stale","ageMinutes":12,"thresholdMinutes":0}],"lessons":[],"stderr":"","events":[{"ts":"","spec_id":"FEAT-TEST-001","phase":"in-progress","event":"subagent_stop","stale":1}]} diff --git a/tests/hooks/fixtures/subagent-retro/stale-minutes-zero-honored/input.json b/tests/hooks/fixtures/subagent-retro/stale-minutes-zero-honored/input.json new file mode 100644 index 0000000..4e5c1fb --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/stale-minutes-zero-honored/input.json @@ -0,0 +1 @@ +{"cwd":"{{CWD}}","session_id":"conformance-fixture"} diff --git a/tests/hooks/fixtures/subagent-retro/stale-minutes-zero-honored/setup.json b/tests/hooks/fixtures/subagent-retro/stale-minutes-zero-honored/setup.json new file mode 100644 index 0000000..6177054 --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/stale-minutes-zero-honored/setup.json @@ -0,0 +1 @@ +{ "touch": [ { "path": ".specs/FEAT-TEST-001/05-retro.md", "ageMinutes": 12 } ] } diff --git a/tests/hooks/fixtures/subagent-retro/stale-minutes-zero-honored/workspace/.claude/project-config.json b/tests/hooks/fixtures/subagent-retro/stale-minutes-zero-honored/workspace/.claude/project-config.json new file mode 100644 index 0000000..35ce296 --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/stale-minutes-zero-honored/workspace/.claude/project-config.json @@ -0,0 +1,4 @@ +{ + "spec": { "dir": ".specs", "indexFile": ".specs/index.md" }, + "hooks": { "subagentRetro": { "enabled": true, "retroStaleMinutes": 0, "debounceMinutes": 10 } } +} diff --git a/tests/hooks/fixtures/subagent-retro/stale-minutes-zero-honored/workspace/.specs/FEAT-TEST-001/05-retro.md b/tests/hooks/fixtures/subagent-retro/stale-minutes-zero-honored/workspace/.specs/FEAT-TEST-001/05-retro.md new file mode 100644 index 0000000..57c282e --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/stale-minutes-zero-honored/workspace/.specs/FEAT-TEST-001/05-retro.md @@ -0,0 +1 @@ +# Retro diff --git a/tests/hooks/fixtures/subagent-retro/stale-minutes-zero-honored/workspace/.specs/index.md b/tests/hooks/fixtures/subagent-retro/stale-minutes-zero-honored/workspace/.specs/index.md new file mode 100644 index 0000000..bbe4179 --- /dev/null +++ b/tests/hooks/fixtures/subagent-retro/stale-minutes-zero-honored/workspace/.specs/index.md @@ -0,0 +1,3 @@ +| ID | Type | Status | Title | +|---|---|---|---| +| FEAT-TEST-001 | feature | in-progress | Conformance fixture feature | diff --git a/tests/hooks/run-conformance.ps1 b/tests/hooks/run-conformance.ps1 new file mode 100644 index 0000000..5e9df56 --- /dev/null +++ b/tests/hooks/run-conformance.ps1 @@ -0,0 +1,608 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + specwright: cross-implementation hook conformance runner. + +.DESCRIPTION + For every fixture case under tests/hooks/fixtures///: + 1. Create a fresh temp workspace PER IMPLEMENTATION and copy the + case's workspace/ tree into it (fresh copy means hook state such + as the subagent-retro debounce file cannot leak across runs). + 2. Apply setup.json actions (currently: backdating file mtimes). + 3. Substitute {{CWD}} in input.json with the workspace path + (forward slashes; both implementations accept them) and pipe the + payload into the implementation on stdin. + 4. Normalize what the hook did into a small decision object. + 5. Assert bash decision == pwsh decision == expected.json golden. + + A behavioral divergence in only one implementation fails the suite + and prints all three decision objects for a clear diff. + + -SelfTest substitutes a stub bash spec-gate hook that always allows, + then asserts the harness DETECTS the divergence. Proves the + comparison would notice real drift (mirror of scripts/selftest-docs). + +.NOTES + PURE ASCII ONLY (see hooks/powershell/prompt-router.ps1 for why). + Single cross-platform runner by design: unlike the platform-native + scripts/ checks, conformance must run BOTH implementations in one + process, so a bash twin of this script would itself be a drift risk. + CI runs this under pwsh on every matrix OS. +#> + +[CmdletBinding()] +param( + [switch]$SelfTest +) + +$ErrorActionPreference = 'Stop' + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$repoRoot = (Resolve-Path (Join-Path $scriptDir '..' '..')).Path +$fixturesDir = Join-Path $scriptDir 'fixtures' + +$script:pass = 0 +$script:fail = 0 + +function Write-Ok { + param([string]$Message) + Write-Host " [OK] $Message" + $script:pass++ +} + +function Write-Bad { + param([string]$Message) + Write-Host " [FAIL] $Message" + $script:fail++ +} + +function Resolve-BashPath { + # On Windows prefer Git Bash explicitly: System32 bash.exe is WSL's + # stub and fails when no distro is installed. + if ($IsWindows) { + $gitBash = 'C:\Program Files\Git\bin\bash.exe' + if (Test-Path -LiteralPath $gitBash) { return $gitBash } + } + $cmd = Get-Command bash -ErrorAction SilentlyContinue + if ($null -ne $cmd) { return $cmd.Source } + return $null +} + +function New-CaseWorkspace { + param([string]$CaseDir) + + $name = 'sd-conformance-' + [System.Guid]::NewGuid().ToString('N').Substring(0, 12) + $ws = Join-Path ([System.IO.Path]::GetTempPath()) $name + New-Item -ItemType Directory -Path $ws -Force | Out-Null + + $src = Join-Path $CaseDir 'workspace' + if (Test-Path -LiteralPath $src) { + # -Force on Get-ChildItem: fixture trees are mostly dot-dirs + # (.claude, .specs) which Unix wildcard copies would skip. + Get-ChildItem -LiteralPath $src -Force | ForEach-Object { + Copy-Item -LiteralPath $_.FullName -Destination $ws -Recurse -Force + } + } + + $setupPath = Join-Path $CaseDir 'setup.json' + if (Test-Path -LiteralPath $setupPath) { + $setup = Get-Content -LiteralPath $setupPath -Raw | ConvertFrom-Json + foreach ($t in @($setup.touch)) { + if ($null -eq $t) { continue } + $target = Join-Path $ws $t.path + if (Test-Path -LiteralPath $target) { + $item = Get-Item -LiteralPath $target + $item.LastWriteTimeUtc = [System.DateTime]::UtcNow.AddMinutes(-1 * [double]$t.ageMinutes) + } + } + # `write` plants a file whose CONTENT carries a timestamp - a hook state + # file, say. The timestamp is computed at run time from a + # {{UTCNOW-90M}} / {{UTCNOW+5M}} token rather than written literally + # into the fixture, which would rot the moment the clock moved past it. + foreach ($w in @($setup.write)) { + if ($null -eq $w) { continue } + $content = [string]$w.content + $content = [regex]::Replace($content, '\{\{UTCNOW([+-]\d+)M\}\}', { + param($m) + $offset = [int]$m.Groups[1].Value + [System.DateTime]::UtcNow.AddMinutes($offset).ToString('yyyy-MM-ddTHH:mm:ssZ') + }) + $target = Join-Path $ws $w.path + $dir = Split-Path -Path $target -Parent + if (-not (Test-Path -LiteralPath $dir)) { + New-Item -ItemType Directory -Path $dir -Force | Out-Null + } + Set-Content -LiteralPath $target -Value $content -Encoding ascii -NoNewline + } + } + + return $ws +} + +function Invoke-HookProcess { + param( + [string]$Exe, + [string[]]$ProcArgs, + [string]$Payload + ) + $psi = [System.Diagnostics.ProcessStartInfo]::new() + $psi.FileName = $Exe + foreach ($a in $ProcArgs) { $psi.ArgumentList.Add($a) } + $psi.RedirectStandardInput = $true + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $psi.UseShellExecute = $false + $proc = [System.Diagnostics.Process]::Start($psi) + try { + $proc.StandardInput.Write($Payload) + $proc.StandardInput.Close() + } catch [System.IO.IOException] { + # Child exited without reading stdin (e.g. the self-test's always-allow + # stub, which never touches its input) - a broken pipe here just means + # the child didn't need the payload, not a harness failure. + } + # Hook output is tiny (well under pipe buffer size), so sequential + # reads cannot deadlock. + $stdout = $proc.StandardOutput.ReadToEnd() + $stderr = $proc.StandardError.ReadToEnd() + $proc.WaitForExit() + return [pscustomobject]@{ + ExitCode = $proc.ExitCode + Stdout = $stdout.Replace("`r", '') + Stderr = $stderr.Replace("`r", '') + } +} + +function Get-MetricsEventsPath { + param([string]$Ws) + # Mirrors both hook implementations' own fallback: hooks.metrics.path from + # the WORKSPACE's own project-config.json (a fresh per-run copy of the + # case's workspace/ tree), defaulting to .specs/_metrics/events.jsonl when + # the key or the file itself is absent/malformed. This is what lets the + # metrics-custom-path case tell the harness where to look. + $relPath = '.specs/_metrics/events.jsonl' + $cfgPath = Join-Path $Ws '.claude/project-config.json' + if (Test-Path -LiteralPath $cfgPath) { + try { + $cfg = Get-Content -LiteralPath $cfgPath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop + if ($cfg.hooks.metrics.path) { $relPath = [string]$cfg.hooks.metrics.path } + } catch { } + } + return (Join-Path $Ws ($relPath.Replace('/', [System.IO.Path]::DirectorySeparatorChar))) +} + +function Read-NormalizedEventLines { + param([string]$Path) + # Read a JSONL metrics file BEFORE the caller deletes the workspace. Each + # line is normalized independently: ts is wall-clock and can never match a + # golden, so it is replaced with the literal "" once verified to look + # like a real timestamp - a malformed ts becomes "" instead of + # being silently erased, so a broken timestamp FAILS the case. + $events = [System.Collections.Generic.List[object]]::new() + if (-not (Test-Path -LiteralPath $Path)) { return , @() } + $lines = Get-Content -LiteralPath $Path -ErrorAction SilentlyContinue + foreach ($line in @($lines)) { + if ([string]::IsNullOrWhiteSpace($line)) { continue } + try { + $obj = $line | ConvertFrom-Json -ErrorAction Stop + } catch { + continue + } + # PowerShell 7's ConvertFrom-Json auto-converts an ISO-8601 "...Z" + # string to a [datetime] with Kind=Utc (same gotcha documented in + # subagent-retro.ps1's Test-DebounceElapsed); PowerShell 5.1 leaves it + # as a plain string. Re-render a [datetime] back into the on-disk + # format before pattern-matching it, rather than stringifying it + # directly, which would use local culture and never match. + $rawTs = $obj.ts + if ($rawTs -is [datetime]) { + $tsVal = $rawTs.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + } elseif ($null -ne $rawTs) { + $tsVal = [string]$rawTs + } else { + $tsVal = '' + } + if ($tsVal -match '^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$') { + $obj.ts = '' + } else { + $obj.ts = '' + } + $events.Add($obj) | Out-Null + } + return , @($events) +} + +function Get-CaseEvents { + param([string]$Ws) + return Read-NormalizedEventLines -Path (Get-MetricsEventsPath -Ws $Ws) +} + +# Rotation (SW-15): the previous generation the hook rolled off to +# `events.jsonl.1`, read the same normalized way as the live log so a rotation +# case can assert the old lines survived the roll byte-for-byte. Empty when +# nothing rotated. +function Get-CaseRotatedEvents { + param([string]$Ws) + return Read-NormalizedEventLines -Path ((Get-MetricsEventsPath -Ws $Ws) + '.1') +} + +function Invoke-HookImpl { + param( + [string]$Impl, + [string]$HookScript, + [string]$CaseDir + ) + $ws = New-CaseWorkspace -CaseDir $CaseDir + try { + $wsForward = $ws.Replace('\', '/') + $inputPath = Join-Path $CaseDir 'input.json' + $payload = (Get-Content -LiteralPath $inputPath -Raw).Replace('{{CWD}}', $wsForward) + if ($Impl -eq 'bash') { + $run = Invoke-HookProcess -Exe $script:bashExe -ProcArgs @($HookScript) -Payload $payload + } else { + $run = Invoke-HookProcess -Exe 'pwsh' -ProcArgs @('-NoProfile', '-File', $HookScript) -Payload $payload + } + $events = Get-CaseEvents -Ws $ws + $rotated = Get-CaseRotatedEvents -Ws $ws + return [pscustomobject]@{ + ExitCode = $run.ExitCode + Stdout = $run.Stdout + Stderr = $run.Stderr + Events = $events + RotatedEvents = $rotated + Workspace = $ws + } + } finally { + Remove-Item -LiteralPath $ws -Recurse -Force -ErrorAction SilentlyContinue + } +} + +function Test-EventsLeakNoPath { + param( + [object[]]$Events, + [string]$Ws + ) + # Structural guard for the metrics-code-edit-allow fixture (privacy + # decision in SW-10-implementation-plan.md section 1: "record the file + # extension only, never the path"). Golden-equality alone is not quite + # enough here - a leaked absolute path would embed a per-run random temp + # directory name and so would merely show up as SOME mismatching value + # in the diff, not as an explicit "path leaked" failure. This walks every + # field of every event (skipping ts, already normalized to ""/ + # "") and fails loudly if a value contains a path separator or + # the workspace path itself, in either slash direction. + $wsForward = $Ws.Replace('\', '/') + foreach ($ev in @($Events)) { + foreach ($prop in $ev.PSObject.Properties) { + if ($prop.Name -eq 'ts') { continue } + $val = [string]$prop.Value + if ($val.Length -eq 0) { continue } + if ($val.Contains('/') -or $val.Contains('\')) { return $false } + if ($val.Contains($Ws) -or $val.Contains($wsForward)) { return $false } + } + } + return $true +} + +# ---- normalizers: one per hook, each maps a raw run to a decision object ---- + +function ConvertTo-SpecGateDecision { + param($Run) + $decision = 'allow' + $permission = $null + $reason = $null + $stdoutTrim = $Run.Stdout.Trim() + if ($stdoutTrim.Length -gt 0) { + try { + $obj = $stdoutTrim | ConvertFrom-Json + if ($obj.decision) { $decision = [string]$obj.decision } + if ($obj.hookSpecificOutput -and $obj.hookSpecificOutput.permissionDecision) { + $permission = [string]$obj.hookSpecificOutput.permissionDecision + } + # The human-readable reason is duplicated into both schema halves by + # both implementations; if the two copies ever disagree the object + # must not silently keep one of them. + $topReason = if ($obj.reason) { [string]$obj.reason } else { $null } + $nestedReason = if ($obj.hookSpecificOutput -and $obj.hookSpecificOutput.reason) { + [string]$obj.hookSpecificOutput.reason + } else { $null } + if ($topReason -ne $nestedReason) { + $reason = 'REASON-MISMATCH-BETWEEN-SCHEMA-HALVES' + } else { + $reason = $topReason + } + } catch { + $decision = 'unparseable-stdout' + } + } elseif ($Run.Stderr.Contains('[WARN]')) { + $decision = 'warn' + } + # stderr is part of the decision: the repo invariant is that a hook stays + # SILENT unless it has something to say, so an unexpected diagnostic on + # stderr must fail the case rather than pass unnoticed. + return [pscustomobject][ordered]@{ + exitCode = $Run.ExitCode + decision = $decision + permissionDecision = $permission + reason = $reason + stderr = $Run.Stderr.Trim() + events = @($Run.Events) + } +} + +function ConvertTo-PromptRouterDecision { + param($Run) + $workflows = [System.Collections.Generic.List[string]]::new() + $ticketIds = [System.Collections.Generic.List[string]]::new() + $specFolders = [System.Collections.Generic.List[string]]::new() + $inProgress = [System.Collections.Generic.List[string]]::new() + $section = '' + foreach ($line in ($Run.Stdout -split "`n")) { + if ($line -match '^Workflow keyword matches:') { $section = 'workflows'; continue } + if ($line -match '^Ticket IDs detected: (.*)$') { + $section = 'tickets' + foreach ($t in ($Matches[1] -split ', ')) { + if ($t.Trim()) { $ticketIds.Add($t.Trim()) } + } + continue + } + if ($line -match '^Matching spec folders') { $section = 'folders'; continue } + if ($line -match '^No matching spec folder') { $section = ''; continue } + if ($line -match '^Specs currently in-progress') { $section = 'inprogress'; continue } + if ($line -match '^\s+-\s+(.+)$') { + $item = $Matches[1].Trim() + switch ($section) { + 'workflows' { + if ($item -match '^/sd:([a-z]+)') { $workflows.Add($Matches[1]) } + } + 'folders' { $specFolders.Add($item) } + 'inprogress' { $inProgress.Add($item) } + } + } + } + return [pscustomobject][ordered]@{ + exitCode = $Run.ExitCode + emitted = $Run.Stdout.Contains('') + workflows = @($workflows | Sort-Object) + ticketIds = @($ticketIds | Sort-Object) + specFolders = @($specFolders | Sort-Object) + inProgress = @($inProgress | Sort-Object) + stderr = $Run.Stderr.Trim() + events = @($Run.Events) + } +} + +function ConvertTo-SubagentRetroDecision { + param($Run) + $stale = [System.Collections.Generic.List[object]]::new() + foreach ($line in ($Run.Stdout -split "`n")) { + # The measured age and the threshold it was compared against are part of + # the decision - dropping them would let the two implementations disagree + # on arithmetic (truncate vs round) while still looking identical. + if ($line -match '^\s+-\s+([A-Za-z0-9_\-]+): 05-retro\.md missing$') { + $stale.Add([pscustomobject][ordered]@{ + id = $Matches[1]; reason = 'missing'; ageMinutes = $null; thresholdMinutes = $null + }) + } elseif ($line -match '^\s+-\s+([A-Za-z0-9_\-]+): 05-retro\.md last touched (\d+) min ago \(threshold (\d+) min\)$') { + $stale.Add([pscustomobject][ordered]@{ + id = $Matches[1]; reason = 'stale' + ageMinutes = [int]$Matches[2]; thresholdMinutes = [int]$Matches[3] + }) + } + } + # Lesson injection (SW-19). Captured verbatim and in EMISSION ORDER, not + # sorted: which lessons are selected and the order they appear in is the + # behaviour under test, and lessons.md is rendered in a total order upstream + # precisely so that order is deterministic. Sorting here would hide a + # divergence in selection order - the exact failure this fixture set exists + # to catch. + $lessons = [System.Collections.Generic.List[string]]::new() + foreach ($line in ($Run.Stdout -split "`n")) { + $trimmed = $line.TrimEnd("`r") + if ($trimmed -cmatch '^ (- \[[a-z-]+\] [a-z]+/[a-z]+: .+)$') { + $lessons.Add($Matches[1]) + } + } + + return [pscustomobject][ordered]@{ + exitCode = $Run.ExitCode + emitted = $Run.Stdout.Contains('') + stale = @($stale | Sort-Object -Property id) + lessons = @($lessons) + stderr = $Run.Stderr.Trim() + events = @($Run.Events) + } +} + +$hookNormalizers = @{ + 'spec-gate' = ${function:ConvertTo-SpecGateDecision} + 'prompt-router' = ${function:ConvertTo-PromptRouterDecision} + 'subagent-retro' = ${function:ConvertTo-SubagentRetroDecision} +} + +function Get-CanonicalJson { + param($Obj) + return ($Obj | ConvertTo-Json -Depth 5 -Compress) +} + +$script:noLeakCases = @{ + 'spec-gate' = @('metrics-code-edit-allow') +} + +# Rotation cases (SW-15). For these, the main golden (expected.json) proves the +# LIVE events.jsonl restarted - it lists only the post-roll line(s), so a +# rotation that never fired would leave the seed lines in the live file and +# mismatch. This map adds the other half of the proof: the rolled-off +# events.jsonl.1 must exist, be non-empty, hold exactly the pre-seeded lines +# (from the case's rotated-expected.json golden), and be identical across bash +# and pwsh - i.e. the roll preserved the old data byte-for-byte on both +# platforms and lost nothing. A case whose name is NOT listed here must produce +# NO .1 at all (no accidental rotation), which is asserted for every case. +$script:rotationCases = @{ + 'spec-gate' = @('metrics-rotates-at-cap') + 'subagent-retro' = @('metrics-rotates-at-cap') +} + +function Invoke-ConformanceCase { + param( + [string]$HookName, + [string]$CaseDir, + [string]$BashHook, + [string]$PwshHook + ) + $normalizer = $hookNormalizers[$HookName] + $bashRun = Invoke-HookImpl -Impl 'bash' -HookScript $BashHook -CaseDir $CaseDir + $pwshRun = Invoke-HookImpl -Impl 'pwsh' -HookScript $PwshHook -CaseDir $CaseDir + $expected = Get-Content -LiteralPath (Join-Path $CaseDir 'expected.json') -Raw | ConvertFrom-Json + $bashJson = Get-CanonicalJson (& $normalizer $bashRun) + $pwshJson = Get-CanonicalJson (& $normalizer $pwshRun) + $expectedJson = Get-CanonicalJson $expected + $match = ($bashJson -eq $expectedJson) -and ($pwshJson -eq $expectedJson) + + $caseName = Split-Path -Leaf $CaseDir + $leakNote = $null + $leakCases = $script:noLeakCases[$HookName] + if ($null -ne $leakCases -and $leakCases -contains $caseName) { + # Golden-equality alone would only surface a leaked path as an + # unexplained mismatch (the leaked value embeds a per-run random temp + # directory name, so it can never equal a fixed golden). This makes + # the failure mode explicit instead of a generic diff. + $bashClean = Test-EventsLeakNoPath -Events $bashRun.Events -Ws $bashRun.Workspace + $pwshClean = Test-EventsLeakNoPath -Events $pwshRun.Events -Ws $pwshRun.Workspace + if ((-not $bashClean) -or (-not $pwshClean)) { + $match = $false + $leakNote = "structural no-path-leak check FAILED (bash clean=$bashClean, pwsh clean=$pwshClean)" + } + } + + # Rotation proof (SW-15). Two halves, both required: + # 1. Every case must produce NO events.jsonl.1 unless it is a declared + # rotation case - catches an accidental roll that the live-file golden + # alone would miss. + # 2. A declared rotation case must roll the pre-seeded lines off to a + # non-empty .1 that equals rotated-expected.json, identically on bash + # and pwsh - the "old data survived byte-for-byte on both platforms" + # half that the live-file golden cannot see. + $rotationNote = $null + $rotationCases = $script:rotationCases[$HookName] + $isRotationCase = ($null -ne $rotationCases -and $rotationCases -contains $caseName) + if ($isRotationCase) { + $rotExpectedPath = Join-Path $CaseDir 'rotated-expected.json' + if (-not (Test-Path -LiteralPath $rotExpectedPath)) { + $match = $false + $rotationNote = 'rotation case is missing rotated-expected.json' + } else { + $rotExpected = Get-Content -LiteralPath $rotExpectedPath -Raw | ConvertFrom-Json + $rotExpectedJson = Get-CanonicalJson $rotExpected + $bashRot = Get-CanonicalJson ([pscustomobject]@{ rotated = @($bashRun.RotatedEvents) }) + $pwshRot = Get-CanonicalJson ([pscustomobject]@{ rotated = @($pwshRun.RotatedEvents) }) + if (@($bashRun.RotatedEvents).Count -eq 0 -or @($pwshRun.RotatedEvents).Count -eq 0) { + $match = $false + $rotationNote = "expected a rolled events.jsonl.1 but it was empty/absent (bash=$(@($bashRun.RotatedEvents).Count), pwsh=$(@($pwshRun.RotatedEvents).Count))" + } elseif ($bashRot -ne $rotExpectedJson -or $pwshRot -ne $rotExpectedJson) { + $match = $false + $rotationNote = "rolled .1 content mismatch`n rot-expected: $rotExpectedJson`n bash .1 : $bashRot`n pwsh .1 : $pwshRot" + } + } + } else { + # No non-rotation case may leave a .1 behind. + if (@($bashRun.RotatedEvents).Count -gt 0 -or @($pwshRun.RotatedEvents).Count -gt 0) { + $match = $false + $rotationNote = "unexpected events.jsonl.1 produced by a non-rotation case (bash=$(@($bashRun.RotatedEvents).Count), pwsh=$(@($pwshRun.RotatedEvents).Count))" + } + } + + return [pscustomobject]@{ + CaseName = $caseName + Bash = $bashJson + Pwsh = $pwshJson + Expected = $expectedJson + Match = $match + LeakNote = $leakNote + RotationNote = $rotationNote + } +} + +function Write-CaseDiff { + param($Result) + Write-Host " expected : $($Result.Expected)" + Write-Host " bash : $($Result.Bash)" + Write-Host " pwsh : $($Result.Pwsh)" + if ($Result.LeakNote) { + Write-Host " leak : $($Result.LeakNote)" + } + if ($Result.RotationNote) { + Write-Host " rotation : $($Result.RotationNote)" + } +} + +# ---- preconditions ---------------------------------------------------------- + +$script:bashExe = Resolve-BashPath +if ($null -eq $script:bashExe) { + Write-Host '[FAIL] bash not found; conformance requires both implementations.' + exit 1 +} +if ($null -eq (Get-Command jq -ErrorAction SilentlyContinue)) { + # Without jq the bash hooks exit 0 silently, which would make every + # bash decision look like "allow" and the comparison meaningless. + Write-Host '[FAIL] jq not found; the bash hooks would silently no-op.' + exit 1 +} + +# ---- self-test mode --------------------------------------------------------- + +if ($SelfTest) { + Write-Host '=== conformance self-test: harness must DETECT divergence ===' + $stubName = 'sd-selftest-' + [System.Guid]::NewGuid().ToString('N').Substring(0, 8) + '.sh' + $stub = Join-Path ([System.IO.Path]::GetTempPath()) $stubName + "#!/usr/bin/env bash`nexit 0`n" | Set-Content -LiteralPath $stub -NoNewline -Encoding ascii + try { + $caseDir = Join-Path $fixturesDir 'spec-gate' 'block-code-no-spec' + $result = Invoke-ConformanceCase -HookName 'spec-gate' -CaseDir $caseDir ` + -BashHook $stub -PwshHook (Join-Path $repoRoot 'hooks' 'powershell' 'spec-gate.ps1') + } finally { + Remove-Item -LiteralPath $stub -Force -ErrorAction SilentlyContinue + } + if ($result.Pwsh -ne $result.Expected) { + Write-Bad 'self-test precondition: real pwsh impl no longer matches the golden' + Write-CaseDiff $result + exit 1 + } + if ($result.Match) { + Write-Bad 'self-test: harness did NOT detect an always-allow bash stub' + Write-CaseDiff $result + exit 1 + } + Write-Ok 'self-test: divergence in one implementation was detected' + exit 0 +} + +# ---- main ------------------------------------------------------------------- + +foreach ($hookDir in (Get-ChildItem -LiteralPath $fixturesDir -Directory | Sort-Object Name)) { + $hookName = $hookDir.Name + if (-not $hookNormalizers.ContainsKey($hookName)) { + Write-Bad "unknown fixture hook '$hookName' (no normalizer registered)" + continue + } + $bashHook = Join-Path $repoRoot 'hooks' 'bash' "$hookName.sh" + $pwshHook = Join-Path $repoRoot 'hooks' 'powershell' "$hookName.ps1" + Write-Host '' + Write-Host "=== $hookName ===" + foreach ($caseDir in (Get-ChildItem -LiteralPath $hookDir.FullName -Directory | Sort-Object Name)) { + $result = Invoke-ConformanceCase -HookName $hookName -CaseDir $caseDir.FullName ` + -BashHook $bashHook -PwshHook $pwshHook + if ($result.Match) { + Write-Ok $result.CaseName + } else { + Write-Bad $result.CaseName + Write-CaseDiff $result + } + } +} + +Write-Host '' +Write-Host "=== Summary: $($script:pass) passed, $($script:fail) failed ===" +if ($script:fail -gt 0) { exit 1 } +exit 0 diff --git a/tests/lessons/fixtures/clean-lessons.md b/tests/lessons/fixtures/clean-lessons.md new file mode 100644 index 0000000..dd33c21 --- /dev/null +++ b/tests/lessons/fixtures/clean-lessons.md @@ -0,0 +1,20 @@ +# Lessons (fixture: must PASS) + +Every line below is a well-formed, identifier-free lesson. `scripts/validate-lessons.*` +must exit 0 on this file, on both platforms. + +Prose like this paragraph, headers, and blank lines are ignored by the validator - only +lines opening with `- [` are candidates. + +## Lessons + +- [sibling-repo-assumption] high/feature: When mirroring a sibling repository, verify the local shared helper matches before copying an attribute. +- [missed-context] high/feature: Re-run impact analysis after any spec refinement, since a refined scope invalidates the earlier map. +- [baseline-attribution] medium/all: Confirm pre-existing failures against a clean baseline before attributing or dismissing them. +- [gate-friction] medium/refactor: When waiving a coverage gate, record measured coverage, residual risk, and what would satisfy it later. +- [config-drift] medium/all: Verify configured build and test commands still resolve before trusting a green or a red result. +- [test-gap] medium/feature: Create the missing test project before the first task rather than midway, even under PowerShell tooling. +- [test-fragility] medium/all: When a test must couple to a name, say so beside the test so a later rename carries a warning. +- [precedent-conflict] low/refactor: When a rule conflicts with an established local pattern, decide once and record which one wins. +- [scope-discipline] low/all: Decline an unrelated cleanup found mid-task and record it as a follow-up instead of absorbing it. +- [tooling-surprise] low/all: Check the working tree state after any command that stashes or regenerates project metadata. (2) diff --git a/tests/lessons/fixtures/corpus/FEAT-A-100/05-retro.md b/tests/lessons/fixtures/corpus/FEAT-A-100/05-retro.md new file mode 100644 index 0000000..f3eca64 --- /dev/null +++ b/tests/lessons/fixtures/corpus/FEAT-A-100/05-retro.md @@ -0,0 +1,10 @@ +# Retro - FEAT-A-100 + +## Surprises + +- [sibling-repo-assumption] high/feature: When mirroring a sibling repository, verify the local shared helper matches before copying an attribute. +- [missed-context] high/feature: Re-run impact analysis after any spec refinement, since a refined scope invalidates the earlier map. + +## Deferred follow-ups + +- [baseline-attribution] medium/all: Confirm pre-existing failures against a clean baseline before attributing or dismissing them. diff --git a/tests/lessons/fixtures/corpus/FEAT-A-101/05-retro.md b/tests/lessons/fixtures/corpus/FEAT-A-101/05-retro.md new file mode 100644 index 0000000..7afc12b --- /dev/null +++ b/tests/lessons/fixtures/corpus/FEAT-A-101/05-retro.md @@ -0,0 +1,3 @@ +# Retro - FEAT-A-101 + +- [2026-06-23T14:30:00Z] Status: in-progress -> done. Reason: manual transition. diff --git a/tests/lessons/fixtures/corpus/FEAT-A-103/05-retro.md b/tests/lessons/fixtures/corpus/FEAT-A-103/05-retro.md new file mode 100644 index 0000000..c65cc51 --- /dev/null +++ b/tests/lessons/fixtures/corpus/FEAT-A-103/05-retro.md @@ -0,0 +1,10 @@ +# Retro - FEAT-A-103 + +- [2026-06-24T09:00:00Z] Status: draft -> approved. Reason: gate 1. +- [2026-06-24T18:00:00Z] Status: approved -> in-progress. Reason: phase 4 start. + +## Surprises + +- [test-gap] medium/feature: Create the missing test project before the first task rather than midway, even under PowerShell tooling. +- [not-a-real-tag] high/feature: This tag is not in the enum and must be skipped rather than rendered. +- [missed-context] high/all: A self-test that plants a hardcoded value stops testing the moment reality moves; derive the value it plants. diff --git a/tests/lessons/fixtures/corpus/RCA-A-104/05-retro.md b/tests/lessons/fixtures/corpus/RCA-A-104/05-retro.md new file mode 100644 index 0000000..e2cbd39 --- /dev/null +++ b/tests/lessons/fixtures/corpus/RCA-A-104/05-retro.md @@ -0,0 +1,3 @@ +# Retro - RCA-A-104 + +- [2026-06-25T11:00:00Z] Status: in-progress -> done. Reason: manual transition. diff --git a/tests/lessons/fixtures/corpus/REF-A-102/05-retro.md b/tests/lessons/fixtures/corpus/REF-A-102/05-retro.md new file mode 100644 index 0000000..6bab84a --- /dev/null +++ b/tests/lessons/fixtures/corpus/REF-A-102/05-retro.md @@ -0,0 +1,10 @@ +# Retro - REF-A-102 + +## Constitution exception + +- [gate-friction] medium/refactor: When waiving a coverage gate, record measured coverage, residual risk, and what would satisfy it later. + +## Surprises + +- [baseline-attribution] low/all: confirm pre-existing failures against a clean baseline before attributing or dismissing them +- [tooling-surprise] low/all: Check the working tree state after any command that stashes or regenerates project metadata. diff --git a/tests/lessons/fixtures/expected-lessons.md b/tests/lessons/fixtures/expected-lessons.md new file mode 100644 index 0000000..52d24ef --- /dev/null +++ b/tests/lessons/fixtures/expected-lessons.md @@ -0,0 +1,37 @@ +# Lessons + +GENERATED FILE - do not edit by hand. Regenerate with +`scripts/aggregate-lessons.sh`; edits are lost on the next run. + +Every rule below is written to be free of identifiers - no paths, file names, +line numbers, class or variable names - so this file can be shared outside the +organisation as-is. That contract is enforced by `scripts/validate-lessons.*` +and is the reason a lesson reads as a general rule rather than a bug report. + +A trailing count is the number of retros a lesson was drawn from. Frequency +never raises severity. + +## sibling-repo-assumption + +- [sibling-repo-assumption] high/feature: When mirroring a sibling repository, verify the local shared helper matches before copying an attribute. + +## missed-context + +- [missed-context] high/all: A self-test that plants a hardcoded value stops testing the moment reality moves; derive the value it plants. +- [missed-context] high/feature: Re-run impact analysis after any spec refinement, since a refined scope invalidates the earlier map. + +## baseline-attribution + +- [baseline-attribution] low/all: Confirm pre-existing failures against a clean baseline before attributing or dismissing them. (2) + +## tooling-surprise + +- [tooling-surprise] low/all: Check the working tree state after any command that stashes or regenerates project metadata. + +## gate-friction + +- [gate-friction] medium/refactor: When waiving a coverage gate, record measured coverage, residual risk, and what would satisfy it later. + +## test-gap + +- [test-gap] medium/feature: Create the missing test project before the first task rather than midway, even under PowerShell tooling. diff --git a/tests/lessons/fixtures/leaky-lessons.md b/tests/lessons/fixtures/leaky-lessons.md new file mode 100644 index 0000000..fdf6bc6 --- /dev/null +++ b/tests/lessons/fixtures/leaky-lessons.md @@ -0,0 +1,29 @@ +# Lessons (fixture: must FAIL) + +Each line below violates at least one rule. `scripts/validate-lessons.*` must exit 1 on +this file, on both platforms. + +This fixture is the reason the validator can be trusted: `validate.*` proving the clean +fixture passes says nothing about whether the checks still fire. A validator that rotted +into a no-op would report the clean fixture green forever. + +## Identifier leaks + +- [sibling-repo-assumption] high/feature: Verify LeagueRedisDao casing before copying the index attribute. +- [missed-context] high/feature: The helper filterSpecialLeagues exists in only one repository, not all of them. +- [config-drift] medium/all: Check that spec_dir and index_file still point at directories that exist. + +## Code content leaks + +- [test-fragility] medium/all: Avoid reflection by string name, as in `ConvertToLeagueGroups`, when a rename is likely. +- [missed-context] high/feature: The landmine sits in the base repository at line :55 and again at :59. +- [test-gap] medium/feature: There was no unit test project, so BaseLeagueRepository.cs went uncovered. +- [scope-discipline] low/all: Decline the global format fix in src/WebServer and log it instead. + +## Shape violations + +- [pattern-violation] high/feature: This tag was retired and must no longer be accepted by the validator. +- [gate-friction] critical/refactor: Severity must come from the closed set, and critical is not in it. +- [baseline-attribution] medium/integration: Scope must come from the closed set, and integration is not in it. +- [tooling-surprise] low/all: This rule sentence is deliberately written far past the hundred and twenty character ceiling so that the length check has something real to catch. +- [missed-context] high feature: The separator between severity and scope is missing, so the grammar does not match. diff --git a/tests/metrics/README.md b/tests/metrics/README.md new file mode 100644 index 0000000..bd5e6d6 --- /dev/null +++ b/tests/metrics/README.md @@ -0,0 +1,85 @@ +# `/sd:status` verification corpus + +Fixtures and an independent oracle for `commands/status.md` (SW-16). + +## What this is - and what it is not + +`commands/status.md` is a **markdown prompt file**. There is no binary, no function, no exit code. +**CI cannot execute it**, so nothing in this folder is wired into `scripts/validate.*` or +`scripts/smoke-hooks.*`. This is a **manual verification corpus**: fixtures with known-correct +answers, plus a `jq` oracle that re-derives every answer independently of the counting method the +command prescribes. + +Stating that boundary is deliberate. A folder named `tests/` that quietly proves nothing is worse +than no folder at all. + +## Layout + +``` +fixtures/populated/events.jsonl 21 well-formed lines, all 3 event kinds, all 3 gate kinds +fixtures/malformed/events.jsonl the same 21 plus 3 bad lines (truncated / blank / non-JSON) +fixtures/empty/events.jsonl 0 bytes +expected/populated.md every number the command must produce +``` + +Line endings are pinned to LF in `.gitattributes`. The log is documented as LF-terminated and the +counts are byte-sensitive; a CRLF checkout on Windows would make the same fixture disagree with +itself across platforms. + +## Procedure + +1. In a scratch directory, create `.claude/project-config.json` and `.specs/_metrics/`, and copy one + fixture to `.specs/_metrics/events.jsonl`. +2. Run `/sd:status`. +3. Compare the output against `expected/populated.md`. +4. Re-derive the numbers with the oracle below and confirm all three agree. + +Scenarios to run, and what each proves: + +| Fixture / setup | Proves | +|---|---| +| `populated/` | Counts are correct and reconcile against `jq` | +| `malformed/` | A bad line is skipped **and counted**; every other number is unchanged | +| `empty/` | `ST004` - labelled empty, not an error, not a blank table | +| No `.specs/_metrics/` directory | `ST003` - "no metrics recorded yet" | +| `hooks.metrics.enabled: false` | `ST002` - disabled is reported as disabled | +| No `.claude/project-config.json` | `ST001` - STOP pointing at `/sd:setup` | + +## The `jq` oracle + +`jq` is **not** a runtime dependency of `/sd:status` - see D1 in the plan. It is used here only as an +independent second opinion, computed a different way than the command computes it. + +```bash +cd tests/metrics/fixtures + +jq -s 'length' populated/events.jsonl # 21 +jq -r '.event' populated/events.jsonl | sort | uniq -c # 13 / 3 / 5 +jq -r 'select(.event=="gate")|.gate' populated/events.jsonl | sort | uniq -c +jq -r 'select(has("decision"))|.decision' populated/events.jsonl | sort | uniq -c +jq -r 'select(has("ext"))|.ext' populated/events.jsonl | sort | uniq -c # sums to 8, not 9 +jq -r '.spec_id' populated/events.jsonl | sort | uniq -c | sort -rn +jq -s '[.[]|select(.event=="subagent_stop" and .stale==1)]|length' populated/events.jsonl # 3 +jq -r 'select(.event=="gate" and .decision=="block")|.spec_id' populated/events.jsonl | sort | uniq -c | sort -rn +``` + +Run the same queries against `malformed/` and **`jq` aborts**: + +``` +jq: parse error: Invalid string: control characters from U+0000 through U+001F + must be escaped at line 6, column 2 +``` + +That failure is a required result, not an inconvenience. It demonstrates why the command counts by +substring instead: a `jq`-based reader loses the whole report to a single interrupted write, which +the ticket explicitly forbids. Use `grep -cF` for the malformed run and confirm every count matches +the populated run with `skipped: 3`. + +## Negative case (required) + +Green is a claim. Before accepting a passing run, break it on purpose: + +- Delete a good line from `malformed/` and confirm a count **moves**. If nothing moves, the counts + are not being read from the fixture at all. +- Point the command at `empty/` and confirm it says so in words. A report that renders empty tables + reads as "no friction" and is a defect (`ST004`). diff --git a/tests/metrics/expected/populated.md b/tests/metrics/expected/populated.md new file mode 100644 index 0000000..94c8102 --- /dev/null +++ b/tests/metrics/expected/populated.md @@ -0,0 +1,98 @@ +# Expected `/sd:status` numbers - `fixtures/populated/events.jsonl` + +Every number below was produced twice: once by the substring counts `commands/status.md` prescribes, +and once by the independent `jq` oracle in `../README.md`. They agree. A change to the command that +makes any of these move is a regression unless the fixture moved with it. + +**Window**: `2026-07-20T08:00:01Z` -> `2026-07-21T12:00:00Z` +**Total lines**: 21 | **Well-formed**: 21 | **Skipped**: 0 + +## Events by kind + +| Kind | Count | +|---|---| +| `gate` | 13 | +| `spec_transition` | 3 | +| `subagent_stop` | 5 | +| **total** | **21** | + +## Gate activity + +| Gate | allow | warn | block | total | +|---|---|---|---|---| +| `verify` | 1 | 0 | 1 | 2 | +| `protected` | 0 | 0 | 2 | 2 | +| `code-edit` | 1 | 3 | 5 | 9 | +| **total** | **2** | **3** | **8** | **13** | + +Decision totals across **all** events that carry a `decision` (gate + spec_transition = 16): +allow 3, warn 3, block 10. The two extra `block`s and one extra `allow` are `spec_transition` +events - do not fold them into the gate table. + +## Extensions on code-edit gates + +| ext | count | +|---|---| +| `.cs` | 3 | +| `.ts` | 2 | +| `.ps1` | 2 | +| `.sh` | 1 | +| **total** | **8** | + +**8, not 9.** One `code-edit` gate carries no `ext` key (line 7 - the hook omits it when it cannot +resolve an extension). This fixture exists specifically so a reader that assumes extensions sum to +the `code-edit` total fails here. + +## Lifecycle transitions + +| Spec | From -> To | Decision | +|---|---|---| +| `FEAT-status-a` | approved -> in-progress | block | +| `BUG-parser-b` | approved -> in-progress | block | +| `REF-cleanup-c` | in-progress -> done | allow | + +## Per-spec event volume + +| spec_id | events | +|---|---| +| `FEAT-status-a` | 8 | +| `BUG-parser-b` | 8 | +| `REF-cleanup-c` | 3 | +| `-` (no spec in scope) | 2 | + +`-` is its own bucket, never ranked as a spec. + +## Friction + +| Signal | Result | +|---|---| +| Blocked at a gate (`event: gate`, `decision: block`) | `BUG-parser-b` 4, `-` 2, `REF-cleanup-c` 1, `FEAT-status-a` 1 | +| code-edit warns ignored | `FEAT-status-a` 3 | +| Retro pressure (`subagent_stop` with `"stale":1`) | `BUG-parser-b` 3 | +| Silent in-progress specs | none (all three appear in the log) | + +`stale` is a flag. `BUG-parser-b` was **observed stale 3 times**; it does not have 3 stale retros. + +## `fixtures/malformed/events.jsonl` + +Same 21 well-formed lines plus three bad ones: a truncated mid-append line, a blank line, and a +non-JSON crash message. + +| Number | Value | +|---|---| +| Total lines | 24 | +| Well-formed | 21 | +| **Skipped** | **3** | + +**Every other number in this document must be identical to the populated run.** If a count moves, +the skip logic is dropping good lines. If `skipped` reads 0, the skip logic is not running at all - +both are failures, not passes. + +`jq -s 'length'` **aborts** on this file (`parse error ... at line 6`). That is the point: a reader +built on `jq` loses the entire report to one interrupted write. The command counts by substring +precisely so a bad line costs one line, not the report. + +## `fixtures/empty/events.jsonl` + +Zero bytes. Expected state `ST004` - `Metrics log exists at but is empty (0 bytes).` +Not an error, and not an empty table. diff --git a/tests/metrics/fixtures/empty/events.jsonl b/tests/metrics/fixtures/empty/events.jsonl new file mode 100644 index 0000000..e69de29 diff --git a/tests/metrics/fixtures/malformed/events.jsonl b/tests/metrics/fixtures/malformed/events.jsonl new file mode 100644 index 0000000..541818d --- /dev/null +++ b/tests/metrics/fixtures/malformed/events.jsonl @@ -0,0 +1,24 @@ +{"ts":"2026-07-20T08:00:01Z","spec_id":"-","phase":"-","event":"gate","gate":"code-edit","decision":"block","ext":".cs"} +{"ts":"2026-07-20T08:05:12Z","spec_id":"-","phase":"-","event":"gate","gate":"code-edit","decision":"block","ext":".cs"} +{"ts":"2026-07-20T08:59:00Z","spec_id":"FEAT-status-a","phase":"in-progress","event":"spec_transition","from":"approved","decision":"block"} +{"ts":"2026-07-20T09:10:00Z","spec_id":"FEAT-status-a","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".cs"} +{"ts":"2026-07-20T09:12:31Z","spec_id":"FEAT-status-a","phase":"in-progr +{"ts":"2026-07-20T09:12:30Z","spec_id":"FEAT-status-a","phase":"in-progress","event":"gate","gate":"code-edit","decision":"warn","ext":".ts"} +{"ts":"2026-07-20T09:13:44Z","spec_id":"FEAT-status-a","phase":"in-progress","event":"gate","gate":"code-edit","decision":"warn","ext":".ts"} +{"ts":"2026-07-20T09:15:02Z","spec_id":"FEAT-status-a","phase":"in-progress","event":"gate","gate":"code-edit","decision":"warn"} +{"ts":"2026-07-20T09:20:11Z","spec_id":"FEAT-status-a","phase":"in-progress","event":"gate","gate":"protected","decision":"block"} +{"ts":"2026-07-20T09:30:00Z","spec_id":"FEAT-status-a","phase":"in-progress","event":"subagent_stop","stale":0} +{"ts":"2026-07-20T09:59:00Z","spec_id":"BUG-parser-b","phase":"in-progress","event":"spec_transition","from":"approved","decision":"block"} +{"ts":"2026-07-20T10:00:00Z","spec_id":"BUG-parser-b","phase":"in-progress","event":"gate","gate":"code-edit","decision":"block","ext":".ps1"} +{"ts":"2026-07-20T10:01:05Z","spec_id":"BUG-parser-b","phase":"in-progress","event":"gate","gate":"code-edit","decision":"block","ext":".ps1"} + +{"ts":"2026-07-20T10:02:40Z","spec_id":"BUG-parser-b","phase":"in-progress","event":"gate","gate":"code-edit","decision":"block","ext":".sh"} +{"ts":"2026-07-20T10:05:00Z","spec_id":"BUG-parser-b","phase":"in-progress","event":"subagent_stop","stale":1} +{"ts":"2026-07-20T10:30:00Z","spec_id":"BUG-parser-b","phase":"in-progress","event":"gate","gate":"protected","decision":"block"} +{"ts":"2026-07-20T10:40:00Z","spec_id":"BUG-parser-b","phase":"in-progress","event":"subagent_stop","stale":1} +{"ts":"2026-07-21T11:00:00Z","spec_id":"REF-cleanup-c","phase":"in-progress","event":"gate","gate":"verify","decision":"block"} +{"ts":"2026-07-21T11:20:00Z","spec_id":"BUG-parser-b","phase":"in-progress","event":"subagent_stop","stale":1} +{"ts":"2026-07-21T11:45:00Z","spec_id":"REF-cleanup-c","phase":"in-progress","event":"gate","gate":"verify","decision":"allow"} +{"ts":"2026-07-21T11:50:00Z","spec_id":"FEAT-status-a","phase":"in-progress","event":"subagent_stop","stale":0} +{"ts":"2026-07-21T12:00:00Z","spec_id":"REF-cleanup-c","phase":"done","event":"spec_transition","from":"in-progress","decision":"allow"} +hook interrupted: unexpected EOF while writing metric diff --git a/tests/metrics/fixtures/populated/events.jsonl b/tests/metrics/fixtures/populated/events.jsonl new file mode 100644 index 0000000..5fd43e9 --- /dev/null +++ b/tests/metrics/fixtures/populated/events.jsonl @@ -0,0 +1,21 @@ +{"ts":"2026-07-20T08:00:01Z","spec_id":"-","phase":"-","event":"gate","gate":"code-edit","decision":"block","ext":".cs"} +{"ts":"2026-07-20T08:05:12Z","spec_id":"-","phase":"-","event":"gate","gate":"code-edit","decision":"block","ext":".cs"} +{"ts":"2026-07-20T08:59:00Z","spec_id":"FEAT-status-a","phase":"in-progress","event":"spec_transition","from":"approved","decision":"block"} +{"ts":"2026-07-20T09:10:00Z","spec_id":"FEAT-status-a","phase":"in-progress","event":"gate","gate":"code-edit","decision":"allow","ext":".cs"} +{"ts":"2026-07-20T09:12:30Z","spec_id":"FEAT-status-a","phase":"in-progress","event":"gate","gate":"code-edit","decision":"warn","ext":".ts"} +{"ts":"2026-07-20T09:13:44Z","spec_id":"FEAT-status-a","phase":"in-progress","event":"gate","gate":"code-edit","decision":"warn","ext":".ts"} +{"ts":"2026-07-20T09:15:02Z","spec_id":"FEAT-status-a","phase":"in-progress","event":"gate","gate":"code-edit","decision":"warn"} +{"ts":"2026-07-20T09:20:11Z","spec_id":"FEAT-status-a","phase":"in-progress","event":"gate","gate":"protected","decision":"block"} +{"ts":"2026-07-20T09:30:00Z","spec_id":"FEAT-status-a","phase":"in-progress","event":"subagent_stop","stale":0} +{"ts":"2026-07-20T09:59:00Z","spec_id":"BUG-parser-b","phase":"in-progress","event":"spec_transition","from":"approved","decision":"block"} +{"ts":"2026-07-20T10:00:00Z","spec_id":"BUG-parser-b","phase":"in-progress","event":"gate","gate":"code-edit","decision":"block","ext":".ps1"} +{"ts":"2026-07-20T10:01:05Z","spec_id":"BUG-parser-b","phase":"in-progress","event":"gate","gate":"code-edit","decision":"block","ext":".ps1"} +{"ts":"2026-07-20T10:02:40Z","spec_id":"BUG-parser-b","phase":"in-progress","event":"gate","gate":"code-edit","decision":"block","ext":".sh"} +{"ts":"2026-07-20T10:05:00Z","spec_id":"BUG-parser-b","phase":"in-progress","event":"subagent_stop","stale":1} +{"ts":"2026-07-20T10:30:00Z","spec_id":"BUG-parser-b","phase":"in-progress","event":"gate","gate":"protected","decision":"block"} +{"ts":"2026-07-20T10:40:00Z","spec_id":"BUG-parser-b","phase":"in-progress","event":"subagent_stop","stale":1} +{"ts":"2026-07-21T11:00:00Z","spec_id":"REF-cleanup-c","phase":"in-progress","event":"gate","gate":"verify","decision":"block"} +{"ts":"2026-07-21T11:20:00Z","spec_id":"BUG-parser-b","phase":"in-progress","event":"subagent_stop","stale":1} +{"ts":"2026-07-21T11:45:00Z","spec_id":"REF-cleanup-c","phase":"in-progress","event":"gate","gate":"verify","decision":"allow"} +{"ts":"2026-07-21T11:50:00Z","spec_id":"FEAT-status-a","phase":"in-progress","event":"subagent_stop","stale":0} +{"ts":"2026-07-21T12:00:00Z","spec_id":"REF-cleanup-c","phase":"done","event":"spec_transition","from":"in-progress","decision":"allow"} diff --git a/tests/revision-log/fixtures/README.md b/tests/revision-log/fixtures/README.md new file mode 100644 index 0000000..12bc6fd --- /dev/null +++ b/tests/revision-log/fixtures/README.md @@ -0,0 +1,30 @@ +# Revision-log integrity fixtures (SW-14) + +These fixtures state the contract for the `SL070`-`SL073` revision-log integrity checks in +`/sd:spec validate` (see `commands/spec.md` -> "Revision-log integrity", and the `sd-replan-loop` +skill). The checks are **cross-artifact**: the append-only `## Revisions` log lives in `01-plan.md`, +its `Revised-by: R` markers live in `02-tasks.md`, and the two must agree in both directions. + +Each case is a directory holding a `01-plan.md` + `02-tasks.md` pair - the minimum a cross-artifact +check needs. Like the `tests/task-format/` fixtures, these **have no runner**: they document the +contract for a human or a future harness, and are pinned to LF via `.gitattributes` so a byte- or +line-sensitive reader agrees on every platform. They are deliberately not silently skipped - the +absence of a runner is recorded here, not hidden. + +| Case | Expected result | Rule | +|---|---|---| +| `valid-revision/` | PASS - one contiguous, well-formed `R1` entry; `T02` marked `Revised-by: R1`; symmetry holds both ways | none | +| `dangling-marker/` | BLOCK - `T02` carries `Revised-by: R1` but `01-plan.md` has no `## Revisions` entry at all | `SL070` | +| `one-sided/` | BLOCK - `R1` lists `Affected tasks: T02` but `T02` carries no `Revised-by: R1` marker | `SL071` | +| `broken-history/` | BLOCK - revisions jump `R1` -> `R3` (a gap); the log was not appended contiguously | `SL072` | + +Notes for a reader running the check by hand: + +- The checks fire **only** because a `## Revisions` section or a `Revised-by` marker is present. A + spec with neither - the common never-re-planned case - produces no `SL07x` finding. +- `valid-revision/` is the byte-intact-original-plan proof: the plan prose above `## Revisions` is the + original text, and the revision is appended below it, not woven in. +- These fixtures cannot demonstrate the boundary the ADR is honest about: an **unmarked** silent edit + (no `Revised-by`, no `## Revisions` entry) is invisible to a static linter and is prevented by the + HARD Gate Re-plan, not by `SL07x`. There is no fixture for it because there is nothing for the lint + to find. diff --git a/tests/revision-log/fixtures/broken-history/01-plan.md b/tests/revision-log/fixtures/broken-history/01-plan.md new file mode 100644 index 0000000..4ea900a --- /dev/null +++ b/tests/revision-log/fixtures/broken-history/01-plan.md @@ -0,0 +1,37 @@ +# Plan - FEAT-example + +## Phased overview + +- Foundation: T01 +- Behavior: T02, T03 + +## Sequencing rationale + +T01 lands the type before T02 and T03 wire behavior onto it. + +## Risks + +- The upstream interface may differ from the sample. Mitigation: verify at implementation. + +## Revisions + +### R1 - 2026-07-22T09:14:00Z + +- Trigger: T02 assumed `IFeed.Fetch` returns a list, but the real interface returns a stream. +- Phase: execute +- Gate: re-plan +- Affected tasks: T02 +- Delta: T02 rewritten to consume the stream. +- revised-from: original T02 asserted a returned `List`. + +### R3 - 2026-07-22T11:02:00Z + +- Trigger: T03's event schema clashed with the existing envelope. +- Phase: review +- Gate: re-plan +- Affected tasks: T03 +- Delta: T03 rewritten to reuse the envelope type. +- revised-from: original T03 defined a new event record. + + diff --git a/tests/revision-log/fixtures/broken-history/02-tasks.md b/tests/revision-log/fixtures/broken-history/02-tasks.md new file mode 100644 index 0000000..5d42cc2 --- /dev/null +++ b/tests/revision-log/fixtures/broken-history/02-tasks.md @@ -0,0 +1,45 @@ +# Tasks - FEAT-example + +### T01 - Add the Feed value type + +- **Files**: src/Feed.cs +- **Layer**: Domain +- **Step type**: foundation +- **Test**: tests/FeedTests.cs +- **Acceptance**: `Feed` constructs from a valid source and rejects an empty one. +- **Covers**: SC-1 +- **Depends on**: none +- **Conflicts with**: none +- **Estimated complexity**: S +- **Reversibility**: trivial +- **Pattern refs**: src/Odds.cs:12 - mirror the value-type validation shape. + +### T02 - Wire the feed handler onto the stream + +- **Files**: src/FeedHandler.cs +- **Layer**: Application +- **Step type**: behavior +- **Test**: tests/FeedHandlerTests.cs +- **Acceptance**: handler consumes the feed stream and emits one event per streamed item. +- **Covers**: SC-2 +- **Depends on**: T01 +- **Conflicts with**: none +- **Estimated complexity**: M +- **Reversibility**: moderate +- **Pattern refs**: src/OddsHandler.cs:20 - mirror the stream-consumption loop. +- **Revised-by**: R1 + +### T03 - Emit the feed event onto the envelope + +- **Files**: src/FeedEvent.cs +- **Layer**: Application +- **Step type**: behavior +- **Test**: tests/FeedEventTests.cs +- **Acceptance**: each consumed item emits one envelope-wrapped event. +- **Covers**: SC-3 +- **Depends on**: T02 +- **Conflicts with**: none +- **Estimated complexity**: M +- **Reversibility**: moderate +- **Pattern refs**: src/OddsEvent.cs:8 - reuse the envelope type. +- **Revised-by**: R3 diff --git a/tests/revision-log/fixtures/dangling-marker/01-plan.md b/tests/revision-log/fixtures/dangling-marker/01-plan.md new file mode 100644 index 0000000..ae867de --- /dev/null +++ b/tests/revision-log/fixtures/dangling-marker/01-plan.md @@ -0,0 +1,17 @@ +# Plan - FEAT-example + +## Phased overview + +- Foundation: T01 +- Behavior: T02 + +## Sequencing rationale + +T01 lands the type before T02 wires the behavior onto it. Critical path is T01 -> T02. + +## Risks + +- The upstream interface T02 assumes may differ from the sample. Mitigation: verify at implementation. + + diff --git a/tests/revision-log/fixtures/dangling-marker/02-tasks.md b/tests/revision-log/fixtures/dangling-marker/02-tasks.md new file mode 100644 index 0000000..9076082 --- /dev/null +++ b/tests/revision-log/fixtures/dangling-marker/02-tasks.md @@ -0,0 +1,30 @@ +# Tasks - FEAT-example + +### T01 - Add the Feed value type + +- **Files**: src/Feed.cs +- **Layer**: Domain +- **Step type**: foundation +- **Test**: tests/FeedTests.cs +- **Acceptance**: `Feed` constructs from a valid source and rejects an empty one. +- **Covers**: SC-1 +- **Depends on**: none +- **Conflicts with**: none +- **Estimated complexity**: S +- **Reversibility**: trivial +- **Pattern refs**: src/Odds.cs:12 - mirror the value-type validation shape. + +### T02 - Wire the feed handler onto the stream + +- **Files**: src/FeedHandler.cs +- **Layer**: Application +- **Step type**: behavior +- **Test**: tests/FeedHandlerTests.cs +- **Acceptance**: handler consumes the feed stream and emits one event per streamed item. +- **Covers**: SC-2 +- **Depends on**: T01 +- **Conflicts with**: none +- **Estimated complexity**: M +- **Reversibility**: moderate +- **Pattern refs**: src/OddsHandler.cs:20 - mirror the stream-consumption loop. +- **Revised-by**: R1 diff --git a/tests/revision-log/fixtures/one-sided/01-plan.md b/tests/revision-log/fixtures/one-sided/01-plan.md new file mode 100644 index 0000000..9e816df --- /dev/null +++ b/tests/revision-log/fixtures/one-sided/01-plan.md @@ -0,0 +1,28 @@ +# Plan - FEAT-example + +## Phased overview + +- Foundation: T01 +- Behavior: T02 + +## Sequencing rationale + +T01 lands the type before T02 wires the behavior onto it. Critical path is T01 -> T02. + +## Risks + +- The upstream interface T02 assumes may differ from the sample. Mitigation: verify at implementation. + +## Revisions + +### R1 - 2026-07-22T09:14:00Z + +- Trigger: T02 assumed `IFeed.Fetch` returns a list, but the real interface returns a stream. +- Phase: execute +- Gate: re-plan +- Affected tasks: T02 +- Delta: T02 rewritten to consume the stream and assert the streamed-count acceptance instead. +- revised-from: original T02 asserted a returned `List` and mirrored the list-based handler. + + diff --git a/tests/revision-log/fixtures/one-sided/02-tasks.md b/tests/revision-log/fixtures/one-sided/02-tasks.md new file mode 100644 index 0000000..4e910b0 --- /dev/null +++ b/tests/revision-log/fixtures/one-sided/02-tasks.md @@ -0,0 +1,29 @@ +# Tasks - FEAT-example + +### T01 - Add the Feed value type + +- **Files**: src/Feed.cs +- **Layer**: Domain +- **Step type**: foundation +- **Test**: tests/FeedTests.cs +- **Acceptance**: `Feed` constructs from a valid source and rejects an empty one. +- **Covers**: SC-1 +- **Depends on**: none +- **Conflicts with**: none +- **Estimated complexity**: S +- **Reversibility**: trivial +- **Pattern refs**: src/Odds.cs:12 - mirror the value-type validation shape. + +### T02 - Wire the feed handler onto the stream + +- **Files**: src/FeedHandler.cs +- **Layer**: Application +- **Step type**: behavior +- **Test**: tests/FeedHandlerTests.cs +- **Acceptance**: handler consumes the feed stream and emits one event per streamed item. +- **Covers**: SC-2 +- **Depends on**: T01 +- **Conflicts with**: none +- **Estimated complexity**: M +- **Reversibility**: moderate +- **Pattern refs**: src/OddsHandler.cs:20 - mirror the stream-consumption loop. diff --git a/tests/revision-log/fixtures/valid-revision/01-plan.md b/tests/revision-log/fixtures/valid-revision/01-plan.md new file mode 100644 index 0000000..9ab3a4b --- /dev/null +++ b/tests/revision-log/fixtures/valid-revision/01-plan.md @@ -0,0 +1,25 @@ +# Plan - FEAT-example + +## Phased overview + +- Foundation: T01 +- Behavior: T02 + +## Sequencing rationale + +T01 lands the type before T02 wires the behavior onto it. Critical path is T01 -> T02. + +## Risks + +- The upstream interface T02 assumes may differ from the sample. Mitigation: verify at implementation. + +## Revisions + +### R1 - 2026-07-22T09:14:00Z + +- Trigger: T02 assumed `IFeed.Fetch` returns a list, but the real interface returns a stream. +- Phase: execute +- Gate: re-plan +- Affected tasks: T02 +- Delta: T02 rewritten to consume the stream and assert the streamed-count acceptance instead. +- revised-from: original T02 asserted a returned `List` and mirrored the list-based handler. diff --git a/tests/revision-log/fixtures/valid-revision/02-tasks.md b/tests/revision-log/fixtures/valid-revision/02-tasks.md new file mode 100644 index 0000000..9076082 --- /dev/null +++ b/tests/revision-log/fixtures/valid-revision/02-tasks.md @@ -0,0 +1,30 @@ +# Tasks - FEAT-example + +### T01 - Add the Feed value type + +- **Files**: src/Feed.cs +- **Layer**: Domain +- **Step type**: foundation +- **Test**: tests/FeedTests.cs +- **Acceptance**: `Feed` constructs from a valid source and rejects an empty one. +- **Covers**: SC-1 +- **Depends on**: none +- **Conflicts with**: none +- **Estimated complexity**: S +- **Reversibility**: trivial +- **Pattern refs**: src/Odds.cs:12 - mirror the value-type validation shape. + +### T02 - Wire the feed handler onto the stream + +- **Files**: src/FeedHandler.cs +- **Layer**: Application +- **Step type**: behavior +- **Test**: tests/FeedHandlerTests.cs +- **Acceptance**: handler consumes the feed stream and emits one event per streamed item. +- **Covers**: SC-2 +- **Depends on**: T01 +- **Conflicts with**: none +- **Estimated complexity**: M +- **Reversibility**: moderate +- **Pattern refs**: src/OddsHandler.cs:20 - mirror the stream-consumption loop. +- **Revised-by**: R1 diff --git a/tests/task-format/fixtures/README.md b/tests/task-format/fixtures/README.md new file mode 100644 index 0000000..8e5bde5 --- /dev/null +++ b/tests/task-format/fixtures/README.md @@ -0,0 +1,41 @@ +# Task-block field-grammar fixtures + +Conformance fixtures for the **Field label grammar** section of +`skills/sd-atomic-task-format/SKILL.md`. + +**These fixtures have no automated runner.** `SL060` lives in `commands/spec.md` as +model-executed prose, and no script in this repo parses task blocks, so +`scripts/validate.{ps1,sh}` cannot exercise them. They are a *conformance contract*, not a CI +check - read them, and any reader of `02-tasks.md` must agree with the expectations below. +Wiring them to a real script is deliberately out of scope (see +`_bmad-output/SW-11-implementation-plan.md` section 5). + +## Why these three label forms + +Each is taken from a real spec in the only live corpus running specwright +(`asian-sportsbook-v2`, 29 tasks across 4 specs). All three occur in production; a reader built +against the canonical form alone would reject 22 of the 29 tasks - i.e. the two best-authored +specs. + +| Fixture | Form | Seen in | +|---|---|---| +| `canonical-labels.md` | `- **Files**: v` | `FEAT-ASF-245` | +| `plain-labels.md` | `- Files: v` | `FEAT-ASF-251` | +| `colon-inside-emphasis.md` | `- **Files:** v` | `FEAT-ASF-251-LeagueContainer` | +| `missing-pattern-refs.md` | canonical, `Pattern refs` absent | negative case | + +## Expectations + +1. **The first three fixtures parse identically.** Same 11 fields, same values. Label form is + presentation, never meaning. +2. **`colon-inside-emphasis.md` proves value extent.** Its `Acceptance` and `Pattern refs` are + multi-line with nested sub-bullets. A reader that stops at the first newline truncates both - + it must return the full value, up to the next field label. +3. **`missing-pattern-refs.md` is the negative case.** It is a well-formed task block with every + other required field present, and no `Pattern refs`. `/sd:spec validate` must report exactly + one `SL060` (WARN) against it - and must report **no** `SL060` for the other three. + +Expectation 3 is the one that matters: a check that never fires is the failure mode this repo +has already shipped once (see SW-20, where `selftest-docs` hardcoded a count and silently stopped +biting). If a change makes `missing-pattern-refs.md` pass clean, the check is broken - not the +fixture. diff --git a/tests/task-format/fixtures/canonical-labels.md b/tests/task-format/fixtures/canonical-labels.md new file mode 100644 index 0000000..9d12691 --- /dev/null +++ b/tests/task-format/fixtures/canonical-labels.md @@ -0,0 +1,13 @@ +### T01 - Add a widget factory + +- **Files**: src/Widgets/WidgetFactory.cs +- **Layer**: Application +- **Step type**: foundation +- **Test**: test/Widgets/WidgetFactoryTests.cs +- **Acceptance**: `WidgetFactory.Create()` returns a non-null `Widget`; zero new build warnings. +- **Covers**: none +- **Depends on**: none +- **Conflicts with**: none +- **Estimated complexity**: S +- **Reversibility**: trivial +- **Pattern refs**: src/Gadgets/GadgetFactory.cs:12 - mirror the factory shape and DI registration. diff --git a/tests/task-format/fixtures/colon-inside-emphasis.md b/tests/task-format/fixtures/colon-inside-emphasis.md new file mode 100644 index 0000000..dfbaf69 --- /dev/null +++ b/tests/task-format/fixtures/colon-inside-emphasis.md @@ -0,0 +1,17 @@ +### T01 - Add a widget factory + +- **Files:** src/Widgets/WidgetFactory.cs +- **Layer:** Application +- **Step type:** foundation +- **Test:** test/Widgets/WidgetFactoryTests.cs +- **Acceptance:** + - `WidgetFactory.Create()` returns a non-null `Widget`. + - Zero new build warnings. +- **Covers:** none +- **Depends on:** none +- **Conflicts with:** none +- **Estimated complexity:** S +- **Reversibility:** trivial +- **Pattern refs:** + - src/Gadgets/GadgetFactory.cs:12 - mirror the factory shape. + - src/Startup.cs:44 - mirror the DI registration line. diff --git a/tests/task-format/fixtures/missing-pattern-refs.md b/tests/task-format/fixtures/missing-pattern-refs.md new file mode 100644 index 0000000..98bab53 --- /dev/null +++ b/tests/task-format/fixtures/missing-pattern-refs.md @@ -0,0 +1,12 @@ +### T01 - Add a widget factory + +- **Files**: src/Widgets/WidgetFactory.cs +- **Layer**: Application +- **Step type**: foundation +- **Test**: test/Widgets/WidgetFactoryTests.cs +- **Acceptance**: `WidgetFactory.Create()` returns a non-null `Widget`; zero new build warnings. +- **Covers**: none +- **Depends on**: none +- **Conflicts with**: none +- **Estimated complexity**: S +- **Reversibility**: trivial diff --git a/tests/task-format/fixtures/plain-labels.md b/tests/task-format/fixtures/plain-labels.md new file mode 100644 index 0000000..f03a6a9 --- /dev/null +++ b/tests/task-format/fixtures/plain-labels.md @@ -0,0 +1,13 @@ +### T01 - Add a widget factory + +- Files: src/Widgets/WidgetFactory.cs +- Layer: Application +- Step type: foundation +- Test: test/Widgets/WidgetFactoryTests.cs +- Acceptance: `WidgetFactory.Create()` returns a non-null `Widget`; zero new build warnings. +- Covers: none +- Depends on: none +- Conflicts with: none +- Estimated complexity: S +- Reversibility: trivial +- Pattern refs: src/Gadgets/GadgetFactory.cs:12 - mirror the factory shape and DI registration.