From 37350ca8da7952a71f3211faed06e0fb26a2869c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20S=C3=A4nger?= <20968534+dsnger@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:23:36 +0200 Subject: [PATCH 1/6] docs(intake): add spec-delta-for-gate-b story; mark P5 light ready for intake (Daniel, 2026-10-03) --- .../2026-10-03-spec-delta-for-gate-b-story.md | 51 +++++++++++++++++++ todos.md | 7 ++- 2 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 docs/superpowers/stories/2026-10-03-spec-delta-for-gate-b-story.md diff --git a/docs/superpowers/stories/2026-10-03-spec-delta-for-gate-b-story.md b/docs/superpowers/stories/2026-10-03-spec-delta-for-gate-b-story.md new file mode 100644 index 0000000..35f6441 --- /dev/null +++ b/docs/superpowers/stories/2026-10-03-spec-delta-for-gate-b-story.md @@ -0,0 +1,51 @@ +# Spec-delta for the Gate-B reviewer (vision step 2c, part 3) — Story + +**Date:** 2026-10-03 · **Size:** story +**Risk:** standard · **Security:** none · **Validation:** battery+check + +## 1. Problem statement +Part 3 of the epic `docs/superpowers/stories/2026-10-02-telemetry-and-review-loop-usefulness-story.md`. +Gate A closes a spec on one exact text: its closing commit carries that text (CLAUDE.md §5, Gate +A's content condition). Afterwards the spec can still change — during planning, during execution, +or through a Gate-B fix that changes specified behaviour. Nothing shows the Gate-B reviewer what +changed in the spec since its review. CLAUDE.md §5 records what this costs: a Gate-B fix reordered +a precedence rule, the spec kept describing the old behaviour, and a PR bot found the disagreement +only after merge-readiness, because the Gate-B call was given only the diff. + +## 2. Desired outcome +For every spec that a change's plans cite, the Gate-B reviewer sees the spec revision that its +Gate-A cycle closed on, and every change to it since then — or an explicit statement that there +was none, or that it cannot be determined. The delta informs the review. It does not change what a +pass is, what a cycle owes, or any record format. + +## 3. Acceptance criteria +- [ ] For a spec with a closed Gate-A cycle, the revision it closed on is determined from the + existing records (the commit carrying that cycle's provenance line), without a new record + format. +- [ ] Every change to that spec between the closed revision and the Gate-B candidate is shown as + a delta. "No change", "renamed or deleted" and "closed revision not determinable" (no + closing commit, conflicting records) are shown explicitly and are never silently skipped. +- [ ] Each Gate-B call can carry that delta, or its explicit absence, for every spec the reviewed + plans cite, beside the diff. +- [ ] A check demonstrates, on a fixture, that a spec edited after its Gate-A close produces a + delta the Gate-B input contains, and that an unedited spec produces "no change". +- [ ] Producing the delta changes no pass-validity rule, floor, closure condition or commit-body + record format, and states what it cannot establish (for example: whether the change was + reviewed, or whether the spec still matches the code). + +## 4. Affected AGENTS.md invariants +- `## Don'ts` — "**Never describe what a gate proves without checking what it actually compares.**" +- `### Packaging` — "5. **Every version pinned exactly.**" +- `### Packaging` — "12. **A plugin change requires a version bump.**" (binds only if anything + ships in the plugin) + +## 5. Open questions +- Is the delta delivered by a repo-local tool that the Gate-B prompt cites, or by a change to the + shipped Gate-B instructions (prompt = product, a different review weight)? +- Do plans get the same treatment as specs? Both close on an exact text under Gate A. +- Does a non-empty delta oblige anything (for example a Gate-A re-review), or only inform? This + story assumes "inform only"; an obligation would change gate rules and needs its own decision. + +## 6. Suggested size +story — one mechanism over existing records plus its use in the Gate-B call; one spec → plan → PR. +G1a (todos.md, controlled change procedure) later produces the human decisions this delta records. diff --git a/todos.md b/todos.md index e0b6195..98443e1 100644 --- a/todos.md +++ b/todos.md @@ -679,8 +679,11 @@ backlog. IDs exist to label what profiles produce, so the numbering scheme should meet a real profiled story before it gets a template slot. **2026-10-03:** ordering group G1 (above) adds G1b, an `AC-ID → evidence → result → - revision` view, on top of these IDs. The trigger above is unchanged; whether the - profiled 2c stories already meet it is Daniel's call. + revision` view, on top of these IDs. The trigger above is unchanged. + **2026-10-03 (Daniel, on `.context/sparring/20261003-152014-post-pr35-p5-next-step-assessment.md`): + trigger met — profiled stories have run (the 2c parts). Ready for intake, scope the IDs + only: G1b and vision leaf 4e are not activated by it. It is not started in parallel with + 2c part 3; after part 3 it is ranked against part 4.** - [ ] **`/workflow-init` preflight checks `CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS`.** The variable keeps a >120 s gate call in the foreground so its result reaches the hook. From 47db446b7f465338793e83de803ff1232c19ec95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20S=C3=A4nger?= <20968534+dsnger@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:05:30 +0200 Subject: [PATCH 2/6] docs(stories): narrow 2c part 3 to an explicit baseline comparison (Daniel, 2026-10-03) --- .../2026-10-03-spec-delta-for-gate-b-story.md | 57 ++++++++++++------- 1 file changed, 38 insertions(+), 19 deletions(-) diff --git a/docs/superpowers/stories/2026-10-03-spec-delta-for-gate-b-story.md b/docs/superpowers/stories/2026-10-03-spec-delta-for-gate-b-story.md index 35f6441..c05f68a 100644 --- a/docs/superpowers/stories/2026-10-03-spec-delta-for-gate-b-story.md +++ b/docs/superpowers/stories/2026-10-03-spec-delta-for-gate-b-story.md @@ -13,25 +13,44 @@ a precedence rule, the spec kept describing the old behaviour, and a PR bot foun only after merge-readiness, because the Gate-B call was given only the diff. ## 2. Desired outcome -For every spec that a change's plans cite, the Gate-B reviewer sees the spec revision that its -Gate-A cycle closed on, and every change to it since then — or an explicit statement that there -was none, or that it cannot be determined. The delta informs the review. It does not change what a -pass is, what a cycle owes, or any record format. +**Narrowed 2026-10-03 (Daniel), after Gate-A spec pass 1.** The existing records do not name the +file a Gate-A cycle reviewed, so this part delivers an **explicit baseline comparison**, not a +discovered "reviewed revision". For every relevant spec and plan, the Gate-B reviewer sees the +change from a **given** baseline (a file at a named commit) to the candidate. The report checks the +baseline's Gate-A metadata and shows any ambiguity, and it claims no confirmed review attribution: +"compared with the given baseline; Gate-A metadata checked". A relevant spec without a baseline, +or whose original is gone after a squash, stays visibly unknown. The report informs and obliges +nothing: it changes no pass rule, closure condition or record format. ## 3. Acceptance criteria -- [ ] For a spec with a closed Gate-A cycle, the revision it closed on is determined from the - existing records (the commit carrying that cycle's provenance line), without a new record - format. -- [ ] Every change to that spec between the closed revision and the Gate-B candidate is shown as - a delta. "No change", "renamed or deleted" and "closed revision not determinable" (no - closing commit, conflicting records) are shown explicitly and are never silently skipped. -- [ ] Each Gate-B call can carry that delta, or its explicit absence, for every spec the reviewed - plans cite, beside the diff. -- [ ] A check demonstrates, on a fixture, that a spec edited after its Gate-A close produces a - delta the Gate-B input contains, and that an unedited spec produces "no change". -- [ ] Producing the delta changes no pass-validity rule, floor, closure condition or commit-body - record format, and states what it cannot establish (for example: whether the change was - reviewed, or whether the spec still matches the code). +- [ ] For each given baseline (`path@commit`), the report checks that the commit resolves, that + the path exists there, and which Gate-A records of the matching kind the commit carries. It + shows ambiguity (several cycles, several reviewed-kind files changed, or records suggesting a + squash) and never states that the cycle reviewed exactly this file. +- [ ] The change from each baseline to the candidate is shown as a delta, with "no change" and + "renamed or deleted" explicit. +- [ ] Every relevant spec appears in the report, including the specs cited by an unchanged + contributing plan. A relevant spec without a given baseline is shown as "baseline missing — + unknown", and never silently dropped or replaced by a later (for example squashed) text. +- [ ] The report's output is carried in a real Gate-B call's input (this story's own Gate B), and + a fixture check shows that an edited and an unedited artifact produce "changed" and "no + change". +- [ ] Producing the report changes no pass-validity rule, floor, closure condition or commit-body + record format, and the report states what it cannot establish. + +**Scope narrowed 2026-10-03 (Daniel, on the reviewer's assessment +`.context/sparring/20261003-160225-spec-delta-explicit-baseline-assessment.md`), after Gate-A spec +pass 1 showed that records do not attribute a cycle to a file.** What changed: + +| Earlier criterion or promise | Fate | +|---|---| +| Outcome: the reviewer sees the revision the Gate-A cycle closed on | **Narrowed** to a comparison with an explicitly given baseline; review attribution is not claimed. | +| Criterion 1: closed revision determined from existing records | **Replaced**: the baseline is given; its Gate-A metadata is checked and ambiguity shown. | +| Criterion 2: delta, explicit no-change / renamed / not determinable | **Kept**, with "not determinable" now "baseline missing — unknown". | +| Criterion 3: each Gate-B call can carry it | **Kept and strengthened**: a real Gate-B call carries it (criterion 4). | +| Criterion 4: fixture check | **Kept**, plus the real Gate-B input. | +| Criterion 5: no rule or record change, stated limits | **Kept**. | +| Discovering closed revisions automatically | **Deferred**: it needs a record that names the reviewed file — a record-format and gate-rule change, outside this story. | ## 4. Affected AGENTS.md invariants - `## Don'ts` — "**Never describe what a gate proves without checking what it actually compares.**" @@ -43,8 +62,8 @@ pass is, what a cycle owes, or any record format. - Is the delta delivered by a repo-local tool that the Gate-B prompt cites, or by a change to the shipped Gate-B instructions (prompt = product, a different review weight)? - Do plans get the same treatment as specs? Both close on an exact text under Gate A. -- Does a non-empty delta oblige anything (for example a Gate-A re-review), or only inform? This - story assumes "inform only"; an obligation would change gate rules and needs its own decision. +- Does a non-empty delta oblige anything (for example a Gate-A re-review)? Decided 2026-10-03: + inform only; an obligation would change gate rules and needs its own decision. ## 6. Suggested size story — one mechanism over existing records plus its use in the Gate-B call; one spec → plan → PR. From 7a36b8071cc12251983809de7ac58e0993aa81aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20S=C3=A4nger?= <20968534+dsnger@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:22:03 +0200 Subject: [PATCH 3/6] docs(specs): design the spec-delta baseline comparison (vision 2c, part 3) A read-only report shows the Gate-B reviewer, for every relevant spec and plan, the change from an explicitly given baseline to the candidate. It quotes the baseline commit's cycle records verbatim, names any ambiguity, and claims no review attribution. A missing baseline stays visibly unknown. Pass 1 led to Daniel narrowing the scope on 2026-10-03 (47db446). cycle 7mdof8i8pb; floor 3 per {docs/superpowers/stories/2026-10-03-spec-delta-for-gate-b-story.md (level 1)}; hook reminder threshold absent cycle 7mdof8i8pb; Gate-A spec (passes 1-4, gpt-6-astra): Findings 13,9,8,6. Blockers 0,0,0,0. Majors 8,4,4,0. --- .../specs/2026-10-03-spec-delta-design.md | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-03-spec-delta-design.md diff --git a/docs/superpowers/specs/2026-10-03-spec-delta-design.md b/docs/superpowers/specs/2026-10-03-spec-delta-design.md new file mode 100644 index 0000000..600f510 --- /dev/null +++ b/docs/superpowers/specs/2026-10-03-spec-delta-design.md @@ -0,0 +1,188 @@ +# Spec-delta for the Gate-B reviewer — design + +**Story:** `docs/superpowers/stories/2026-10-03-spec-delta-for-gate-b-story.md` — read the profile from its header at every gate call. + +Vision step 2c, part 3, narrowed on 2026-10-03 to an **explicit baseline comparison**. The epic is +`docs/superpowers/stories/2026-10-02-telemetry-and-review-loop-usefulness-story.md` (criterion 6). + +## §1 What it is + +`scripts/spec-delta.py` is a read-only report for a Gate-B call. For every relevant spec and plan +(§3), it shows the change from a **given baseline** to the Gate-B candidate. It checks the +baseline's Gate-A metadata and shows any ambiguity. Its claim is exactly: "compared with the given +baseline; Gate-A metadata checked". **It never states that a Gate-A cycle reviewed this file in +this version**, because no record names the file a cycle reviewed. + +The output is plain text, made to be pasted into a Gate-B call's `additionalContext` beside the +diff. It writes nothing. It is repo-local and not shipped, like `scripts/ledger-metrics.py`, +`scripts/run-analytics.py` and `scripts/loop-usefulness.py`. + +**It informs and obliges nothing** (story, decided 2026-10-03). It changes no pass-validity rule, +floor, closure condition, gate obligation or record format. A non-empty delta reopens nothing. + +| Path | Change | +|---|---| +| `scripts/spec-delta.py` | **New.** Python 3.8+, standard library. | +| `scripts/spec-delta.test.sh` | **New.** POSIX-sh suite with a fixture repository. | +| `AGENTS.md`, `.github/workflows/ci.yml`, `README.md` | The suite joins the quality and lint rows and CI; the inventories count the new script. | + +Nothing under `plugins/` changes, so invariant 12 does not bind. + +## §2 Invocation + +``` +python3 -B scripts/spec-delta.py [--base ] [--plan ]... [--spec ]... [--baseline :]... [] +``` + +- `` is the Gate-B candidate (default `HEAD`); `--base` is the Gate-B `baseSha`. +- `--plan` names a contributing plan. It is repeatable, and needed when a plan is unchanged in + `..`, for example under the pre-commit WIP range CLAUDE.md §5 prescribes. +- `--spec` names a relevant spec that no other rule selects, for example one unchanged in the + range and cited elsewhere than a plan header. It is repeatable; with no `--baseline` for it, it + shows as "baseline missing — unknown". +- `--baseline` names an artifact's baseline in git's own `:` form, split on the first + `:` (a ref name cannot contain one). It is repeatable. Recent plans in this repository record + their spec's closing commit in the header (for example "closed in `f9aae57`"); older ones do not, + and then the caller has to find the commit. + +`` and `--base` are resolved with `git rev-parse --verify --end-of-options ^{commit}`; +either not resolving is exit 1. A **baseline** ref that does not resolve — for example an original +closing commit lost to a squash merge or missing from this clone — is **not** an exit: that +artifact's state is "baseline commit not available — unknown", and the rest of the report runs. +The same path given twice with different refs is exit 1, "conflicting baselines for ". + +## §3 Which artifacts are relevant + +1. **Contributing plans:** every `--plan`, plus, with `--base`, every `docs/superpowers/plans/*.md` + path that a commit in `..` changes. They are deduplicated. +1a. **Changed specs:** with `--base`, every `docs/superpowers/specs/*.md` path that a commit in + `..` changes, whether or not a plan cites it. +2. **Cited specs:** for each contributing plan, every backticked path matching + `docs/superpowers/specs/*.md` on the plan's **header** `**Spec:**` line. That is the first line + that starts `**Spec:**` and comes before the plan's first `## ` heading. Task-level `**Spec:**` + lines later in a plan are not read. The plan is read at ``, or, if it is absent there and + `--base` is given, at ``. A plan that cannot be read at either is listed as "plan not + readable" with its specs unknown, and a plan with no header `**Spec:**` line, or one naming no + spec path, is listed as "no spec header" — both in the plan's own block. +3. Every `--spec` and every `--baseline` path. + +The relevant set is the union. Each member gets a block, so a plan that did not change in the range +never hides its specs, and a relevant artifact without a `--baseline` is shown as **baseline +missing — unknown**. The report never substitutes another text, such as a squash commit's, for a +missing baseline. + +## §4 Baseline checks and states + +For each artifact with a baseline `@`, the report checks and shows: +- whether the path exists at `` (if not: "baseline path absent at ", and no + comparison); +- the cycle records in ``'s body, **quoted verbatim**, one per line: every line that + `scripts/ledger-metrics.py`'s `CANDIDATE` matches, each marked "matching kind" when + `parse_record` returns a curve or skip record whose kind is `Gate-A spec` (for + `docs/superpowers/specs/`) or `Gate-A plan` (for `docs/superpowers/plans/`), and "unparsed" when + `parse_record` rejects it. The report joins, pairs or interprets nothing further. A provenance + line has no kind, and a pre-rule record identifies no cycle, so both are quoted and never + counted as matches. The reader sees exactly what the commit claims; +- **ambiguity**, each case named: + - no matching-kind record ("no Gate-A record of this kind at the baseline"); + - matching-kind records naming more than one cycle field; + - the commit changing more than one path of the same kind (specs or plans), so that which file + a cycle reviewed is not settled by the commit; + - the commit also carrying a `Gate B` curve — a sign that it may be a squash of a later state + rather than the original close. + +These checks qualify the comparison and never block it. Then: + +| State | When | Shown | +|---|---|---| +| **no change** | content at `` equals content at `` | the checks | +| **changed** | content differs | the checks, then the diff from `` to `` for the path | +| **renamed or deleted** | the path is absent at `` | the checks; for a rename detected by `git diff --find-renames` between the two commits, the new path and its diff; otherwise the deletion diff | +| **baseline missing — unknown** | a relevant artifact with no `--baseline` | nothing else | +| **baseline path absent** | the path does not exist at `` | the checks | +| **baseline commit not available — unknown** | the baseline ref does not resolve | the ref as given | + +**Git hygiene,** as in `scripts/loop-usefulness.py`: +- remove repository-selecting variables and `GIT_TRACE*`, and set `GIT_TRACE2*` to `0`; +- read with `--no-replace-objects` and an empty graft file; +- disable signature display; +- refuse partial clones (exit 1). + +Diffs use `--no-color --no-ext-diff --no-textconv --no-relative`, plus `-c diff.noprefix=false +-c diff.mnemonicPrefix=false -c core.quotePath=true`. These neutralize the settings they name — +colour, external diff drivers, text conversion, relative paths, prefixes and path quoting — and +the list is not exhaustive. A shallow repository is noted in the header, because a baseline commit may be missing +there. A missing baseline commit is that artifact's "baseline commit not available — unknown" +(§2); only `` and `--base` are fatal. + +## §5 The report + +Plain text on stdout: + +1. **Header:** + - `` and `` (if given) as full shas, and whether history is shallow; + - the relevant artifacts with their counts per state; + - the line "compared with given baselines; Gate-A metadata checked; review attribution not + established; informs and obliges nothing". +2. **One block per artifact**: specs first, then plans, each sorted by path. A block starts with + `== `, then the baseline, its checks, the state and any diff. +3. **What this report cannot establish:** + - which file a Gate-A cycle reviewed; + - whether a change was reviewed or intended; + - whether a spec still matches the code; + - an original text lost to a squash merge; + - relevant artifacts outside the given and discovered set; + - specs cited elsewhere than the plan header. + +Exit 0 on success. Exit 1, with `spec-delta: ` on stderr and nothing on stdout, when: +- `` or `--base` does not resolve, or baselines conflict; +- history cannot be read; +- git is missing or older than 2.36; +- the repository is a partial clone; +- the parser cannot be loaded. + +The report is meant to be run under `python3 -B`, so the interpreter's own startup writes no +bytecode either. The script itself turns bytecode off before its first import, as part 2 does. + +## §6 Tests and the real Gate-B input + +`scripts/spec-delta.test.sh` builds a fixture repository and compares the whole report with +expected text. It covers: +- a spec with a baseline, then edited (**changed**, with the diff), and one untouched (**no + change**); +- a plan with a baseline, then edited; +- a plan header citing two specs, one with and one without a baseline (**baseline missing**); +- an unchanged plan given by `--plan`, whose specs still appear; +- a plan deleted at ``, read at ``; +- a renamed spec and a deleted spec (with its deletion diff); +- a spec changed in the range that no plan cites (it still appears); +- a baseline ref that does not resolve (that artifact is unknown, the rest runs); +- a plan with no header `**Spec:**` line; +- record quoting: a matching curve, a matching skip record, a provenance line, a pre-rule record, + a record of another kind, and an unparsed candidate line, each marked as §4 says; +- a spec selected only by `--spec`, without a baseline; +- each ambiguity: no metadata, two cycles, two specs changed in the baseline commit, and a + baseline commit that also carries a `Gate B` curve; +- a baseline path absent at its commit, and conflicting baselines (exit 1); +- an unresolvable ref and a partial clone (exit 1, nothing on stdout); +- user diff configuration (`diff.noprefix`, `diff.relative`, an external diff driver) that leaves + the report unchanged; +- the run writes nothing: repository and HOME are unchanged, ignored files included, under + `python3 -B`; +- a negative control: a copy that reads every `**Spec:**` line instead of the header line picks + up task-level references, and the suite catches it. + +**The real Gate-B input (story criterion 4):** this story's own Gate-B calls carry the report for +this story's spec and plan, with `--baseline` set to their Gate-A closing commits. The evidence +entry records the exact invocation, the `` sha, the report's header line and each +artifact's state line, so a reader can rerun it. + +## §7 Story criteria + +| Criterion | Where | +|---|---| +| 1 baseline checks, ambiguity shown, no attribution claimed | §1, §4 | +| 2 delta with explicit no-change / renamed or deleted | §4 | +| 3 every relevant spec appears; a missing baseline is unknown | §3 | +| 4 real Gate-B input and fixture check | §6 | +| 5 no rule or record change; stated limits | §1, §5 | From 8b52829abe00d589db23e50aa327bbfb72ea82f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20S=C3=A4nger?= <20968534+dsnger@users.noreply.github.com> Date: Sat, 3 Oct 2026 17:22:32 +0200 Subject: [PATCH 4/6] Add the spec-delta implementation plan (vision 2c, part 3) The plan embeds the tested report, its POSIX-sh suite (16 cases) and the docs/CI edit script, plus twelve rulings where it settles what the closed spec left open. cycle i0rng7770i; floor 3 per {docs/superpowers/stories/2026-10-03-spec-delta-for-gate-b-story.md (level 1)}; hook reminder threshold absent cycle i0rng7770i; Gate-A plan (passes 1-4, gpt-6-astra): Findings 16,8,4,2. Blockers 0,0,0,0. Majors 9,5,1,0. --- .../plans/2026-10-03-spec-delta.md | 961 ++++++++++++++++++ 1 file changed, 961 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-03-spec-delta.md diff --git a/docs/superpowers/plans/2026-10-03-spec-delta.md b/docs/superpowers/plans/2026-10-03-spec-delta.md new file mode 100644 index 0000000..46f8ed6 --- /dev/null +++ b/docs/superpowers/plans/2026-10-03-spec-delta.md @@ -0,0 +1,961 @@ +# Spec-delta 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. + +**Story:** `docs/superpowers/stories/2026-10-03-spec-delta-for-gate-b-story.md` — read the profile from its header at every gate call. + +**Goal:** Add `scripts/spec-delta.py`, a read-only report that shows a Gate-B reviewer, for every relevant spec and plan, the change from an explicitly given baseline to the candidate. It quotes the baseline commit's cycle records, names any ambiguity, and claims no review attribution. Add its POSIX-sh suite, and wire the suite into the battery, CI and the docs. + +**Architecture:** One Python 3.8+ standard-library script, steps in order: +1. Refuse a partial clone, and resolve the head and base refs. A baseline ref that does not resolve only makes that artifact unknown. +2. Collect the relevant artifacts: changed plans and specs in the range, the specs cited on each contributing plan's header `**Spec:**` line, and every `--spec` and `--baseline` path. +3. For each artifact with a baseline: quote the baseline commit's cycle records (marked through `scripts/ledger-metrics.py`'s parser), name the ambiguities, and diff baseline against candidate under fixed git and diff settings. + +It writes nothing. The suite builds one fixture repository and compares the whole report with expected text. + +**Tech Stack:** Python 3.8+ (standard library), git 2.36+, POSIX `sh` for the suite, `shellcheck` 0.11.0. + +**Spec:** `docs/superpowers/specs/2026-10-03-spec-delta-design.md` (Gate-A spec cycle `7mdof8i8pb`, closed in `7a36b80`). + +## Global Constraints + +- Worktree `/Users/daniel/DEVELOPMENT/APPS/dwk-spec-delta`, branch `spec-delta`. Paths are relative to it. +- Not shipped: no file under `plugins/` changes, and there is no version bump (spec §1). +- Writes nothing. Informs and obliges nothing; no gate rule, pass rule or record format changes (story, spec §1). +- P8 (`scripts/ledger-metrics.py`) is imported and never changed (epic criterion 8). +- The suite passes `shellcheck --shell=sh --exclude=SC2015`. +- Unexpected repository state is a stop and a question to Daniel; no automated stash, rebase or reset. + +## Rulings — spec cycle Minors and implementation choices + +The spec is closed and is not edited. Where the implementation settles something the spec left open, the ruling is stated here, and the Gate-B call names it. + +1. **Plan diagnostics** ("plan not readable", "no spec header", and the `cites:` line) are shown in the plan's own block, whatever its comparison state (spec pass-4 Minor). +2. **The real Gate-B input** (spec §6) is generated with the full `baseSha` and `headSha` that the Gate-B call itself passes, and the call carries exactly that output (spec pass-4 Minor). +3. **Diffs also use `--text`**, so a `binary` attribute cannot reduce an artifact's diff to a summary (spec pass-4 Minor). +4. **Any repository-relative path** is accepted by `--spec` and `--baseline`. Only specs and plans have a matching kind, and a path that is not a file at the baseline is "baseline path absent" (spec pass-4 Minor). +5. **Wording.** Zero matches reads "no matching-kind record with a cycle nonce at the baseline". A pre-rule record is quoted and marked "pre-rule record, identifies no cycle", never as a match (spec pass-4 Minor). +6. **Control characters** in quoted records, paths and diff lines print as `\xNN`. A tab is kept (spec pass-4 Minor). +7. **A rename destination changed in the range is a relevant spec of its own.** It shows "baseline missing — unknown" unless a baseline is given for it. Its source path, with a baseline, shows the rename. +8. **Every git call runs at the repository root with `GIT_LITERAL_PATHSPECS=1`.** Every read the report depends on is checked. A failed read is exit 1, "history could not be read", and nothing is reported from a partial read. An absent tree entry is distinguished from an unreadable object, which is exit 1 (plan pass-1 Majors). +9. **Changed paths are the union of every commit in the range, with merges diffed against their first parent (`--diff-merges=first-parent`), and the net ``..`` diff.** So a file that only a merge resolution changes is found, even one a later merge restores (plan passes 1 and 2). +10. **Only a `Gate B` curve raises the squash ambiguity**, as spec §4 says; a `Gate B` skip record is quoted without it. A `remote.*.promisor` set to false is not a partial clone, and the baseline commit's changed-path census forces `log.showRoot=true`. A block lists the baseline and its checks before the state, then any diff (spec §5). +11. **Paths are normalized** (`./` removed, `posixpath.normpath`), the command-line ones and those cited in a plan header alike, and a path that leaves the repository is exit 1. A plan file that exists is read through a checked read, so a broken object is exit 1, not a silent fallback to the base (plan pass 2). +12. **Not demonstrated by the suite, stated rather than implied:** signature suppression needs signed fixture commits and a signing key, and `diff.mnemonicPrefix` and `core.quotePath` change nothing in a commit-to-commit diff of ASCII paths. The overrides stay, as harmless guards. Graft-file suppression, replace refs, colour, text conversion, external diff drivers and relative paths are each pinned by a test. + +## Review Focus + +1. **This branch's own Gate B** is the real input check (story criterion 4). The call carries the report for this spec and plan, so a reviewer sees the report as it will be used. +2. **CI's Linux runner** runs the suite for the first time. It needs `git`, `python3` and POSIX utilities. +3. **A squash commit as baseline** is flagged by its `Gate B` record, never rejected. The report still compares, because the caller chose it. +4. **A large diff** is printed in full. No limit is set; the Gate-B prompt size is the caller's concern. + +--- + +## File map + +| Path | Change | Task | +|---|---|---| +| `scripts/spec-delta.test.sh` | Create | 1 | +| `scripts/spec-delta.py` | Create | 1 | +| `AGENTS.md` | Layout tree, Boundaries, quality, lint and typecheck rows, prerequisites | 2 | +| `.github/workflows/ci.yml` | Lint step, suite step, one comment | 2 | +| `README.md` | Contributing: the suites line and one paragraph | 2 | + +--- + +### Task 1: The suite, then the script + +**Files:** Create `scripts/spec-delta.test.sh`, `scripts/spec-delta.py`. + +- [ ] **Step 1: Write the suite** + +Create `scripts/spec-delta.test.sh` with exactly this content: + +````sh +#!/bin/sh +# Regression suite for spec-delta.py. +# +# Spec: docs/superpowers/specs/2026-10-03-spec-delta-design.md §6. Builds a fixture repository whose +# commits carry Gate-A records and change specs and plans, runs the report with explicit baselines, +# and compares it with expected text. +set -u + +HERE="$(cd "$(dirname "$0")" && pwd)" +SCRIPT="$HERE/spec-delta.py" +LEDGER="$HERE/ledger-metrics.py" +pass_n=0; fail_n=0 +pass() { pass_n=$((pass_n + 1)); printf 'ok - %s\n' "$1"; } +fail() { fail_n=$((fail_n + 1)); printf 'FAIL - %s\n' "$1"; } + +work=$(mktemp -d) || work='' +if [ -z "$work" ] || [ ! -d "$work" ]; then + printf 'FAIL - could not create a temporary directory; refusing to run\n' >&2 + exit 1 +fi +trap 'rm -rf "$work"' EXIT +work=$(cd "$work" && pwd -P) + +GIT_CONFIG_GLOBAL=/dev/null; GIT_CONFIG_NOSYSTEM=1; GIT_ATTR_NOSYSTEM=1 +export GIT_CONFIG_GLOBAL GIT_CONFIG_NOSYSTEM GIT_ATTR_NOSYSTEM +unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES \ + GIT_CEILING_DIRECTORIES GIT_DEFAULT_HASH GIT_COMMON_DIR GIT_NAMESPACE \ + GIT_CONFIG_COUNT GIT_CONFIG_PARAMETERS GIT_REPLACE_REF_BASE GIT_SHALLOW_FILE \ + GIT_GRAFT_FILE GIT_NO_REPLACE_OBJECTS +tick=1700000000 +gitc() { + GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@example.com GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@example.com \ + GIT_AUTHOR_DATE="$tick +0000" GIT_COMMITTER_DATE="$tick +0000" \ + git -c commit.gpgsign=false -c init.defaultBranch=main "$@" +} +S=docs/superpowers/specs; PL=docs/superpowers/plans +# put FILE TEXT: write a file (creating its directory) +put() { mkdir -p "$(dirname "$1")"; printf '%b' "$2" > "$1"; } +# cm MESSAGE: commit everything with MESSAGE; prints nothing +cm() { tick=$((tick + 100)); printf '%b' "$1" > "$work/msg"; gitc add -A && gitc commit -q -F "$work/msg"; } +sha() { git rev-parse HEAD; } +prov() { printf "cycle %s; floor 3 per {docs/superpowers/stories/s-story.md (level 1)}; hook reminder threshold absent" "$1"; } +curve() { printf 'cycle %s; %s (passes 1-2, codex): Findings 2,0. Blockers 0,0. Majors 1,0.' "$1" "$2"; } + +mkrepo() { + rm -rf "${work:?}/repo"; mkdir -p "$work/repo/scripts" + cp "$SCRIPT" "$LEDGER" "$work/repo/scripts/" + ( + cd "$work/repo" || exit 1 + gitc init -q --template= --object-format=sha1 . + printf '.context/\n' > .gitignore + for f in a b c d e g h i j m n q r; do put $S/$f.md "# $f\\nline one\\n"; done + put $PL/p4.md "# p4\\n\\n**Spec:** \`$S/m.md\`, \`$S/q.md\` — two citations, neither with a baseline\\n" + put $PL/p1.md "# p1\\n\\n**Spec:** \`$S/a.md\`, \`$S/b.md\` — read the profile.\\n\\n## Task 1\\n\\n**Spec:** \`$S/task.md\`\\n" + put $PL/p2.md "# p2\\n\\nno header line here\\n" + put $PL/p3.md "# p3\\n\\n**Spec:** \`$S/f.md\`\\n" + cm 'base\n' + sha > "$work/c_base" + put $S/a.md "# a\\nline one\\nreviewed text\\n" + cm "close a\\n\\n$(prov aaaaaaaa)\\n$(curve aaaaaaaa 'Gate-A spec')\\n" + sha > "$work/c_closeA" + put $PL/p1.md "# p1\\n\\n**Spec:** \`$S/a.md\`, \`$S/b.md\` — read the profile.\\nreviewed plan\\n\\n## Task 1\\n\\n**Spec:** \`$S/task.md\`\\n" + cm "close p1\\n\\n$(prov pppppppp)\\n$(curve pppppppp 'Gate-A plan')\\n" + sha > "$work/c_closeP1" + put $S/g.md "# g\\nline two\\n"; put $S/h.md "# h\\nline two\\n" + cm "two cycles\\n\\n$(prov bbbbbbbb)\\n$(curve bbbbbbbb 'Gate-A spec')\\n$(curve cccccccc 'Gate-A spec')\\n$(curve dddddddd 'Gate-A plan')\\ncycle none (pre-rule); Gate-A spec (passes 1, codex): Findings 0. Blockers 0. Majors 0.\\ncycle zzzzzzzz; not a record\\n" + sha > "$work/c_two" + put $S/i.md "# i\\nsquashed\\n" + cm "squash\\n\\n$(prov eeeeeeee)\\n$(curve eeeeeeee 'Gate-A spec')\\n$(curve ffffffff 'Gate B')\\n" + sha > "$work/c_squash" + put $S/n.md "# n\\nskipped close\\n" + cm "skip close\\n\\n$(prov gggggggg)\\ncycle gggggggg; Gate-A spec: skipped (see skip reason)\\nSkip reason: trivial.\\n\\ncycle hhhhhhhh; Gate B: skipped (see skip reason)\\nSkip reason: trivial.\\n" + sha > "$work/c_skip" + sha > "$work/c_basepoint" + # ---- the reviewed range ---- + put $S/a.md "# a\\nline one\\nreviewed text\\nedited after the close\\n" + put $PL/p1.md "# p1\\n\\n**Spec:** \`$S/a.md\`, \`$S/b.md\` — read the profile.\\nreviewed plan\\nplan edited\\n\\n## Task 1\\n\\n**Spec:** \`$S/task.md\`\\n" + gitc mv $S/c.md $S/c2.md + git rm -q $S/d.md $PL/p3.md + put $S/e.md "# e\\nline one\\nchanged, cited by no plan\\n" + cm 'candidate\n' + # a spec that only a merge resolution adds + gitc checkout -q -b side + put side.txt "side\\n"; cm 'side\n' + gitc checkout -q main + gitc merge -q --no-ff --no-commit side >/dev/null 2>&1 + put $S/o.md "# o\\nadded in the merge resolution\\n" + tick=$((tick + 100)); gitc add -A && gitc commit -q -m 'merge side' + # r.md is changed by one merge resolution and restored by another: only merge diffs show it + gitc checkout -q -b side2 "$(cat "$work/c_basepoint")"; put side2.txt "s2\\n"; cm 'side2\n' + gitc checkout -q -b side3 "$(cat "$work/c_basepoint")"; put side3.txt "s3\\n"; cm 'side3\n' + gitc checkout -q main + gitc merge -q --no-ff --no-commit side2 >/dev/null 2>&1 + put $S/r.md "# r\\nchanged in a merge\\n"; tick=$((tick + 100)); gitc add -A && gitc commit -q -m 'merge side2' + gitc merge -q --no-ff --no-commit side3 >/dev/null 2>&1 + put $S/r.md "# r\\nline one\\n"; tick=$((tick + 100)); gitc add -A && gitc commit -q -m 'merge side3' + sha > "$work/c_head" + ) +} + +raw() { (cd "$work/repo" && HOME="$work/home" python3 -B scripts/spec-delta.py "$@"); } +run() { raw "$@" 2>&1; } +snapshot() { python3 - "$1" <<'SNAP' +import hashlib, os, sys +for d, dirs, files in sorted(os.walk(sys.argv[1])): + for n in sorted(dirs + files): + p = os.path.join(d, n); st = os.lstat(p) + h = hashlib.sha256(open(p, "rb").read()).hexdigest() if os.path.isfile(p) and not os.path.islink(p) else "-" + print(os.path.relpath(p, sys.argv[1]), oct(st.st_mode), st.st_size, st.st_mtime_ns, h) +SNAP +} +expect() { # name, actual (expected on stdin) + printf '%s\n' "$2" > "$work/actual"; cat > "$work/expected" + if diff "$work/expected" "$work/actual" > "$work/diff"; then pass "$1"; else fail "$1"; sed 's/^/ /' "$work/diff"; fi +} +bad() { # name, expected message, args... — exit 1, the reason on stderr, nothing on stdout + name=$1; msg=$2; shift 2 + raw "$@" > "$work/bad.out" 2> "$work/bad.err"; s=$? + e=$(cat "$work/bad.err") + if [ "$s" -eq 1 ] && printf '%s' "$e" | grep -qF "spec-delta: $msg" && [ ! -s "$work/bad.out" ]; then pass "$name" + else fail "$name (exit $s: $e; stdout $(wc -c < "$work/bad.out") bytes)"; fi +} +mask() { sed -E 's/[0-9a-f]{40}/SHA/g; s/index [0-9a-f]+\.\.[0-9a-f]+/index X..Y/; s/^baseline: [0-9a-f]{7,40} /baseline: REF /'; } + +build() { rm -rf "${work:?}/home"; mkdir -p "$work/home"; mkrepo; } +# wargs CMD...: run CMD with the main run's arguments appended. +wargs() { + "$@" --base "$(cat "$work/c_basepoint")" --plan $PL/p2.md --plan $PL/p3.md --plan $PL/p4.md \ + --plan $PL/ghost.md --spec $S/j.md --baseline "$(cat "$work/c_skip"):$S/n.md" \ + --baseline "$(cat "$work/c_base"):$S/q.md" \ + --baseline "$(cat "$work/c_closeA"):$S/a.md" --baseline "$(cat "$work/c_base"):$S/b.md" \ + --baseline "$(cat "$work/c_base"):$S/c.md" --baseline "$(cat "$work/c_base"):$S/d.md" \ + --baseline "$(cat "$work/c_two"):$S/g.md" --baseline "$(cat "$work/c_squash"):$S/i.md" \ + --baseline "deadbeef:$S/k.md" --baseline "$(cat "$work/c_base"):$S/zz.md" \ + --baseline "$(cat "$work/c_closeP1"):$PL/p1.md" +} + +# ---- 1. the main report ----------------------------------------------------------------------------- +build +before=$(snapshot "$work/repo"; snapshot "$work/home") +out=$(wargs run); st=$? +[ "$st" -eq 0 ] && pass "main run exits 0" || fail "main run exit $st" +[ "$(snapshot "$work/repo"; snapshot "$work/home")" = "$before" ] && + pass "the run writes nothing (repository and HOME, ignored files included)" || fail "the run wrote a file" +expect "report" "$(printf '%s\n' "$out" | mask)" <<'EOF' +spec-delta — compared with given baselines; Gate-A metadata checked; review attribution not established; informs and obliges nothing +head SHA base SHA +artifacts 22: baseline commit not available — unknown 1, baseline missing — unknown 11, baseline path absent 1, changed 2, no change 5, renamed or deleted 2 + +== docs/superpowers/specs/a.md +baseline: SHA (SHA) +records in the baseline commit (verbatim): + [record] cycle aaaaaaaa; floor 3 per {docs/superpowers/stories/s-story.md (level 1)}; hook reminder threshold absent + [matching kind] cycle aaaaaaaa; Gate-A spec (passes 1-2, codex): Findings 2,0. Blockers 0,0. Majors 1,0. +ambiguity: none found +state: changed +diff: +diff --git a/docs/superpowers/specs/a.md b/docs/superpowers/specs/a.md +index X..Y 100644 +--- a/docs/superpowers/specs/a.md ++++ b/docs/superpowers/specs/a.md +@@ -1,3 +1,4 @@ + # a + line one + reviewed text ++edited after the close + +== docs/superpowers/specs/b.md +baseline: SHA (SHA) +records in the baseline commit: none +ambiguity: no matching-kind record with a cycle nonce at the baseline; the baseline commit changes 13 specs, so it does not settle which one a cycle reviewed +state: no change + +== docs/superpowers/specs/c.md +baseline: SHA (SHA) +records in the baseline commit: none +ambiguity: no matching-kind record with a cycle nonce at the baseline; the baseline commit changes 13 specs, so it does not settle which one a cycle reviewed +renamed to docs/superpowers/specs/c2.md +state: renamed or deleted +diff: +diff --git a/docs/superpowers/specs/c.md b/docs/superpowers/specs/c2.md +similarity index 100% +rename from docs/superpowers/specs/c.md +rename to docs/superpowers/specs/c2.md + +== docs/superpowers/specs/c2.md +state: baseline missing — unknown + +== docs/superpowers/specs/d.md +baseline: SHA (SHA) +records in the baseline commit: none +ambiguity: no matching-kind record with a cycle nonce at the baseline; the baseline commit changes 13 specs, so it does not settle which one a cycle reviewed +deleted at the candidate +state: renamed or deleted +diff: +diff --git a/docs/superpowers/specs/d.md b/docs/superpowers/specs/d.md +deleted file mode 100644 +index X..Y +--- a/docs/superpowers/specs/d.md ++++ /dev/null +@@ -1,2 +0,0 @@ +-# d +-line one + +== docs/superpowers/specs/e.md +state: baseline missing — unknown + +== docs/superpowers/specs/f.md +state: baseline missing — unknown + +== docs/superpowers/specs/g.md +baseline: SHA (SHA) +records in the baseline commit (verbatim): + [record] cycle bbbbbbbb; floor 3 per {docs/superpowers/stories/s-story.md (level 1)}; hook reminder threshold absent + [matching kind] cycle bbbbbbbb; Gate-A spec (passes 1-2, codex): Findings 2,0. Blockers 0,0. Majors 1,0. + [matching kind] cycle cccccccc; Gate-A spec (passes 1-2, codex): Findings 2,0. Blockers 0,0. Majors 1,0. + [record] cycle dddddddd; Gate-A plan (passes 1-2, codex): Findings 2,0. Blockers 0,0. Majors 1,0. + [pre-rule record, identifies no cycle] cycle none (pre-rule); Gate-A spec (passes 1, codex): Findings 0. Blockers 0. Majors 0. + [unparsed] cycle zzzzzzzz; not a record +ambiguity: matching-kind records name 2 cycles: bbbbbbbb, cccccccc; the baseline commit changes 2 specs, so it does not settle which one a cycle reviewed +state: no change + +== docs/superpowers/specs/i.md +baseline: SHA (SHA) +records in the baseline commit (verbatim): + [record] cycle eeeeeeee; floor 3 per {docs/superpowers/stories/s-story.md (level 1)}; hook reminder threshold absent + [matching kind] cycle eeeeeeee; Gate-A spec (passes 1-2, codex): Findings 2,0. Blockers 0,0. Majors 1,0. + [record] cycle ffffffff; Gate B (passes 1-2, codex): Findings 2,0. Blockers 0,0. Majors 1,0. +ambiguity: the baseline commit also carries a Gate B record: it may be a squash of a later state +state: no change + +== docs/superpowers/specs/j.md +state: baseline missing — unknown + +== docs/superpowers/specs/k.md +baseline: REF (does not resolve) +state: baseline commit not available — unknown + +== docs/superpowers/specs/m.md +state: baseline missing — unknown + +== docs/superpowers/specs/n.md +baseline: SHA (SHA) +records in the baseline commit (verbatim): + [record] cycle gggggggg; floor 3 per {docs/superpowers/stories/s-story.md (level 1)}; hook reminder threshold absent + [matching kind] cycle gggggggg; Gate-A spec: skipped (see skip reason) + [record] cycle hhhhhhhh; Gate B: skipped (see skip reason) +ambiguity: none found +state: no change + +== docs/superpowers/specs/o.md +state: baseline missing — unknown + +== docs/superpowers/specs/q.md +baseline: SHA (SHA) +records in the baseline commit: none +ambiguity: no matching-kind record with a cycle nonce at the baseline; the baseline commit changes 13 specs, so it does not settle which one a cycle reviewed +state: no change + +== docs/superpowers/specs/r.md +state: baseline missing — unknown + +== docs/superpowers/specs/zz.md +baseline: SHA (SHA) +records in the baseline commit: none +ambiguity: no matching-kind record with a cycle nonce at the baseline; the baseline commit changes 13 specs, so it does not settle which one a cycle reviewed +the path is not a file at the baseline commit +state: baseline path absent + +== docs/superpowers/plans/ghost.md +plan not readable at the candidate or the base; its specs are unknown +state: baseline missing — unknown + +== docs/superpowers/plans/p1.md +cites: docs/superpowers/specs/a.md, docs/superpowers/specs/b.md +baseline: SHA (SHA) +records in the baseline commit (verbatim): + [record] cycle pppppppp; floor 3 per {docs/superpowers/stories/s-story.md (level 1)}; hook reminder threshold absent + [matching kind] cycle pppppppp; Gate-A plan (passes 1-2, codex): Findings 2,0. Blockers 0,0. Majors 1,0. +ambiguity: none found +state: changed +diff: +diff --git a/docs/superpowers/plans/p1.md b/docs/superpowers/plans/p1.md +index X..Y 100644 +--- a/docs/superpowers/plans/p1.md ++++ b/docs/superpowers/plans/p1.md +@@ -2,6 +2,7 @@ + + **Spec:** `docs/superpowers/specs/a.md`, `docs/superpowers/specs/b.md` — read the profile. + reviewed plan ++plan edited + + ## Task 1 + + +== docs/superpowers/plans/p2.md +no spec header (no header **Spec:** line naming a spec path) +state: baseline missing — unknown + +== docs/superpowers/plans/p3.md +cites: docs/superpowers/specs/f.md +state: baseline missing — unknown + +== docs/superpowers/plans/p4.md +cites: docs/superpowers/specs/m.md, docs/superpowers/specs/q.md +state: baseline missing — unknown + +== What this report cannot establish +- Which file a Gate-A cycle reviewed: no record names it, so a baseline is the caller's claim. +- Whether a change was reviewed or intended, or whether a spec still matches the code. +- An original text lost to a squash merge: give the original closing commit, or the artifact stays unknown. +- Relevant artifacts outside the given and discovered set, and specs cited elsewhere than a plan's header line. +EOF + +# ---- 2. diff configuration does not change the report ---------------------------------------------- +(cd "$work/repo" && git config diff.noprefix true && git config diff.relative true && git config diff.external false && + git config core.quotePath false && git config color.ui always && git config diff.mnemonicPrefix true && + git config diff.upper.textconv 'tr a-z A-Z' && mkdir -p .git/info && printf '*.md diff=upper\n' > .git/info/attributes && + git config log.showSignature true) +cfg=$(wargs run) +[ "$cfg" = "$out" ] && pass "user diff configuration leaves the report unchanged" || fail "diff configuration changed the report" + +# ---- 3. failures ------------------------------------------------------------------------------------ +build +bad "unresolvable head" "the ref does not resolve to a commit" nosuchref +bad "unresolvable base" "the ref does not resolve to a commit" --base nosuchref +bad "conflicting baselines" "conflicting baselines for $S/a.md" --baseline "HEAD:$S/a.md" --baseline "HEAD~1:$S/a.md" +(cd "$work/repo" && git config extensions.partialClone origin) +bad "partial clone refused" "partial clones are not supported" + +# ---- 3b. explicit head, hostile environment, shallow history, missing parser, old git ------------ +build +old=$(run --baseline "$(cat "$work/c_closeA"):$S/a.md" "$(cat "$work/c_closeP1")") +printf '%s\n' "$old" | grep -q "^head $(cat "$work/c_closeP1")$" && + printf '%s\n' "$old" | grep -q '^state: no change$' && + pass "an explicit older head is compared, not HEAD" || fail "explicit head ignored" +plain=$(wargs run) +( + cd "$work/repo" || exit 1 + c=$(cat "$work/c_closeA") + git cat-file commit "$c" | sed 's/^cycle aaaaaaaa; Gate-A spec/cycle aaaaaaaa; Gate-A plan/' > "$work/fake" + git replace "$c" "$(git hash-object -t commit -w "$work/fake")" +) +mkdir -p "$work/other" && (cd "$work/other" && gitc init -q .) +printf '%s\n' "$(cat "$work/c_closeA")" > "$work/grafts" # would make c_closeA a root commit +hostile_run() { (cd "$work/repo/scripts" && GIT_DIR="$work/other/.git" GIT_TRACE="$work/trace" \ + GIT_TRACE2_EVENT="$work/trace2" GIT_GRAFT_FILE="$work/grafts" HOME="$work/home" python3 -B spec-delta.py "$@") 2>&1; } +hostile=$(wargs hostile_run) +[ "$hostile" = "$plain" ] && [ ! -e "$work/trace" ] && [ ! -e "$work/trace2" ] && + pass "from a subdirectory, with GIT_DIR, GIT_TRACE, a graft file and a replace ref: the same report, no trace written" || + fail "the environment changed the report or wrote a trace" +dot=$(run --baseline "$(cat "$work/c_closeA"):./$S/a.md") +printf '%s\n' "$dot" | grep -q "^== $S/a.md$" && printf '%s\n' "$dot" | grep -q '^state: changed$' && + pass "a ./ path is normalized" || fail "a ./ path was not normalized" +rm -rf "${work:?}/shallow"; git clone -q --depth 1 "file://$work/repo" "$work/shallow" 2>/dev/null +sh_out=$( (cd "$work/shallow" && HOME="$work/home" python3 -B scripts/spec-delta.py --spec $S/a.md) 2>&1) +printf '%s\n' "$sh_out" | grep -q 'shallow: a baseline commit may be missing' && + pass "a shallow clone is noted in the header" || fail "shallow clone not noted" +mkdir -p "$work/repo/tools" && cp "$work/repo/scripts/spec-delta.py" "$work/repo/tools/" +(cd "$work/repo" && HOME="$work/home" python3 -B tools/spec-delta.py) > "$work/bad.out" 2> "$work/bad.err"; s=$? +[ "$s" -eq 1 ] && grep -qF 'spec-delta: the record parser scripts/ledger-metrics.py cannot be loaded' "$work/bad.err" && + [ ! -s "$work/bad.out" ] && pass "a missing parser is exit 1" || fail "missing parser (exit $s)" +rm -rf "${work:?}/repo/tools" +mkdir -p "$work/shim" && printf '#!/bin/sh\nprintf "git version 2.30.0\\n"\n' > "$work/shim/git" && chmod +x "$work/shim/git" +(cd "$work/repo" && PATH="$work/shim:$PATH" HOME="$work/home" python3 -B scripts/spec-delta.py) > "$work/bad.out" 2> "$work/bad.err"; s=$? +[ "$s" -eq 1 ] && grep -qF 'spec-delta: git 2.36 or later is required' "$work/bad.err" && [ ! -s "$work/bad.out" ] && + pass "git older than 2.36 is exit 1" || fail "old git (exit $s)" +build +c=$(cat "$work/c_skip") # a commit inside the reviewed range +rm -f "$work/repo/.git/objects/$(printf '%s' "$c" | cut -c1-2)/$(printf '%s' "$c" | cut -c3-)" +bad "history that cannot be read" "history could not be read" --base "$(cat "$work/c_two")" --spec $S/a.md \ + --baseline "$(cat "$work/c_two"):$S/g.md" + +# ---- 4. negative control ---------------------------------------------------------------------------- +build +python3 - "$work/repo/scripts/spec-delta.py" "$work/repo/scripts/allspec.py" <<'PY' +import sys +s = open(sys.argv[1]).read() +i, j = s.index("def header_specs(text):"), s.index("def read_plan(") +mutant = ('def header_specs(text): # mutant: every **Spec:** line, header or not\n' + ' found = [p for line in text.split("\\n") if line.startswith("**Spec:**")\n' + ' for p in RE_SPEC_PATH.findall(line)]\n' + ' return list(dict.fromkeys(found)) or None\n\n\n') +open(sys.argv[2], "w").write(s[:i] + mutant + s[j:]) +PY +mutrun() { (cd "$work/repo" && HOME="$work/home" python3 -B scripts/allspec.py "$@") 2>&1; } +mut=$(wargs mutrun) +printf '%s\n' "$mut" | grep -q "^== $S/task.md$" && ! printf '%s\n' "$out" | grep -q "^== $S/task.md$" && + pass "negative control: reading every **Spec:** line picks up a task-level reference, and the suite catches it" || + fail "negative control not caught" + +printf '\n%d passed, %d failed\n' "$pass_n" "$fail_n" +[ "$fail_n" -eq 0 ] +```` + +- [ ] **Step 2: Run it before the script exists** + +Run: `sh scripts/spec-delta.test.sh > /tmp/sd-red.log 2>&1; echo "exit=$?"; tail -1 /tmp/sd-red.log` +Expected: `exit=1`. + +- [ ] **Step 3: Write the script** + +Create `scripts/spec-delta.py` with exactly this content: + +````python +#!/usr/bin/env python3 +"""Spec-delta: compare each relevant spec and plan with an explicitly given baseline. + +Spec: docs/superpowers/specs/2026-10-03-spec-delta-design.md +Usage: python3 -B scripts/spec-delta.py [--base ] [--plan ]... [--spec ]... + [--baseline :]... [] + +Read-only. It checks each baseline commit's Gate-A records and shows them verbatim, names any +ambiguity, and claims no review attribution: no record names the file a Gate-A cycle reviewed. +It informs a Gate-B reviewer and obliges nothing. Standard library only; Python 3.8+; git 2.36+. +""" +import sys + +sys.dont_write_bytecode = True # before any other import: the report writes nothing, .pyc included + +import importlib.util # noqa: E402 +import os # noqa: E402 +import posixpath # noqa: E402 +import re # noqa: E402 +import subprocess # noqa: E402 + +GIT_SELECTORS = ("GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_INDEX_FILE", + "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_CEILING_DIRECTORIES", "GIT_DISCOVERY_ACROSS_FILESYSTEM", "GIT_NAMESPACE") +NO_SIG = ("-c", "log.showSignature=false") +DIFF_CFG = ("-c", "diff.noprefix=false", "-c", "diff.mnemonicPrefix=false", "-c", "core.quotePath=true") +DIFF_FLAGS = ("--no-color", "--no-ext-diff", "--no-textconv", "--no-relative", "--text") +SPECS, PLANS = "docs/superpowers/specs/", "docs/superpowers/plans/" +RE_SPEC_PATH = re.compile(r"`(docs/superpowers/specs/[^`]+\.md)`") +HEADER = "compared with given baselines; Gate-A metadata checked; review attribution not established; " \ + "informs and obliges nothing" +CANNOT = """== What this report cannot establish +- Which file a Gate-A cycle reviewed: no record names it, so a baseline is the caller's claim. +- Whether a change was reviewed or intended, or whether a spec still matches the code. +- An original text lost to a squash merge: give the original closing commit, or the artifact stays unknown. +- Relevant artifacts outside the given and discovered set, and specs cited elsewhere than a plan's header line.""" + + +def shown(text): + return "".join("\\x%02x" % ord(c) if (ord(c) < 0x20 and c != "\t") or 0x7F <= ord(c) <= 0x9F else c + for c in str(text)) + + +def die(msg): + sys.stderr.buffer.write(("spec-delta: %s\n" % shown(msg)).encode("utf-8", "backslashreplace")) + sys.stderr.flush() + sys.exit(1) + + +ROOT = [None] # the repository root, set in main; every git call runs there + + +def git(args): + env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_TRACE")} + for k in GIT_SELECTORS + ("GIT_SHALLOW_FILE",): + env.pop(k, None) + env.update(GIT_TRACE2="0", GIT_TRACE2_EVENT="0", GIT_TRACE2_PERF="0", GIT_GRAFT_FILE=os.devnull, + GIT_LITERAL_PATHSPECS="1") + try: + p = subprocess.run(("git", "--no-replace-objects") + NO_SIG + tuple(args), env=env, cwd=ROOT[0], + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + except OSError: + return 127, b"" + return p.returncode, p.stdout + + +def must(args): + """A git read that has to succeed; any failure is exit 1, so nothing is reported from a partial read.""" + rc, out = git(args) + if rc != 0: + die("history could not be read") + return out + + +def resolve(ref): + rc, out = git(("rev-parse", "--verify", "--end-of-options", ref + "^{commit}")) + return out.decode().strip() if rc == 0 and out.strip() else None + + +def blob(commit, path): + """The blob id of path at commit, or None if no file entry is there; an unreadable object is exit 1.""" + entry = must(("ls-tree", "-z", commit, "--", path)).split(b"\0")[0] + meta, _, name = entry.partition(b"\t") + fields = meta.split() + if len(fields) != 3 or fields[1] != b"blob" or os.fsdecode(name) != path: + return None + oid = fields[2].decode() + must(("cat-file", "-e", oid)) + return oid + + +def kind_of(path): + return "Gate-A spec" if path.startswith(SPECS) else "Gate-A plan" if path.startswith(PLANS) else None + + +def load_parser(here): + spec = importlib.util.spec_from_file_location("ledger_metrics", os.path.join(here, "ledger-metrics.py")) + try: + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + except (OSError, SyntaxError, AttributeError): + die("the record parser scripts/ledger-metrics.py cannot be loaded") + return mod + + +def parse_args(argv): + opts = {"base": None, "plan": [], "spec": [], "baseline": {}, "head": "HEAD"} + rest, i = [], 0 + while i < len(argv): + a = argv[i] + if a in ("--base", "--plan", "--spec", "--baseline"): + if i + 1 >= len(argv): + die("%s needs a value" % a) + v = argv[i + 1] + i += 2 + if a == "--base": + opts["base"] = v + elif a == "--baseline": + ref, sep, path = v.partition(":") + if not sep or not ref or not path: + die("--baseline needs :") + path = norm(path) + if opts["baseline"].get(path, ref) != ref: + die("conflicting baselines for %s" % path) + opts["baseline"][path] = ref + else: + opts[a[2:]].append(norm(v)) + else: + rest.append(a) + i += 1 + if len(rest) > 1: + die("usage: spec-delta.py [--base ] [--plan ]... [--spec ]... " + "[--baseline :]... []") + if rest: + opts["head"] = rest[0] + return opts + + +def header_specs(text): + """Spec paths on a plan's header **Spec:** line, or None when there is no such line.""" + for line in text.split("\n"): + if line.startswith("## "): + return None + if line.startswith("**Spec:**"): + return list(dict.fromkeys(RE_SPEC_PATH.findall(line))) + return None + + +def read_plan(path, head, base): + for c in (head, base): + if c and blob(c, path): + return must(("cat-file", "blob", blob(c, path))).decode("utf-8", "replace") + return None + + +def norm(path): + """A repository-relative path in canonical form; one that leaves the repository is exit 1.""" + p = posixpath.normpath(path) + if p.startswith("/") or p == ".." or p.startswith("../") or p == ".": + die("path outside the repository: %s" % path) + return p + + +def baseline_block(path, ref, head, lm): + """Lines for one artifact with a baseline; returns (state, lines).""" + commit = resolve(ref) + if commit is None: + return "baseline commit not available — unknown", ["baseline: %s (does not resolve)" % shown(ref)] + lines = ["baseline: %s (%s)" % (shown(ref), commit)] + body = must(("log", "-1", "--format=%B", commit)) + kind = kind_of(path) + fields, gate_b = set(), False + records = [] + for line in body.decode("utf-8", "replace").split("\n"): + if not lm.CANDIDATE.match(line): + continue + parsed = lm.parse_record(line) + mark = "unparsed" + if parsed is not None: + field, typ, data = parsed + mark = "record" + if typ == "curve" and data.get("kind") == "Gate B": + gate_b = True + if field == "none (pre-rule)": + mark = "pre-rule record, identifies no cycle" + elif typ in ("curve", "skip") and kind and data.get("kind") == kind: + mark = "matching kind" + fields.add(field) + records.append(" [%s] %s" % (mark, shown(line))) + lines.append("records in the baseline commit (verbatim):" if records else "records in the baseline commit: none") + lines += records + out = must(("-c", "log.showRoot=true", "show", "--first-parent", "--no-renames", "--name-only", "--format=", + "-z", commit)) + changed = [p for p in out.decode("utf-8", "replace").split("\0") if p] + same_kind = [p for p in changed if kind and kind_of(p) == kind] + amb = [] + if not fields: + amb.append("no matching-kind record with a cycle nonce at the baseline") + if len(fields) > 1: + amb.append("matching-kind records name %d cycles: %s" % (len(fields), ", ".join(sorted(fields)))) + if len(same_kind) > 1: + amb.append("the baseline commit changes %d %s, so it does not settle which one a cycle reviewed" % ( + len(same_kind), "specs" if kind == "Gate-A spec" else "plans")) + if gate_b: + amb.append("the baseline commit also carries a Gate B record: it may be a squash of a later state") + lines.append("ambiguity: " + ("; ".join(amb) if amb else "none found")) + old = blob(commit, path) + if old is None: + return "baseline path absent", lines + ["the path is not a file at the baseline commit"] + new = blob(head, path) + if new == old: + return "no change", lines + if new is not None: + d = must(DIFF_CFG + ("diff",) + DIFF_FLAGS + (commit, head, "--", path)) + return "changed", lines + ["diff:"] + [shown(x) for x in d.decode("utf-8", "replace").rstrip("\n").split("\n")] + ns = must(("diff", "--find-renames", "--name-status", "-z", commit, head)) + parts = ns.decode("utf-8", "replace").split("\0") + target, i = None, 0 + while i < len(parts) - 1: + st = parts[i] + if st.startswith("R"): + if parts[i + 1] == path: + target = parts[i + 2] + i += 3 + elif st.startswith("C"): + i += 3 + else: + i += 2 + args = (commit, head, "--", path, target) if target else (commit, head, "--", path) + d = must(DIFF_CFG + ("diff", "--find-renames") + DIFF_FLAGS + args) + lines.append("renamed to %s" % shown(target) if target else "deleted at the candidate") + return "renamed or deleted", lines + ["diff:"] + [shown(x) for x in d.decode("utf-8", "replace").rstrip("\n").split("\n")] + + +def main(argv): + o = parse_args(argv[1:]) + rc, out = git(("--version",)) + m = re.search(rb"(\d+)\.(\d+)", out) if rc == 0 else None + if not m or (int(m.group(1)), int(m.group(2))) < (2, 36): + die("git 2.36 or later is required") + rc, top = git(("rev-parse", "--show-toplevel")) + if rc != 0 or not top.strip(): + die("not inside a git working tree") + ROOT[0] = os.fsdecode(top.rstrip(b"\n")) + rc, out = git(("config", "--get-regexp", r"^(extensions\.partialclone|remote\..*\.(promisor|partialclonefilter))$")) + if rc not in (0, 1): # 1 means no such key + die("the repository configuration cannot be read") + for line in out.decode("utf-8", "replace").splitlines(): + key, _, value = line.partition(" ") + if not key.endswith(".promisor") or value.strip().lower() not in ("false", "no", "off", "0"): + die("partial clones are not supported (reading history could fetch objects)") + head = resolve(o["head"]) + if head is None: + die("the ref does not resolve to a commit: %s" % o["head"]) + base = None + if o["base"] is not None: + base = resolve(o["base"]) + if base is None: + die("the ref does not resolve to a commit: %s" % o["base"]) + lm = load_parser(os.path.dirname(os.path.abspath(__file__))) + + changed = [] + if base: + out = must(("log", "--format=", "--name-only", "--no-renames", "--diff-merges=first-parent", "-z", + "%s..%s" % (base, head))) + net = must(("diff", "--name-only", "--no-renames", "-z", base, head)) # includes merge-only edits + changed = list(dict.fromkeys(p for p in (out + b"\0" + net).decode("utf-8", "replace") + .replace("\n", "\0").split("\0") if p)) + plans = list(dict.fromkeys(o["plan"] + [p for p in changed if p.startswith(PLANS) and p.endswith(".md")])) + relevant = dict.fromkeys(plans) + relevant.update(dict.fromkeys(p for p in changed if p.startswith(SPECS) and p.endswith(".md"))) + notes = {} + for p in plans: + text = read_plan(p, head, base) + if text is None: + notes[p] = "plan not readable at the candidate or the base; its specs are unknown" + continue + cited = header_specs(text) + if not cited: + notes[p] = "no spec header (no header **Spec:** line naming a spec path)" + continue + cited = list(dict.fromkeys(norm(c) for c in cited)) + notes[p] = "cites: " + shown(", ".join(cited)) + relevant.update(dict.fromkeys(cited)) + relevant.update(dict.fromkeys(o["spec"])) + relevant.update(dict.fromkeys(o["baseline"])) + + order = sorted(relevant, key=lambda p: (0 if p.startswith(SPECS) else 1 if p.startswith(PLANS) else 2, p)) + blocks, counts = [], {} + for p in order: + if p in o["baseline"]: + st, lines = baseline_block(p, o["baseline"][p], head, lm) + else: + st, lines = "baseline missing — unknown", [] + counts[st] = counts.get(st, 0) + 1 + if "diff:" in lines: + k = lines.index("diff:") + lines = lines[:k] + ["state: " + st] + lines[k:] + else: + lines = lines + ["state: " + st] + blocks.append(["== %s" % shown(p)] + ([notes[p]] if p in notes else []) + lines) + + sh = must(("rev-parse", "--is-shallow-repository")) + out = ["spec-delta — " + HEADER, + "head %s%s%s" % (head, " base %s" % base if base else "", + " shallow: a baseline commit may be missing" if sh.strip() == b"true" else ""), + "artifacts %d: %s" % (len(order), ", ".join("%s %d" % (k, v) for k, v in sorted(counts.items())) or "none"), + ""] + for b in blocks: + out += b + [""] + out.append(CANNOT) + sys.stdout.buffer.write(("\n".join(out) + "\n").encode("utf-8", "backslashreplace")) + return 0 + + +if __name__ == "__main__": + if hasattr(sys, "set_int_max_str_digits"): + sys.set_int_max_str_digits(0) + sys.exit(main(sys.argv)) +```` + +- [ ] **Step 4: Run the suite under both shells, reading each status** + +Run: `sh scripts/spec-delta.test.sh > /tmp/sd-sh.log 2>&1; echo "sh=$?"; dash scripts/spec-delta.test.sh > /tmp/sd-dash.log 2>&1; echo "dash=$?"; tail -1 /tmp/sd-sh.log /tmp/sd-dash.log` +Expected: `sh=0`, `dash=0`, and `16 passed, 0 failed` in both (measured on the prototype on 2026-10-03). + +- [ ] **Step 5: Lint the suite** + +Run: `shellcheck --shell=sh --exclude=SC2015 scripts/spec-delta.test.sh; echo "lint=$?"`. Expected: `lint=0`. + +No commit. + +--- + +### Task 2: Battery, CI and docs + +**Files:** Modify `AGENTS.md`, `.github/workflows/ci.yml`, `README.md`. + +- [ ] **Step 1: Find every place that counts the reports or suites** + +```sh +grep -rnE 'three Python reports|four Python reports|three untyped|six suites|seven suites|three reports|four reports' --include='*.md' --include='*.yml' . | grep -vE 'source-files/|docs/superpowers/|\.context/|hardening-log|CHANGELOG' +``` +Expected on 2026-10-03: `.github/workflows/ci.yml:40`, `AGENTS.md:71`, `AGENTS.md:265` and `README.md:163`. All four are edited below; any other hit is a stop. + +Then run `cat plugins/dev-workflow/.claude-plugin/plugin.json`, and confirm it declares no component keys. Run the census `grep -rniE 'declare[sd]?|convention[- ]load' --include='*.md' . | grep -v source-files/`. Read the hits in the edited files and confirm none becomes false; the edit only adds the fourth report. + +- [ ] **Step 2: Apply the edits** + +Save this as a temporary file outside the repository and run it from the repository root with `python3`. It writes nothing unless every replacement matches exactly once. + +```python +# Applies the Task 2 documentation and CI edits. Every replacement must match exactly +# once, or the script stops before writing anything. +import sys +edits = { + "AGENTS.md": [ + ("scripts/loop-usefulness.test.sh # its regression suite — fixture repo + store, expected text\n", + "scripts/loop-usefulness.test.sh # its regression suite — fixture repo + store, expected text\n" + "scripts/spec-delta.py # spec/plan change since a given baseline, for Gate B (vision step 2c, part 3)\n" + "scripts/spec-delta.test.sh # its regression suite — fixture repo, expected text\n"), + ("`scripts/check-version-bump.{sh,test.sh}`), plus three Python reports with their suites:\n" + "`scripts/ledger-metrics.{py,test.sh}` and `scripts/loop-usefulness.{py,test.sh}` (read-only),\n", + "`scripts/check-version-bump.{sh,test.sh}`), plus four Python reports with their suites:\n" + "`scripts/ledger-metrics.{py,test.sh}`, `scripts/loop-usefulness.{py,test.sh}` and\n" + "`scripts/spec-delta.{py,test.sh}` (read-only),\n"), + ("shellcheck --shell=sh --exclude=SC2015 scripts/loop-usefulness.test.sh && HOOK_SH=sh", + "shellcheck --shell=sh --exclude=SC2015 scripts/loop-usefulness.test.sh && shellcheck --shell=sh --exclude=SC2015 scripts/spec-delta.test.sh && HOOK_SH=sh"), + ("shellcheck --shell=sh --exclude=SC2015 scripts/loop-usefulness.test.sh` |", + "shellcheck --shell=sh --exclude=SC2015 scripts/loop-usefulness.test.sh && shellcheck --shell=sh --exclude=SC2015 scripts/spec-delta.test.sh` |"), + ("sh scripts/loop-usefulness.test.sh && claude plugin validate . --strict` |", + "sh scripts/loop-usefulness.test.sh && sh scripts/spec-delta.test.sh && claude plugin validate . --strict` |"), + ("| typecheck | n/a — no typed sources (shell, three untyped Python reports, markdown) |", + "| typecheck | n/a — no typed sources (shell, four untyped Python reports, markdown) |"), + ("`scripts/run-analytics.py` and `scripts/loop-usefulness.py`\nalso need git 2.36 or later and check for it.\n", + "`scripts/run-analytics.py`, `scripts/loop-usefulness.py` and\n`scripts/spec-delta.py` also need git 2.36 or later and check for it.\n"), + ], + ".github/workflows/ci.yml": [ + (" # The hook, the two checkers, the three Python reports and their six suites are\n", + " # The hook, the two checkers, the four Python reports and their seven suites are\n"), + (" --shell=sh --exclude=SC2015 scripts/loop-usefulness.test.sh\n", + " --shell=sh --exclude=SC2015 scripts/loop-usefulness.test.sh\n" + " # The spec-delta suite (vision step 2c, part 3), linted the same way.\n" + " docker run --rm -v \"$PWD:/mnt\" -w /mnt koalaman/shellcheck:v0.11.0 \\\n" + " --shell=sh --exclude=SC2015 scripts/spec-delta.test.sh\n"), + (" sh scripts/loop-usefulness.test.sh\n\n", + " sh scripts/loop-usefulness.test.sh\n sh scripts/spec-delta.test.sh\n\n"), + ], + "README.md": [ + ("both checkers' regression suites, the three reports' suites, and\n", + "both checkers' regression suites, the four reports' suites, and\n"), + ("nothing (`-B` keeps the interpreter from writing bytecode too).\n", + "nothing (`-B` keeps the interpreter from writing bytecode too).\n\n" + "`python3 -B scripts/spec-delta.py --base --baseline : … []` shows a\n" + "Gate-B reviewer how each relevant spec and plan changed since a baseline you name, usually the\n" + "commit that closed its Gate-A cycle. It quotes that commit's cycle records and names any\n" + "ambiguity. It does not claim the cycle reviewed exactly that file, and it informs without\n" + "obliging anything. It writes nothing.\n"), + ], +} +new = {} +for path, reps in edits.items(): + s = open(path).read() + for old, rep in reps: + n = s.count(old) + if n != 1: + sys.exit(f"STOP: {path}: expected exactly 1 match, found {n}: {old[:70]!r}") + s = s.replace(old, rep) + new[path] = s +for path, s in new.items(): + open(path, "w").write(s) +print("edited:", ", ".join(new)) +``` + +Expected: `edited: AGENTS.md, .github/workflows/ci.yml, README.md`. + +- [ ] **Step 3: Run the AGENTS.md quality row, verbatim** + +`sh -c '' > /tmp/sd-quality.log 2>&1; echo "quality=$?"`. Expected: `quality=0`, with `16 passed, 0 failed` from the new suite (measured on a copy on 2026-10-03). + +No commit. + +--- + +### Task 3: Evidence, Gate B with the report as input, close + +- [ ] **Step 1: Base check.** Run `git fetch origin; echo "fetch=$?"` and `git merge-base --is-ancestor origin/main HEAD; echo "anc=$?"`. Anything but `fetch=0` and `anc=0` is a stop: ask Daniel. + +- [ ] **Step 2: Stage and snapshot.** Run `git add scripts/spec-delta.py scripts/spec-delta.test.sh AGENTS.md .github/workflows/ci.yml README.md && git diff --cached --name-only`. Exactly those five paths must be listed. Then, as its own one-line tool call: `git commit -m 'WIP: spec delta candidate'`. + +- [ ] **Step 3: Evidence run.** + - Record `H=$(git rev-parse HEAD)` and `B=$(git rev-parse HEAD^)`, and check that `git status --porcelain --untracked-files=no` prints nothing. + - Check `test "$(git rev-parse main)" = "$(git rev-parse origin/main)"`. + - Run the quality row verbatim (exit 0), and `dash scripts/spec-delta.test.sh` (exit 0, `16 passed, 0 failed`). + - `git ls-tree 7a36b80 -- scripts/spec-delta.py` must print nothing. + - **Generate the real Gate-B input:** + `python3 -B scripts/spec-delta.py --base "$B" --plan docs/superpowers/plans/2026-10-03-spec-delta.md --baseline 7a36b80:docs/superpowers/specs/2026-10-03-spec-delta-design.md --baseline :docs/superpowers/plans/2026-10-03-spec-delta.md "$H" > /tmp/sd-input.txt; echo "exit=$?"` + Here `` is this plan's Gate-A closing commit. Expect exit 0, and "no change" for both artifacts. + - Repeat the HEAD and status checks. + +Evidence entry for the commit body: + +``` +Evidence — docs/superpowers/stories/2026-10-03-spec-delta-for-gate-b-story.md +Battery: AGENTS.md quality row, exit 0 at . +Check (counterfactual): at 7a36b80 no report exists (git ls-tree prints nothing). +Negative control in the suite: a copy that reads every **Spec:** line picks up a task-level +reference, and the suite catches it. Suite 16/16 under sh (in the quality row) and under dash. +Real Gate-B input: , at over ; header and state lines: +. The Gate-B calls carried this output in additionalContext. +``` + +- [ ] **Step 4: Gate B.** Follow CLAUDE.md §5: + - Draw a new nonce. Derive the floor and the lens sets from the story header at the call. On 2026-10-03 that was `standard`/`none`, giving floor 3 and no lens set. + - Check `mcp__codex__health` first; it must report `gpt-6-astra`. + - Use one `reviewType: full` call per pass, against the full `baseSha`/`headSha` above, with separate branch files. + - **Each call's `additionalContext` carries `/tmp/sd-input.txt` verbatim**, regenerated whenever `headSha` changes. + - Each call also carries the story path, the evidence entry, the twelve rulings, and the standing lens (report and suite counts, the AGENTS.md rows and prerequisites, the CI steps, README). + - Fixes: amend the WIP, rerun the evidence, regenerate the input, re-review. + +- [ ] **Step 5: Close and PR.** When the §5 closure ordering allows it: + - Run the evidence again. + - `git log --format='%h %s' origin/main..HEAD` must show the WIP commit over the plan's Gate-A closing commit, then `7a36b80`, `47db446`, `37350ca`, with nothing staged. Any other shape is a stop. + - Run `git commit --amend -m ""`, with the evidence entry, the provenance line, the curve and the logical-pass prose, and no trailers. + - Push, open a PR, and check that CI's log shows `16 passed, 0 failed`. + +## Self-review (2026-10-03) + +- **Spec coverage:** + - §2 → `parse_args`, `resolve`. + - §3 → `main`'s relevant set, `header_specs`, `read_plan`. + - §4 → `baseline_block` and the git hygiene in `git`. + - §5 → `main`'s report and `CANNOT`. + - §6 → the suite and Task 3's real input. +- **Story criteria:** + - 1 → the quoted records and the ambiguity line; + - 2 → the states; + - 3 → the union, `--plan`, `--spec`, and "baseline missing"; + - 4 → Task 3's Gate-B input and the fixture; + - 5 → `HEADER` and `CANNOT`, and the no-write test. +- **Placeholders:** ``, ``, ``, ``, `` and `` are filled in at run time. From c12a412f97f55f4435d85f57325973cfe8a24ea4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20S=C3=A4nger?= <20968534+dsnger@users.noreply.github.com> Date: Sat, 3 Oct 2026 17:30:51 +0200 Subject: [PATCH 5/6] Add spec-delta: compare specs and plans with a given baseline for Gate B (vision 2c, part 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/spec-delta.py shows a Gate-B reviewer, for every relevant spec and plan, the change from an explicitly given baseline commit to the candidate. Relevant artifacts are changed plans and specs, the specs a contributing plan's header cites, and every --spec and --baseline path. For each baseline, the report quotes that commit's cycle records verbatim and names any ambiguity: no matching record, several cycles, several same-kind files, or a likely squash. It claims no review attribution. A missing baseline stays visibly unknown. The report writes nothing and informs without obliging. scripts/spec-delta.test.sh (16 cases) joins the quality and lint rows, CI and the inventories in AGENTS.md and README.md. Evidence — docs/superpowers/stories/2026-10-03-spec-delta-for-gate-b-story.md Battery: AGENTS.md quality row, exit 0 at f8b8e3541428872828f2e4d6a166de7ee426b4fe. Check (counterfactual): at 7a36b80 no report exists (git ls-tree prints nothing). Negative control in the suite: a copy that reads every **Spec:** line picks up a task-level reference, and the suite catches it. Suite 16/16 under sh (in the quality row) and under dash. Real Gate-B input: python3 -B scripts/spec-delta.py --base 8b52829abe00d589db23e50aa327bbfb72ea82f0 --plan docs/superpowers/plans/2026-10-03-spec-delta.md --baseline 7a36b80:docs/superpowers/specs/2026-10-03-spec-delta-design.md --baseline 8b52829:docs/superpowers/plans/2026-10-03-spec-delta.md f8b8e3541428872828f2e4d6a166de7ee426b4fe — exit 0; "artifacts 2: no change 2". Gate-B passes 2 and 3 carried this output in additionalContext; pass 1 carried the same invocation's output for its own head. cycle hc7bzri2yb; floor 3 per {docs/superpowers/stories/2026-10-03-spec-delta-for-gate-b-story.md (level 1)}; hook reminder threshold absent cycle hc7bzri2yb; Gate B (passes 1-3, gpt-6-astra): Findings 3,2,1. Blockers 0,0,0. Majors 1,0,0. Each logical pass is one reviewType full call. Pass 1 ran against 8b52829.../8eb984f...; its Major (a ./ header citation was dropped) and a Minor (newlines in file names) were fixed by an amend. Passes 2 and 3 ran against 8b52829.../f8b8e35.... --- .github/workflows/ci.yml | 6 +- AGENTS.md | 17 +- README.md | 8 +- scripts/spec-delta.py | 318 +++++++++++++++++++++++++++++ scripts/spec-delta.test.sh | 397 +++++++++++++++++++++++++++++++++++++ 5 files changed, 737 insertions(+), 9 deletions(-) create mode 100644 scripts/spec-delta.py create mode 100644 scripts/spec-delta.test.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b5cf1c..6d7f31a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,7 +37,7 @@ jobs: # merge-base. ~50 commits, so the full history costs nothing here. fetch-depth: 0 - # The hook, the two checkers, the three Python reports and their six suites are + # The hook, the two checkers, the four Python reports and their seven suites are # the only executables here, # so lint plus those suites are the only mechanical gates we have. Everything else # here is a prompt, and prompts have no typechecker (see README, "Contributing"). @@ -82,6 +82,9 @@ jobs: # The loop-usefulness suite (vision step 2c, part 2), linted the same way. docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:v0.11.0 \ --shell=sh --exclude=SC2015 scripts/loop-usefulness.test.sh + # The spec-delta suite (vision step 2c, part 3), linted the same way. + docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:v0.11.0 \ + --shell=sh --exclude=SC2015 scripts/spec-delta.test.sh # Two runs, because the hook has to be correct under both shells and the runner's # /bin/sh is dash while a contributor's may be bash. HOOK_SH selects the shell the @@ -110,6 +113,7 @@ jobs: sh scripts/ledger-metrics.test.sh sh scripts/run-analytics.test.sh sh scripts/loop-usefulness.test.sh + sh scripts/spec-delta.test.sh # Invariant 12 itself needs a base to diff against, so it runs only on pull # requests — a push to main has no PR base, and diffing the push range would just diff --git a/AGENTS.md b/AGENTS.md index ce8cc82..8bedd11 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,8 @@ scripts/run-analytics.py # gate-call effort from local logs (vision ste scripts/run-analytics.test.sh # its regression suite — fixture home + repos, expected text scripts/loop-usefulness.py # warning light over recorded review cycles (vision step 2c, part 2) scripts/loop-usefulness.test.sh # its regression suite — fixture repo + store, expected text +scripts/spec-delta.py # spec/plan change since a given baseline, for Gate B (vision step 2c, part 3) +scripts/spec-delta.test.sh # its regression suite — fixture repo, expected text plugins/dev-workflow/ .claude-plugin/plugin.json # metadata only — no component keys (invariant 6) CHANGELOG.md # every manifest version, newest first @@ -68,8 +70,9 @@ source-files/ # the extraction seed this repo was built from **Boundaries.** `skills/`, `commands/`, `agents/` and `hooks/hooks.json` are loaded by convention from their paths. The executable artifacts are the hook and its test, plus the two repo-local CI checkers and their tests (`scripts/check-invariants.{sh,test.sh}` and -`scripts/check-version-bump.{sh,test.sh}`), plus three Python reports with their suites: -`scripts/ledger-metrics.{py,test.sh}` and `scripts/loop-usefulness.{py,test.sh}` (read-only), +`scripts/check-version-bump.{sh,test.sh}`), plus four Python reports with their suites: +`scripts/ledger-metrics.{py,test.sh}`, `scripts/loop-usefulness.{py,test.sh}` and +`scripts/spec-delta.{py,test.sh}` (read-only), and `scripts/run-analytics.{py,test.sh}` (writes only its own store under `.context/telemetry/`) — the hook ships in the plugin, the checkers and the reports do not; everything else is text read by a model. `examples/` is reference material, outside the loaded surface: never scaffolded or copied into a user's project, though it @@ -261,9 +264,9 @@ Every command below was run in this session and observed to exit 0. | Role | Command | |---|---| -| quality (the whole battery — what CI runs) | `shellcheck --shell=sh plugins/dev-workflow/hooks/codex-gate.sh && shellcheck --shell=sh --exclude=SC2015 plugins/dev-workflow/hooks/codex-gate.test.sh && shellcheck --shell=sh scripts/check-invariants.sh && shellcheck --shell=sh --exclude=SC2015 scripts/check-invariants.test.sh && shellcheck --shell=sh scripts/check-version-bump.sh && shellcheck --shell=sh scripts/check-version-bump.test.sh && shellcheck --shell=sh --exclude=SC2015 scripts/ledger-metrics.test.sh && shellcheck --shell=sh --exclude=SC2015 scripts/run-analytics.test.sh && shellcheck --shell=sh --exclude=SC2015 scripts/loop-usefulness.test.sh && HOOK_SH=sh sh plugins/dev-workflow/hooks/codex-gate.test.sh && HOOK_SH=dash dash plugins/dev-workflow/hooks/codex-gate.test.sh && sh scripts/check-invariants.test.sh && sh scripts/check-invariants.sh && sh scripts/check-version-bump.test.sh && sh scripts/check-version-bump.sh main && sh scripts/ledger-metrics.test.sh && sh scripts/run-analytics.test.sh && sh scripts/loop-usefulness.test.sh && claude plugin validate . --strict` | -| typecheck | n/a — no typed sources (shell, three untyped Python reports, markdown) | -| lint | `shellcheck --shell=sh plugins/dev-workflow/hooks/codex-gate.sh && shellcheck --shell=sh --exclude=SC2015 plugins/dev-workflow/hooks/codex-gate.test.sh && shellcheck --shell=sh scripts/check-invariants.sh && shellcheck --shell=sh --exclude=SC2015 scripts/check-invariants.test.sh && shellcheck --shell=sh scripts/check-version-bump.sh && shellcheck --shell=sh scripts/check-version-bump.test.sh && shellcheck --shell=sh --exclude=SC2015 scripts/ledger-metrics.test.sh && shellcheck --shell=sh --exclude=SC2015 scripts/run-analytics.test.sh && shellcheck --shell=sh --exclude=SC2015 scripts/loop-usefulness.test.sh` | +| quality (the whole battery — what CI runs) | `shellcheck --shell=sh plugins/dev-workflow/hooks/codex-gate.sh && shellcheck --shell=sh --exclude=SC2015 plugins/dev-workflow/hooks/codex-gate.test.sh && shellcheck --shell=sh scripts/check-invariants.sh && shellcheck --shell=sh --exclude=SC2015 scripts/check-invariants.test.sh && shellcheck --shell=sh scripts/check-version-bump.sh && shellcheck --shell=sh scripts/check-version-bump.test.sh && shellcheck --shell=sh --exclude=SC2015 scripts/ledger-metrics.test.sh && shellcheck --shell=sh --exclude=SC2015 scripts/run-analytics.test.sh && shellcheck --shell=sh --exclude=SC2015 scripts/loop-usefulness.test.sh && shellcheck --shell=sh --exclude=SC2015 scripts/spec-delta.test.sh && HOOK_SH=sh sh plugins/dev-workflow/hooks/codex-gate.test.sh && HOOK_SH=dash dash plugins/dev-workflow/hooks/codex-gate.test.sh && sh scripts/check-invariants.test.sh && sh scripts/check-invariants.sh && sh scripts/check-version-bump.test.sh && sh scripts/check-version-bump.sh main && sh scripts/ledger-metrics.test.sh && sh scripts/run-analytics.test.sh && sh scripts/loop-usefulness.test.sh && sh scripts/spec-delta.test.sh && claude plugin validate . --strict` | +| typecheck | n/a — no typed sources (shell, four untyped Python reports, markdown) | +| lint | `shellcheck --shell=sh plugins/dev-workflow/hooks/codex-gate.sh && shellcheck --shell=sh --exclude=SC2015 plugins/dev-workflow/hooks/codex-gate.test.sh && shellcheck --shell=sh scripts/check-invariants.sh && shellcheck --shell=sh --exclude=SC2015 scripts/check-invariants.test.sh && shellcheck --shell=sh scripts/check-version-bump.sh && shellcheck --shell=sh scripts/check-version-bump.test.sh && shellcheck --shell=sh --exclude=SC2015 scripts/ledger-metrics.test.sh && shellcheck --shell=sh --exclude=SC2015 scripts/run-analytics.test.sh && shellcheck --shell=sh --exclude=SC2015 scripts/loop-usefulness.test.sh && shellcheck --shell=sh --exclude=SC2015 scripts/spec-delta.test.sh` | | test | `HOOK_SH=sh sh plugins/dev-workflow/hooks/codex-gate.test.sh && HOOK_SH=dash dash plugins/dev-workflow/hooks/codex-gate.test.sh` — two runs; `HOOK_SH` selects the shell the HOOK runs under, and without it a dash invocation only exercises the harness | | invariant checks (5 pinning, 6 manifest, prompt conformance) | `sh scripts/check-invariants.test.sh && sh scripts/check-invariants.sh` | | invariant check (12 version bump) | `sh scripts/check-version-bump.test.sh && sh scripts/check-version-bump.sh main` | @@ -278,8 +281,8 @@ invariant 5; `dash` is addressed by name because it is the system shell, not a p tool. Without it the second run cannot start, and dropping that run is what let a `dash`-only defect ship once already. The metrics report's suite also needs **`python3`** 3.8 or later, addressed by name for the same reason as `dash`: it is the -system interpreter, not a pinned tool. `scripts/run-analytics.py` and `scripts/loop-usefulness.py` -also need git 2.36 or later and check for it. +system interpreter, not a pinned tool. `scripts/run-analytics.py`, `scripts/loop-usefulness.py` and +`scripts/spec-delta.py` also need git 2.36 or later and check for it. **The `--exclude=SC2015` on the test file** is a single-code exclusion, not a blanket disable: every other shellcheck rule still applies to that file. Its hits are all diff --git a/README.md b/README.md index b3dcb6f..9b1d7ff 100644 --- a/README.md +++ b/README.md @@ -160,7 +160,7 @@ CI ([`.github/workflows/ci.yml`](.github/workflows/ci.yml)) runs four checks on PR and push to main: `shellcheck --shell=sh` over the three shell executables and every shell test file, the hook's test suite, [`scripts/check-invariants.sh`](scripts/check-invariants.sh) (invariants 5 and 6, plus three prompt-conformance checks) plus -both checkers' regression suites, the three reports' suites, and +both checkers' regression suites, the four reports' suites, and `claude plugin validate . --strict`. `python3 scripts/ledger-metrics.py` prints a read-only report over the hardening ledger @@ -179,6 +179,12 @@ no warning where no threshold is reached, and not determinable where missing or data leaves the state open. It does not say whether a loop was worth its effort, and it writes nothing (`-B` keeps the interpreter from writing bytecode too). +`python3 -B scripts/spec-delta.py --base --baseline : … []` shows a +Gate-B reviewer how each relevant spec and plan changed since a baseline you name, usually the +commit that closed its Gate-A cycle. It quotes that commit's cycle records and names any +ambiguity. It does not claim the cycle reviewed exactly that file, and it informs without +obliging anything. It writes nothing. + A fifth check runs **on pull requests only**: [`scripts/check-version-bump.sh`](scripts/check-version-bump.sh) (invariant 12), which needs a base branch to diff against. Its *suite* runs unconditionally with the others; diff --git a/scripts/spec-delta.py b/scripts/spec-delta.py new file mode 100644 index 0000000..e002d65 --- /dev/null +++ b/scripts/spec-delta.py @@ -0,0 +1,318 @@ +#!/usr/bin/env python3 +"""Spec-delta: compare each relevant spec and plan with an explicitly given baseline. + +Spec: docs/superpowers/specs/2026-10-03-spec-delta-design.md +Usage: python3 -B scripts/spec-delta.py [--base ] [--plan ]... [--spec ]... + [--baseline :]... [] + +Read-only. It checks each baseline commit's Gate-A records and shows them verbatim, names any +ambiguity, and claims no review attribution: no record names the file a Gate-A cycle reviewed. +It informs a Gate-B reviewer and obliges nothing. Standard library only; Python 3.8+; git 2.36+. +""" +import sys + +sys.dont_write_bytecode = True # before any other import: the report writes nothing, .pyc included + +import importlib.util # noqa: E402 +import os # noqa: E402 +import posixpath # noqa: E402 +import re # noqa: E402 +import subprocess # noqa: E402 + +GIT_SELECTORS = ("GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_INDEX_FILE", + "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_CEILING_DIRECTORIES", "GIT_DISCOVERY_ACROSS_FILESYSTEM", "GIT_NAMESPACE") +NO_SIG = ("-c", "log.showSignature=false") +DIFF_CFG = ("-c", "diff.noprefix=false", "-c", "diff.mnemonicPrefix=false", "-c", "core.quotePath=true") +DIFF_FLAGS = ("--no-color", "--no-ext-diff", "--no-textconv", "--no-relative", "--text") +SPECS, PLANS = "docs/superpowers/specs/", "docs/superpowers/plans/" +RE_SPEC_PATH = re.compile(r"`((?:\./)?docs/superpowers/specs/[^`]+\.md)`") +HEADER = "compared with given baselines; Gate-A metadata checked; review attribution not established; " \ + "informs and obliges nothing" +CANNOT = """== What this report cannot establish +- Which file a Gate-A cycle reviewed: no record names it, so a baseline is the caller's claim. +- Whether a change was reviewed or intended, or whether a spec still matches the code. +- An original text lost to a squash merge: give the original closing commit, or the artifact stays unknown. +- Relevant artifacts outside the given and discovered set, and specs cited elsewhere than a plan's header line.""" + + +def shown(text): + return "".join("\\x%02x" % ord(c) if (ord(c) < 0x20 and c != "\t") or 0x7F <= ord(c) <= 0x9F else c + for c in str(text)) + + +def die(msg): + sys.stderr.buffer.write(("spec-delta: %s\n" % shown(msg)).encode("utf-8", "backslashreplace")) + sys.stderr.flush() + sys.exit(1) + + +ROOT = [None] # the repository root, set in main; every git call runs there + + +def git(args): + env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_TRACE")} + for k in GIT_SELECTORS + ("GIT_SHALLOW_FILE",): + env.pop(k, None) + env.update(GIT_TRACE2="0", GIT_TRACE2_EVENT="0", GIT_TRACE2_PERF="0", GIT_GRAFT_FILE=os.devnull, + GIT_LITERAL_PATHSPECS="1") + try: + p = subprocess.run(("git", "--no-replace-objects") + NO_SIG + tuple(args), env=env, cwd=ROOT[0], + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + except OSError: + return 127, b"" + return p.returncode, p.stdout + + +def must(args): + """A git read that has to succeed; any failure is exit 1, so nothing is reported from a partial read.""" + rc, out = git(args) + if rc != 0: + die("history could not be read") + return out + + +def resolve(ref): + rc, out = git(("rev-parse", "--verify", "--end-of-options", ref + "^{commit}")) + return out.decode().strip() if rc == 0 and out.strip() else None + + +def blob(commit, path): + """The blob id of path at commit, or None if no file entry is there; an unreadable object is exit 1.""" + entry = must(("ls-tree", "-z", commit, "--", path)).split(b"\0")[0] + meta, _, name = entry.partition(b"\t") + fields = meta.split() + if len(fields) != 3 or fields[1] != b"blob" or os.fsdecode(name) != path: + return None + oid = fields[2].decode() + must(("cat-file", "-e", oid)) + return oid + + +def kind_of(path): + return "Gate-A spec" if path.startswith(SPECS) else "Gate-A plan" if path.startswith(PLANS) else None + + +def load_parser(here): + spec = importlib.util.spec_from_file_location("ledger_metrics", os.path.join(here, "ledger-metrics.py")) + try: + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + except (OSError, SyntaxError, AttributeError): + die("the record parser scripts/ledger-metrics.py cannot be loaded") + return mod + + +def parse_args(argv): + opts = {"base": None, "plan": [], "spec": [], "baseline": {}, "head": "HEAD"} + rest, i = [], 0 + while i < len(argv): + a = argv[i] + if a in ("--base", "--plan", "--spec", "--baseline"): + if i + 1 >= len(argv): + die("%s needs a value" % a) + v = argv[i + 1] + i += 2 + if a == "--base": + opts["base"] = v + elif a == "--baseline": + ref, sep, path = v.partition(":") + if not sep or not ref or not path: + die("--baseline needs :") + path = norm(path) + if opts["baseline"].get(path, ref) != ref: + die("conflicting baselines for %s" % path) + opts["baseline"][path] = ref + else: + opts[a[2:]].append(norm(v)) + else: + rest.append(a) + i += 1 + if len(rest) > 1: + die("usage: spec-delta.py [--base ] [--plan ]... [--spec ]... " + "[--baseline :]... []") + if rest: + opts["head"] = rest[0] + return opts + + +def header_specs(text): + """Spec paths on a plan's header **Spec:** line, or None when there is no such line.""" + for line in text.split("\n"): + if line.startswith("## "): + return None + if line.startswith("**Spec:**"): + return list(dict.fromkeys(RE_SPEC_PATH.findall(line))) + return None + + +def read_plan(path, head, base): + for c in (head, base): + if c and blob(c, path): + return must(("cat-file", "blob", blob(c, path))).decode("utf-8", "replace") + return None + + +def norm(path): + """A repository-relative path in canonical form; one that leaves the repository is exit 1.""" + p = posixpath.normpath(path) + if p.startswith("/") or p == ".." or p.startswith("../") or p == ".": + die("path outside the repository: %s" % path) + return p + + +def baseline_block(path, ref, head, lm): + """Lines for one artifact with a baseline; returns (state, lines).""" + commit = resolve(ref) + if commit is None: + return "baseline commit not available — unknown", ["baseline: %s (does not resolve)" % shown(ref)] + lines = ["baseline: %s (%s)" % (shown(ref), commit)] + body = must(("log", "-1", "--format=%B", commit)) + kind = kind_of(path) + fields, gate_b = set(), False + records = [] + for line in body.decode("utf-8", "replace").split("\n"): + if not lm.CANDIDATE.match(line): + continue + parsed = lm.parse_record(line) + mark = "unparsed" + if parsed is not None: + field, typ, data = parsed + mark = "record" + if typ == "curve" and data.get("kind") == "Gate B": + gate_b = True + if field == "none (pre-rule)": + mark = "pre-rule record, identifies no cycle" + elif typ in ("curve", "skip") and kind and data.get("kind") == kind: + mark = "matching kind" + fields.add(field) + records.append(" [%s] %s" % (mark, shown(line))) + lines.append("records in the baseline commit (verbatim):" if records else "records in the baseline commit: none") + lines += records + out = must(("-c", "log.showRoot=true", "show", "--first-parent", "--no-renames", "--name-only", "--format=", + "-z", commit)) + changed = [p for p in out.decode("utf-8", "replace").split("\0") if p] + same_kind = [p for p in changed if kind and kind_of(p) == kind] + amb = [] + if not fields: + amb.append("no matching-kind record with a cycle nonce at the baseline") + if len(fields) > 1: + amb.append("matching-kind records name %d cycles: %s" % (len(fields), ", ".join(sorted(fields)))) + if len(same_kind) > 1: + amb.append("the baseline commit changes %d %s, so it does not settle which one a cycle reviewed" % ( + len(same_kind), "specs" if kind == "Gate-A spec" else "plans")) + if gate_b: + amb.append("the baseline commit also carries a Gate B record: it may be a squash of a later state") + lines.append("ambiguity: " + ("; ".join(amb) if amb else "none found")) + old = blob(commit, path) + if old is None: + return "baseline path absent", lines + ["the path is not a file at the baseline commit"] + new = blob(head, path) + if new == old: + return "no change", lines + if new is not None: + d = must(DIFF_CFG + ("diff",) + DIFF_FLAGS + (commit, head, "--", path)) + return "changed", lines + ["diff:"] + [shown(x) for x in d.decode("utf-8", "replace").rstrip("\n").split("\n")] + ns = must(("diff", "--find-renames", "--name-status", "-z", commit, head)) + parts = ns.decode("utf-8", "replace").split("\0") + target, i = None, 0 + while i < len(parts) - 1: + st = parts[i] + if st.startswith("R"): + if parts[i + 1] == path: + target = parts[i + 2] + i += 3 + elif st.startswith("C"): + i += 3 + else: + i += 2 + args = (commit, head, "--", path, target) if target else (commit, head, "--", path) + d = must(DIFF_CFG + ("diff", "--find-renames") + DIFF_FLAGS + args) + lines.append("renamed to %s" % shown(target) if target else "deleted at the candidate") + return "renamed or deleted", lines + ["diff:"] + [shown(x) for x in d.decode("utf-8", "replace").rstrip("\n").split("\n")] + + +def main(argv): + o = parse_args(argv[1:]) + rc, out = git(("--version",)) + m = re.search(rb"(\d+)\.(\d+)", out) if rc == 0 else None + if not m or (int(m.group(1)), int(m.group(2))) < (2, 36): + die("git 2.36 or later is required") + rc, top = git(("rev-parse", "--show-toplevel")) + if rc != 0 or not top.strip(): + die("not inside a git working tree") + ROOT[0] = os.fsdecode(top.rstrip(b"\n")) + rc, out = git(("config", "--get-regexp", r"^(extensions\.partialclone|remote\..*\.(promisor|partialclonefilter))$")) + if rc not in (0, 1): # 1 means no such key + die("the repository configuration cannot be read") + for line in out.decode("utf-8", "replace").splitlines(): + key, _, value = line.partition(" ") + if not key.endswith(".promisor") or value.strip().lower() not in ("false", "no", "off", "0"): + die("partial clones are not supported (reading history could fetch objects)") + head = resolve(o["head"]) + if head is None: + die("the ref does not resolve to a commit: %s" % o["head"]) + base = None + if o["base"] is not None: + base = resolve(o["base"]) + if base is None: + die("the ref does not resolve to a commit: %s" % o["base"]) + lm = load_parser(os.path.dirname(os.path.abspath(__file__))) + + changed = [] + if base: + out = must(("log", "--format=", "--name-only", "--no-renames", "--diff-merges=first-parent", "-z", + "%s..%s" % (base, head))) + net = must(("diff", "--name-only", "--no-renames", "-z", base, head)) # includes merge-only edits + changed = list(dict.fromkeys(p for p in (out + b"\0" + net).decode("utf-8", "replace").split("\0") if p)) + plans = list(dict.fromkeys(o["plan"] + [p for p in changed if p.startswith(PLANS) and p.endswith(".md")])) + relevant = dict.fromkeys(plans) + relevant.update(dict.fromkeys(p for p in changed if p.startswith(SPECS) and p.endswith(".md"))) + notes = {} + for p in plans: + text = read_plan(p, head, base) + if text is None: + notes[p] = "plan not readable at the candidate or the base; its specs are unknown" + continue + cited = header_specs(text) + if not cited: + notes[p] = "no spec header (no header **Spec:** line naming a spec path)" + continue + cited = list(dict.fromkeys(norm(c) for c in cited)) + notes[p] = "cites: " + shown(", ".join(cited)) + relevant.update(dict.fromkeys(cited)) + relevant.update(dict.fromkeys(o["spec"])) + relevant.update(dict.fromkeys(o["baseline"])) + + order = sorted(relevant, key=lambda p: (0 if p.startswith(SPECS) else 1 if p.startswith(PLANS) else 2, p)) + blocks, counts = [], {} + for p in order: + if p in o["baseline"]: + st, lines = baseline_block(p, o["baseline"][p], head, lm) + else: + st, lines = "baseline missing — unknown", [] + counts[st] = counts.get(st, 0) + 1 + if "diff:" in lines: + k = lines.index("diff:") + lines = lines[:k] + ["state: " + st] + lines[k:] + else: + lines = lines + ["state: " + st] + blocks.append(["== %s" % shown(p)] + ([notes[p]] if p in notes else []) + lines) + + sh = must(("rev-parse", "--is-shallow-repository")) + out = ["spec-delta — " + HEADER, + "head %s%s%s" % (head, " base %s" % base if base else "", + " shallow: a baseline commit may be missing" if sh.strip() == b"true" else ""), + "artifacts %d: %s" % (len(order), ", ".join("%s %d" % (k, v) for k, v in sorted(counts.items())) or "none"), + ""] + for b in blocks: + out += b + [""] + out.append(CANNOT) + sys.stdout.buffer.write(("\n".join(out) + "\n").encode("utf-8", "backslashreplace")) + return 0 + + +if __name__ == "__main__": + if hasattr(sys, "set_int_max_str_digits"): + sys.set_int_max_str_digits(0) + sys.exit(main(sys.argv)) diff --git a/scripts/spec-delta.test.sh b/scripts/spec-delta.test.sh new file mode 100644 index 0000000..34b34e4 --- /dev/null +++ b/scripts/spec-delta.test.sh @@ -0,0 +1,397 @@ +#!/bin/sh +# Regression suite for spec-delta.py. +# +# Spec: docs/superpowers/specs/2026-10-03-spec-delta-design.md §6. Builds a fixture repository whose +# commits carry Gate-A records and change specs and plans, runs the report with explicit baselines, +# and compares it with expected text. +set -u + +HERE="$(cd "$(dirname "$0")" && pwd)" +SCRIPT="$HERE/spec-delta.py" +LEDGER="$HERE/ledger-metrics.py" +pass_n=0; fail_n=0 +pass() { pass_n=$((pass_n + 1)); printf 'ok - %s\n' "$1"; } +fail() { fail_n=$((fail_n + 1)); printf 'FAIL - %s\n' "$1"; } + +work=$(mktemp -d) || work='' +if [ -z "$work" ] || [ ! -d "$work" ]; then + printf 'FAIL - could not create a temporary directory; refusing to run\n' >&2 + exit 1 +fi +trap 'rm -rf "$work"' EXIT +work=$(cd "$work" && pwd -P) + +GIT_CONFIG_GLOBAL=/dev/null; GIT_CONFIG_NOSYSTEM=1; GIT_ATTR_NOSYSTEM=1 +export GIT_CONFIG_GLOBAL GIT_CONFIG_NOSYSTEM GIT_ATTR_NOSYSTEM +unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES \ + GIT_CEILING_DIRECTORIES GIT_DEFAULT_HASH GIT_COMMON_DIR GIT_NAMESPACE \ + GIT_CONFIG_COUNT GIT_CONFIG_PARAMETERS GIT_REPLACE_REF_BASE GIT_SHALLOW_FILE \ + GIT_GRAFT_FILE GIT_NO_REPLACE_OBJECTS +tick=1700000000 +gitc() { + GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@example.com GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@example.com \ + GIT_AUTHOR_DATE="$tick +0000" GIT_COMMITTER_DATE="$tick +0000" \ + git -c commit.gpgsign=false -c init.defaultBranch=main "$@" +} +S=docs/superpowers/specs; PL=docs/superpowers/plans +# put FILE TEXT: write a file (creating its directory) +put() { mkdir -p "$(dirname "$1")"; printf '%b' "$2" > "$1"; } +# cm MESSAGE: commit everything with MESSAGE; prints nothing +cm() { tick=$((tick + 100)); printf '%b' "$1" > "$work/msg"; gitc add -A && gitc commit -q -F "$work/msg"; } +sha() { git rev-parse HEAD; } +prov() { printf "cycle %s; floor 3 per {docs/superpowers/stories/s-story.md (level 1)}; hook reminder threshold absent" "$1"; } +curve() { printf 'cycle %s; %s (passes 1-2, codex): Findings 2,0. Blockers 0,0. Majors 1,0.' "$1" "$2"; } + +mkrepo() { + rm -rf "${work:?}/repo"; mkdir -p "$work/repo/scripts" + cp "$SCRIPT" "$LEDGER" "$work/repo/scripts/" + ( + cd "$work/repo" || exit 1 + gitc init -q --template= --object-format=sha1 . + printf '.context/\n' > .gitignore + for f in a b c d e g h i j m n q r; do put $S/$f.md "# $f\\nline one\\n"; done + put $PL/p4.md "# p4\\n\\n**Spec:** \`./$S/m.md\`, \`$S/q.md\` — two citations, one with a baseline, one spelled with ./\\n" + put $PL/p1.md "# p1\\n\\n**Spec:** \`$S/a.md\`, \`$S/b.md\` — read the profile.\\n\\n## Task 1\\n\\n**Spec:** \`$S/task.md\`\\n" + put $PL/p2.md "# p2\\n\\nno header line here\\n" + put $PL/p3.md "# p3\\n\\n**Spec:** \`$S/f.md\`\\n" + cm 'base\n' + sha > "$work/c_base" + put $S/a.md "# a\\nline one\\nreviewed text\\n" + cm "close a\\n\\n$(prov aaaaaaaa)\\n$(curve aaaaaaaa 'Gate-A spec')\\n" + sha > "$work/c_closeA" + put $PL/p1.md "# p1\\n\\n**Spec:** \`$S/a.md\`, \`$S/b.md\` — read the profile.\\nreviewed plan\\n\\n## Task 1\\n\\n**Spec:** \`$S/task.md\`\\n" + cm "close p1\\n\\n$(prov pppppppp)\\n$(curve pppppppp 'Gate-A plan')\\n" + sha > "$work/c_closeP1" + put $S/g.md "# g\\nline two\\n"; put $S/h.md "# h\\nline two\\n" + cm "two cycles\\n\\n$(prov bbbbbbbb)\\n$(curve bbbbbbbb 'Gate-A spec')\\n$(curve cccccccc 'Gate-A spec')\\n$(curve dddddddd 'Gate-A plan')\\ncycle none (pre-rule); Gate-A spec (passes 1, codex): Findings 0. Blockers 0. Majors 0.\\ncycle zzzzzzzz; not a record\\n" + sha > "$work/c_two" + put $S/i.md "# i\\nsquashed\\n" + cm "squash\\n\\n$(prov eeeeeeee)\\n$(curve eeeeeeee 'Gate-A spec')\\n$(curve ffffffff 'Gate B')\\n" + sha > "$work/c_squash" + put $S/n.md "# n\\nskipped close\\n" + cm "skip close\\n\\n$(prov gggggggg)\\ncycle gggggggg; Gate-A spec: skipped (see skip reason)\\nSkip reason: trivial.\\n\\ncycle hhhhhhhh; Gate B: skipped (see skip reason)\\nSkip reason: trivial.\\n" + sha > "$work/c_skip" + sha > "$work/c_basepoint" + # ---- the reviewed range ---- + put $S/a.md "# a\\nline one\\nreviewed text\\nedited after the close\\n" + put $PL/p1.md "# p1\\n\\n**Spec:** \`$S/a.md\`, \`$S/b.md\` — read the profile.\\nreviewed plan\\nplan edited\\n\\n## Task 1\\n\\n**Spec:** \`$S/task.md\`\\n" + gitc mv $S/c.md $S/c2.md + git rm -q $S/d.md $PL/p3.md + put $S/e.md "# e\\nline one\\nchanged, cited by no plan\\n" + nl=$(printf 'li\nne'); printf 'x\n' > "$S/$nl.md" # a spec whose name contains a newline + cm 'candidate\n' + # a spec that only a merge resolution adds + gitc checkout -q -b side + put side.txt "side\\n"; cm 'side\n' + gitc checkout -q main + gitc merge -q --no-ff --no-commit side >/dev/null 2>&1 + put $S/o.md "# o\\nadded in the merge resolution\\n" + tick=$((tick + 100)); gitc add -A && gitc commit -q -m 'merge side' + # r.md is changed by one merge resolution and restored by another: only merge diffs show it + gitc checkout -q -b side2 "$(cat "$work/c_basepoint")"; put side2.txt "s2\\n"; cm 'side2\n' + gitc checkout -q -b side3 "$(cat "$work/c_basepoint")"; put side3.txt "s3\\n"; cm 'side3\n' + gitc checkout -q main + gitc merge -q --no-ff --no-commit side2 >/dev/null 2>&1 + put $S/r.md "# r\\nchanged in a merge\\n"; tick=$((tick + 100)); gitc add -A && gitc commit -q -m 'merge side2' + gitc merge -q --no-ff --no-commit side3 >/dev/null 2>&1 + put $S/r.md "# r\\nline one\\n"; tick=$((tick + 100)); gitc add -A && gitc commit -q -m 'merge side3' + sha > "$work/c_head" + ) +} + +raw() { (cd "$work/repo" && HOME="$work/home" python3 -B scripts/spec-delta.py "$@"); } +run() { raw "$@" 2>&1; } +snapshot() { python3 - "$1" <<'SNAP' +import hashlib, os, sys +for d, dirs, files in sorted(os.walk(sys.argv[1])): + for n in sorted(dirs + files): + p = os.path.join(d, n); st = os.lstat(p) + h = hashlib.sha256(open(p, "rb").read()).hexdigest() if os.path.isfile(p) and not os.path.islink(p) else "-" + print(os.path.relpath(p, sys.argv[1]), oct(st.st_mode), st.st_size, st.st_mtime_ns, h) +SNAP +} +expect() { # name, actual (expected on stdin) + printf '%s\n' "$2" > "$work/actual"; cat > "$work/expected" + if diff "$work/expected" "$work/actual" > "$work/diff"; then pass "$1"; else fail "$1"; sed 's/^/ /' "$work/diff"; fi +} +bad() { # name, expected message, args... — exit 1, the reason on stderr, nothing on stdout + name=$1; msg=$2; shift 2 + raw "$@" > "$work/bad.out" 2> "$work/bad.err"; s=$? + e=$(cat "$work/bad.err") + if [ "$s" -eq 1 ] && printf '%s' "$e" | grep -qF "spec-delta: $msg" && [ ! -s "$work/bad.out" ]; then pass "$name" + else fail "$name (exit $s: $e; stdout $(wc -c < "$work/bad.out") bytes)"; fi +} +mask() { sed -E 's/[0-9a-f]{40}/SHA/g; s/index [0-9a-f]+\.\.[0-9a-f]+/index X..Y/; s/^baseline: [0-9a-f]{7,40} /baseline: REF /'; } + +build() { rm -rf "${work:?}/home"; mkdir -p "$work/home"; mkrepo; } +# wargs CMD...: run CMD with the main run's arguments appended. +wargs() { + "$@" --base "$(cat "$work/c_basepoint")" --plan $PL/p2.md --plan $PL/p3.md --plan $PL/p4.md \ + --plan $PL/ghost.md --spec $S/j.md --baseline "$(cat "$work/c_skip"):$S/n.md" \ + --baseline "$(cat "$work/c_base"):$S/q.md" \ + --baseline "$(cat "$work/c_closeA"):$S/a.md" --baseline "$(cat "$work/c_base"):$S/b.md" \ + --baseline "$(cat "$work/c_base"):$S/c.md" --baseline "$(cat "$work/c_base"):$S/d.md" \ + --baseline "$(cat "$work/c_two"):$S/g.md" --baseline "$(cat "$work/c_squash"):$S/i.md" \ + --baseline "deadbeef:$S/k.md" --baseline "$(cat "$work/c_base"):$S/zz.md" \ + --baseline "$(cat "$work/c_closeP1"):$PL/p1.md" +} + +# ---- 1. the main report ----------------------------------------------------------------------------- +build +before=$(snapshot "$work/repo"; snapshot "$work/home") +out=$(wargs run); st=$? +[ "$st" -eq 0 ] && pass "main run exits 0" || fail "main run exit $st" +[ "$(snapshot "$work/repo"; snapshot "$work/home")" = "$before" ] && + pass "the run writes nothing (repository and HOME, ignored files included)" || fail "the run wrote a file" +expect "report" "$(printf '%s\n' "$out" | mask)" <<'EOF' +spec-delta — compared with given baselines; Gate-A metadata checked; review attribution not established; informs and obliges nothing +head SHA base SHA +artifacts 23: baseline commit not available — unknown 1, baseline missing — unknown 12, baseline path absent 1, changed 2, no change 5, renamed or deleted 2 + +== docs/superpowers/specs/a.md +baseline: SHA (SHA) +records in the baseline commit (verbatim): + [record] cycle aaaaaaaa; floor 3 per {docs/superpowers/stories/s-story.md (level 1)}; hook reminder threshold absent + [matching kind] cycle aaaaaaaa; Gate-A spec (passes 1-2, codex): Findings 2,0. Blockers 0,0. Majors 1,0. +ambiguity: none found +state: changed +diff: +diff --git a/docs/superpowers/specs/a.md b/docs/superpowers/specs/a.md +index X..Y 100644 +--- a/docs/superpowers/specs/a.md ++++ b/docs/superpowers/specs/a.md +@@ -1,3 +1,4 @@ + # a + line one + reviewed text ++edited after the close + +== docs/superpowers/specs/b.md +baseline: SHA (SHA) +records in the baseline commit: none +ambiguity: no matching-kind record with a cycle nonce at the baseline; the baseline commit changes 13 specs, so it does not settle which one a cycle reviewed +state: no change + +== docs/superpowers/specs/c.md +baseline: SHA (SHA) +records in the baseline commit: none +ambiguity: no matching-kind record with a cycle nonce at the baseline; the baseline commit changes 13 specs, so it does not settle which one a cycle reviewed +renamed to docs/superpowers/specs/c2.md +state: renamed or deleted +diff: +diff --git a/docs/superpowers/specs/c.md b/docs/superpowers/specs/c2.md +similarity index 100% +rename from docs/superpowers/specs/c.md +rename to docs/superpowers/specs/c2.md + +== docs/superpowers/specs/c2.md +state: baseline missing — unknown + +== docs/superpowers/specs/d.md +baseline: SHA (SHA) +records in the baseline commit: none +ambiguity: no matching-kind record with a cycle nonce at the baseline; the baseline commit changes 13 specs, so it does not settle which one a cycle reviewed +deleted at the candidate +state: renamed or deleted +diff: +diff --git a/docs/superpowers/specs/d.md b/docs/superpowers/specs/d.md +deleted file mode 100644 +index X..Y +--- a/docs/superpowers/specs/d.md ++++ /dev/null +@@ -1,2 +0,0 @@ +-# d +-line one + +== docs/superpowers/specs/e.md +state: baseline missing — unknown + +== docs/superpowers/specs/f.md +state: baseline missing — unknown + +== docs/superpowers/specs/g.md +baseline: SHA (SHA) +records in the baseline commit (verbatim): + [record] cycle bbbbbbbb; floor 3 per {docs/superpowers/stories/s-story.md (level 1)}; hook reminder threshold absent + [matching kind] cycle bbbbbbbb; Gate-A spec (passes 1-2, codex): Findings 2,0. Blockers 0,0. Majors 1,0. + [matching kind] cycle cccccccc; Gate-A spec (passes 1-2, codex): Findings 2,0. Blockers 0,0. Majors 1,0. + [record] cycle dddddddd; Gate-A plan (passes 1-2, codex): Findings 2,0. Blockers 0,0. Majors 1,0. + [pre-rule record, identifies no cycle] cycle none (pre-rule); Gate-A spec (passes 1, codex): Findings 0. Blockers 0. Majors 0. + [unparsed] cycle zzzzzzzz; not a record +ambiguity: matching-kind records name 2 cycles: bbbbbbbb, cccccccc; the baseline commit changes 2 specs, so it does not settle which one a cycle reviewed +state: no change + +== docs/superpowers/specs/i.md +baseline: SHA (SHA) +records in the baseline commit (verbatim): + [record] cycle eeeeeeee; floor 3 per {docs/superpowers/stories/s-story.md (level 1)}; hook reminder threshold absent + [matching kind] cycle eeeeeeee; Gate-A spec (passes 1-2, codex): Findings 2,0. Blockers 0,0. Majors 1,0. + [record] cycle ffffffff; Gate B (passes 1-2, codex): Findings 2,0. Blockers 0,0. Majors 1,0. +ambiguity: the baseline commit also carries a Gate B record: it may be a squash of a later state +state: no change + +== docs/superpowers/specs/j.md +state: baseline missing — unknown + +== docs/superpowers/specs/k.md +baseline: REF (does not resolve) +state: baseline commit not available — unknown + +== docs/superpowers/specs/li\x0ane.md +state: baseline missing — unknown + +== docs/superpowers/specs/m.md +state: baseline missing — unknown + +== docs/superpowers/specs/n.md +baseline: SHA (SHA) +records in the baseline commit (verbatim): + [record] cycle gggggggg; floor 3 per {docs/superpowers/stories/s-story.md (level 1)}; hook reminder threshold absent + [matching kind] cycle gggggggg; Gate-A spec: skipped (see skip reason) + [record] cycle hhhhhhhh; Gate B: skipped (see skip reason) +ambiguity: none found +state: no change + +== docs/superpowers/specs/o.md +state: baseline missing — unknown + +== docs/superpowers/specs/q.md +baseline: SHA (SHA) +records in the baseline commit: none +ambiguity: no matching-kind record with a cycle nonce at the baseline; the baseline commit changes 13 specs, so it does not settle which one a cycle reviewed +state: no change + +== docs/superpowers/specs/r.md +state: baseline missing — unknown + +== docs/superpowers/specs/zz.md +baseline: SHA (SHA) +records in the baseline commit: none +ambiguity: no matching-kind record with a cycle nonce at the baseline; the baseline commit changes 13 specs, so it does not settle which one a cycle reviewed +the path is not a file at the baseline commit +state: baseline path absent + +== docs/superpowers/plans/ghost.md +plan not readable at the candidate or the base; its specs are unknown +state: baseline missing — unknown + +== docs/superpowers/plans/p1.md +cites: docs/superpowers/specs/a.md, docs/superpowers/specs/b.md +baseline: SHA (SHA) +records in the baseline commit (verbatim): + [record] cycle pppppppp; floor 3 per {docs/superpowers/stories/s-story.md (level 1)}; hook reminder threshold absent + [matching kind] cycle pppppppp; Gate-A plan (passes 1-2, codex): Findings 2,0. Blockers 0,0. Majors 1,0. +ambiguity: none found +state: changed +diff: +diff --git a/docs/superpowers/plans/p1.md b/docs/superpowers/plans/p1.md +index X..Y 100644 +--- a/docs/superpowers/plans/p1.md ++++ b/docs/superpowers/plans/p1.md +@@ -2,6 +2,7 @@ + + **Spec:** `docs/superpowers/specs/a.md`, `docs/superpowers/specs/b.md` — read the profile. + reviewed plan ++plan edited + + ## Task 1 + + +== docs/superpowers/plans/p2.md +no spec header (no header **Spec:** line naming a spec path) +state: baseline missing — unknown + +== docs/superpowers/plans/p3.md +cites: docs/superpowers/specs/f.md +state: baseline missing — unknown + +== docs/superpowers/plans/p4.md +cites: docs/superpowers/specs/m.md, docs/superpowers/specs/q.md +state: baseline missing — unknown + +== What this report cannot establish +- Which file a Gate-A cycle reviewed: no record names it, so a baseline is the caller's claim. +- Whether a change was reviewed or intended, or whether a spec still matches the code. +- An original text lost to a squash merge: give the original closing commit, or the artifact stays unknown. +- Relevant artifacts outside the given and discovered set, and specs cited elsewhere than a plan's header line. +EOF + +# ---- 2. diff configuration does not change the report ---------------------------------------------- +(cd "$work/repo" && git config diff.noprefix true && git config diff.relative true && git config diff.external false && + git config core.quotePath false && git config color.ui always && git config diff.mnemonicPrefix true && + git config diff.upper.textconv 'tr a-z A-Z' && mkdir -p .git/info && printf '*.md diff=upper\n' > .git/info/attributes && + git config log.showSignature true) +cfg=$(wargs run) +[ "$cfg" = "$out" ] && pass "user diff configuration leaves the report unchanged" || fail "diff configuration changed the report" + +# ---- 3. failures ------------------------------------------------------------------------------------ +build +bad "unresolvable head" "the ref does not resolve to a commit" nosuchref +bad "unresolvable base" "the ref does not resolve to a commit" --base nosuchref +bad "conflicting baselines" "conflicting baselines for $S/a.md" --baseline "HEAD:$S/a.md" --baseline "HEAD~1:$S/a.md" +(cd "$work/repo" && git config extensions.partialClone origin) +bad "partial clone refused" "partial clones are not supported" + +# ---- 3b. explicit head, hostile environment, shallow history, missing parser, old git ------------ +build +old=$(run --baseline "$(cat "$work/c_closeA"):$S/a.md" "$(cat "$work/c_closeP1")") +printf '%s\n' "$old" | grep -q "^head $(cat "$work/c_closeP1")$" && + printf '%s\n' "$old" | grep -q '^state: no change$' && + pass "an explicit older head is compared, not HEAD" || fail "explicit head ignored" +plain=$(wargs run) +( + cd "$work/repo" || exit 1 + c=$(cat "$work/c_closeA") + git cat-file commit "$c" | sed 's/^cycle aaaaaaaa; Gate-A spec/cycle aaaaaaaa; Gate-A plan/' > "$work/fake" + git replace "$c" "$(git hash-object -t commit -w "$work/fake")" +) +mkdir -p "$work/other" && (cd "$work/other" && gitc init -q .) +printf '%s\n' "$(cat "$work/c_closeA")" > "$work/grafts" # would make c_closeA a root commit +hostile_run() { (cd "$work/repo/scripts" && GIT_DIR="$work/other/.git" GIT_TRACE="$work/trace" \ + GIT_TRACE2_EVENT="$work/trace2" GIT_GRAFT_FILE="$work/grafts" HOME="$work/home" python3 -B spec-delta.py "$@") 2>&1; } +hostile=$(wargs hostile_run) +[ "$hostile" = "$plain" ] && [ ! -e "$work/trace" ] && [ ! -e "$work/trace2" ] && + pass "from a subdirectory, with GIT_DIR, GIT_TRACE, a graft file and a replace ref: the same report, no trace written" || + fail "the environment changed the report or wrote a trace" +dot=$(run --baseline "$(cat "$work/c_closeA"):./$S/a.md") +printf '%s\n' "$dot" | grep -q "^== $S/a.md$" && printf '%s\n' "$dot" | grep -q '^state: changed$' && + pass "a ./ path is normalized" || fail "a ./ path was not normalized" +rm -rf "${work:?}/shallow"; git clone -q --depth 1 "file://$work/repo" "$work/shallow" 2>/dev/null +sh_out=$( (cd "$work/shallow" && HOME="$work/home" python3 -B scripts/spec-delta.py --spec $S/a.md) 2>&1) +printf '%s\n' "$sh_out" | grep -q 'shallow: a baseline commit may be missing' && + pass "a shallow clone is noted in the header" || fail "shallow clone not noted" +mkdir -p "$work/repo/tools" && cp "$work/repo/scripts/spec-delta.py" "$work/repo/tools/" +(cd "$work/repo" && HOME="$work/home" python3 -B tools/spec-delta.py) > "$work/bad.out" 2> "$work/bad.err"; s=$? +[ "$s" -eq 1 ] && grep -qF 'spec-delta: the record parser scripts/ledger-metrics.py cannot be loaded' "$work/bad.err" && + [ ! -s "$work/bad.out" ] && pass "a missing parser is exit 1" || fail "missing parser (exit $s)" +rm -rf "${work:?}/repo/tools" +mkdir -p "$work/shim" && printf '#!/bin/sh\nprintf "git version 2.30.0\\n"\n' > "$work/shim/git" && chmod +x "$work/shim/git" +(cd "$work/repo" && PATH="$work/shim:$PATH" HOME="$work/home" python3 -B scripts/spec-delta.py) > "$work/bad.out" 2> "$work/bad.err"; s=$? +[ "$s" -eq 1 ] && grep -qF 'spec-delta: git 2.36 or later is required' "$work/bad.err" && [ ! -s "$work/bad.out" ] && + pass "git older than 2.36 is exit 1" || fail "old git (exit $s)" +build +c=$(cat "$work/c_skip") # a commit inside the reviewed range +rm -f "$work/repo/.git/objects/$(printf '%s' "$c" | cut -c1-2)/$(printf '%s' "$c" | cut -c3-)" +bad "history that cannot be read" "history could not be read" --base "$(cat "$work/c_two")" --spec $S/a.md \ + --baseline "$(cat "$work/c_two"):$S/g.md" + +# ---- 4. negative control ---------------------------------------------------------------------------- +build +python3 - "$work/repo/scripts/spec-delta.py" "$work/repo/scripts/allspec.py" <<'PY' +import sys +s = open(sys.argv[1]).read() +i, j = s.index("def header_specs(text):"), s.index("def read_plan(") +mutant = ('def header_specs(text): # mutant: every **Spec:** line, header or not\n' + ' found = [p for line in text.split("\\n") if line.startswith("**Spec:**")\n' + ' for p in RE_SPEC_PATH.findall(line)]\n' + ' return list(dict.fromkeys(found)) or None\n\n\n') +open(sys.argv[2], "w").write(s[:i] + mutant + s[j:]) +PY +mutrun() { (cd "$work/repo" && HOME="$work/home" python3 -B scripts/allspec.py "$@") 2>&1; } +mut=$(wargs mutrun) +printf '%s\n' "$mut" | grep -q "^== $S/task.md$" && ! printf '%s\n' "$out" | grep -q "^== $S/task.md$" && + pass "negative control: reading every **Spec:** line picks up a task-level reference, and the suite catches it" || + fail "negative control not caught" + +printf '\n%d passed, %d failed\n' "$pass_n" "$fail_n" +[ "$fail_n" -eq 0 ] From 3adcbbc642be0b5b44b9aca6f721e3a0df68dab4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20S=C3=A4nger?= <20968534+dsnger@users.noreply.github.com> Date: Sat, 3 Oct 2026 18:34:30 +0200 Subject: [PATCH 6/6] Keep non-UTF-8 file names intact in spec-delta (PR 37 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit git's NUL-delimited path lists are now decoded with os.fsdecode, so a changed spec whose name is not valid UTF-8 keeps its bytes. It can be looked up again and deduplicated against a --baseline for the same path. The output escapes it. The suite adds such a spec, written through the index, with a baseline. Evidence — docs/superpowers/stories/2026-10-03-spec-delta-for-gate-b-story.md Battery: AGENTS.md quality row, exit 0 at 5aeb3b66d2d64816441d46baaf57d17eaacee466. Check (counterfactual): with scripts/spec-delta.py from c12a412, the extended fixture's report differs (the non-UTF-8 spec appears twice, once with a replaced name), and the report case fails; with the fix, 16/16 under sh and dash. Real Gate-B input: python3 -B scripts/spec-delta.py --base c12a412f97f55f4435d85f57325973cfe8a24ea4 --plan docs/superpowers/plans/2026-10-03-spec-delta.md --baseline 7a36b80:docs/superpowers/specs/2026-10-03-spec-delta-design.md --baseline 8b52829:docs/superpowers/plans/2026-10-03-spec-delta.md 5aeb3b66d2d64816441d46baaf57d17eaacee466 — exit 0; "artifacts 2: no change 2". cycle 0yfsl8xv5o; floor 3 per {docs/superpowers/stories/2026-10-03-spec-delta-for-gate-b-story.md (level 1)}; hook reminder threshold absent cycle 0yfsl8xv5o; Gate B (passes 1, gpt-6-astra): Findings 0. Blockers 0. Majors 0. The single logical pass was one reviewType full call against c12a412.../5aeb3b6...; both branches found nothing: the zero-finding exit. --- scripts/spec-delta.py | 7 ++++--- scripts/spec-delta.test.sh | 14 ++++++++++++-- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/scripts/spec-delta.py b/scripts/spec-delta.py index e002d65..2527fd9 100644 --- a/scripts/spec-delta.py +++ b/scripts/spec-delta.py @@ -191,7 +191,7 @@ def baseline_block(path, ref, head, lm): lines += records out = must(("-c", "log.showRoot=true", "show", "--first-parent", "--no-renames", "--name-only", "--format=", "-z", commit)) - changed = [p for p in out.decode("utf-8", "replace").split("\0") if p] + changed = [os.fsdecode(p) for p in out.split(b"\0") if p] # path bytes kept, as in main same_kind = [p for p in changed if kind and kind_of(p) == kind] amb = [] if not fields: @@ -214,7 +214,7 @@ def baseline_block(path, ref, head, lm): d = must(DIFF_CFG + ("diff",) + DIFF_FLAGS + (commit, head, "--", path)) return "changed", lines + ["diff:"] + [shown(x) for x in d.decode("utf-8", "replace").rstrip("\n").split("\n")] ns = must(("diff", "--find-renames", "--name-status", "-z", commit, head)) - parts = ns.decode("utf-8", "replace").split("\0") + parts = [os.fsdecode(x) for x in ns.split(b"\0")] target, i = None, 0 while i < len(parts) - 1: st = parts[i] @@ -264,7 +264,8 @@ def main(argv): out = must(("log", "--format=", "--name-only", "--no-renames", "--diff-merges=first-parent", "-z", "%s..%s" % (base, head))) net = must(("diff", "--name-only", "--no-renames", "-z", base, head)) # includes merge-only edits - changed = list(dict.fromkeys(p for p in (out + b"\0" + net).decode("utf-8", "replace").split("\0") if p)) + # os.fsdecode keeps a non-UTF-8 name's bytes, so it can be looked up again; shown() escapes it on output + changed = list(dict.fromkeys(os.fsdecode(p) for p in (out + b"\0" + net).split(b"\0") if p)) plans = list(dict.fromkeys(o["plan"] + [p for p in changed if p.startswith(PLANS) and p.endswith(".md")])) relevant = dict.fromkeys(plans) relevant.update(dict.fromkeys(p for p in changed if p.startswith(SPECS) and p.endswith(".md"))) diff --git a/scripts/spec-delta.test.sh b/scripts/spec-delta.test.sh index 34b34e4..8af9a11 100644 --- a/scripts/spec-delta.test.sh +++ b/scripts/spec-delta.test.sh @@ -95,6 +95,10 @@ mkrepo() { put $S/r.md "# r\\nchanged in a merge\\n"; tick=$((tick + 100)); gitc add -A && gitc commit -q -m 'merge side2' gitc merge -q --no-ff --no-commit side3 >/dev/null 2>&1 put $S/r.md "# r\\nline one\\n"; tick=$((tick + 100)); gitc add -A && gitc commit -q -m 'merge side3' + # a spec whose name is not valid UTF-8, added through the index (some file systems refuse the name) + blob=$(printf 'x\n' | git hash-object -w --stdin) + git update-index --add --cacheinfo "100644,$blob,$(printf '%s/bad\377.md' "$S")" + tick=$((tick + 100)); gitc commit -q -m 'non-UTF-8 name' sha > "$work/c_head" ) } @@ -128,7 +132,7 @@ build() { rm -rf "${work:?}/home"; mkdir -p "$work/home"; mkrepo; } wargs() { "$@" --base "$(cat "$work/c_basepoint")" --plan $PL/p2.md --plan $PL/p3.md --plan $PL/p4.md \ --plan $PL/ghost.md --spec $S/j.md --baseline "$(cat "$work/c_skip"):$S/n.md" \ - --baseline "$(cat "$work/c_base"):$S/q.md" \ + --baseline "$(cat "$work/c_base"):$S/q.md" --baseline "$(cat "$work/c_head"):$(printf '%s/bad\377.md' "$S")" \ --baseline "$(cat "$work/c_closeA"):$S/a.md" --baseline "$(cat "$work/c_base"):$S/b.md" \ --baseline "$(cat "$work/c_base"):$S/c.md" --baseline "$(cat "$work/c_base"):$S/d.md" \ --baseline "$(cat "$work/c_two"):$S/g.md" --baseline "$(cat "$work/c_squash"):$S/i.md" \ @@ -146,7 +150,7 @@ out=$(wargs run); st=$? expect "report" "$(printf '%s\n' "$out" | mask)" <<'EOF' spec-delta — compared with given baselines; Gate-A metadata checked; review attribution not established; informs and obliges nothing head SHA base SHA -artifacts 23: baseline commit not available — unknown 1, baseline missing — unknown 12, baseline path absent 1, changed 2, no change 5, renamed or deleted 2 +artifacts 24: baseline commit not available — unknown 1, baseline missing — unknown 12, baseline path absent 1, changed 2, no change 6, renamed or deleted 2 == docs/superpowers/specs/a.md baseline: SHA (SHA) @@ -172,6 +176,12 @@ records in the baseline commit: none ambiguity: no matching-kind record with a cycle nonce at the baseline; the baseline commit changes 13 specs, so it does not settle which one a cycle reviewed state: no change +== docs/superpowers/specs/bad\udcff.md +baseline: SHA (SHA) +records in the baseline commit: none +ambiguity: no matching-kind record with a cycle nonce at the baseline +state: no change + == docs/superpowers/specs/c.md baseline: SHA (SHA) records in the baseline commit: none