Skip to content

Commit 57398b6

Browse files
TMogdansclaude
andcommitted
feat: spec-change loopback — SDD 'change spec -> regenerate affected code'
Operationalises framework §5.1 for an existing feature. Same pipeline (driver unchanged); stations become amend-aware; tests are the change-propagation layer. - core/req-delta.ts reqDelta(old,new) -> {added,changed,removed} REQ-ids (TDD); cli/req-delta. - spec-to-tests (amend): per req-delta — added -> new .skip'd; changed -> edit test + RE-SKIP (keeps main green until code catches up); removed -> delete test. On the spec PR. - implement (amend): un-skip new+changed tests, update code, remove dead code for removed REQs. - verify-unskip is PR-type-aware (core/unskip.isSpecBranch): no-op on devloop/spec/* (spec-to-tests is the legit author there; vitest/trace/mutation + spec-review keep it honest), enforces unskip-only on devloop/<slug> (the implement seam). template passes the PR head branch. - specify (amend existing spec, stable REQ ids); loop skill (spec PR on devloop/spec/<slug>). - design doc docs/2026-06-21-spec-change-loopback-design.md; USAGE section. driver state machine unchanged. v0.4.0, 146 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RjNiTNchAZoTmkmovVngnR
1 parent a436541 commit 57398b6

21 files changed

Lines changed: 310 additions & 7 deletions

.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "devloop",
3-
"version": "0.3.1",
3+
"version": "0.4.0",
44
"description": "Generische agentische Dev-Loop-Kette: /devloop:specify|spec-to-tests|implement|critic|loop + Bindungs-Anker (Wächter-Vorbedingung, content-gebundene Stopps, CI-Required-Check). Projektübergreifend.",
55
"author": { "name": "Tobias Mogdans" }
66
}

USAGE.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,21 @@ Was passiert (Spec-PR-zuerst; der Driver gehorcht dem getesteten Kern, trifft ni
100100
> **Zwei PRs, zwei Mensch-Tore:** der **Spec-PR** (Schritt 5, Spec-Review §5.1) und der
101101
> **Implementierungs-PR** (Schritt 10, Merge-Stopp §9). Beide sind echte CODEOWNER-Reviews (Anker b).
102102
103+
### Eine Spec ändern (Rückschleife)
104+
105+
SDD: **erst die Spec ändern, dann den Code regenerieren.** Läuft als *dieselbe* Kette auf einem
106+
*bestehenden* Feature — die Stationen arbeiten amend-fähig:
107+
108+
1. `specify` lädt die `spec.md` und ändert gezielt die betroffenen `REQ-`-Kriterien (IDs **stabil** halten).
109+
2. `spec-to-tests` fasst per `req-delta` **genau** die geänderten REQs an: neu → `.skip`'t; **geändert →
110+
Test ändern + wieder `.skip`'en** (sonst rötet der jetzt-aktive Test `main`); entfernt → Test weg.
111+
3. Spec-PR auf `devloop/spec/<slug>` → Review → Merge.
112+
4. `implement` entskippt die neuen + geänderten Tests, zieht den **Code** nach und entfernt **toten
113+
Code** für entfernte REQs. An Tests weiterhin nur `.skip` entfernen.
114+
115+
Die **Tests sind die Change-Propagation:** geänderte Tests röten genau den betroffenen Code; `implement`
116+
macht sie grün. Beide Mensch-Tore + die Entskip-Naht gelten unverändert.
117+
103118
---
104119

105120
## 3. Die zwei Stopps — wie du freigibst (Anker b)

agents/devloop-implement.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ Du läufst in der **Sandbox** auf dem Dev-Rechner: Lichter aus (`--dangerously-s
2525
2. Implementiere minimal gegen die Tests/Spec. **Der volle Gate-Satz läuft hier lokal mit** (`vitest` · Stryker · Semgrep · Traceability) — nicht nur `tsc`/`biome`. Iteriere, bis lokal grün. Dieser lokale Lauf ist **advisorisch, nicht autoritativ** (er ist korrumpierbar) — er kauft *Tempo*, nicht *Vertrauen*.
2626
3. Erst wenn lokal grün: öffne einen **PR** auf den Feature-Branch (die Capability-Grenze; nie Push auf main). Das **Verdikt von Rang** kommt vom **geschützten Runner** (CI = Gate of Record), nicht von deinem lokalen Lauf.
2727

28+
## Spec-Änderung (Amend-Modus)
29+
30+
Liegt eine Spec-Änderung vor (der Spec-PR mit geänderten/neuen `.skip`'ten Tests ist schon auf `main`): **entskippe** die neuen + geänderten Tests, zieh den **Code** nach, bis sie grün sind, und **entferne toten Code** für entfernte REQs. An den Tests weiterhin **nur `.skip` entfernen** — die geänderten Assertions kamen von `spec-to-tests` (Spec-PR), du aktivierst sie nur (`verify-unskip` erzwingt das auf `devloop/<slug>`).
31+
2832
## Rückkante (äußere Schleife — CI)
2933

3034
CI re-runt die Gates autoritativ. Kommt ein rotes Verdikt als **Defektsignal** über den schmalen Rückkanal zurück (`gh pr checks`, `gh run view --log-failed`: Datei:Zeile:Regel / überlebende Mutante), behebe den **Defekt** — nicht das Signal. Den **geschützten Satz** dabei nie anfassen (CI-Protected-Set-Ratchet greift sonst). Stagniert es, übernimmt der Driver (frischer Kontext / Eskalation).

agents/devloop-spec-to-tests.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,18 @@ Für **jede** `REQ-<CTX>-<nr>`-ID **mindestens einen** Test, getaggt mit der ID
2525
- Markiere jeden mit **`.skip`** (Vitest-Familie: `test.skip`/`it.skip`/`describe.skip`). So zählt das Trace-Gate sie als Abdeckung, aber Vitest rötet nicht (red-before-green), solange der Code fehlt → der Spec-PR hält `main` grün.
2626
- **Du schreibst KEINEN Produktcode.** Nur Tests. `implement` darf später **ausschließlich das `.skip` entfernen** — nie deine Assertions/Titel ändern. Genau deshalb müssen deine Tests jetzt vollständig sein: was du nicht schreibst, kann `implement` nicht hinzufügen, ohne die Gewaltenteilung zu brechen (maschinell geprüft von `verify-unskip`).
2727

28+
## Spec-Änderung (Amend-Modus)
29+
30+
Ändert sich eine *bestehende* Spec, fasst du **nur die betroffenen REQs** an — nicht das ganze Feature. Hol das Delta deterministisch:
31+
```
32+
node "${CLAUDE_PLUGIN_ROOT}"/dist/cli/req-delta.js <alte-spec> <neue-spec> # {added, changed, removed}
33+
```
34+
(alte Spec: `git show <base>:<spec.md>`). Dann je Fall:
35+
- **added** → neuen Test, `.skip`'t (wie oben).
36+
- **changed** → den bestehenden Test (gleiche `REQ-`-ID) ändern **und `.skip` wieder setzen**. Sonst rötet der jetzt-aktive Test gegen den (noch alten) Code `main` beim Spec-PR-Merge. `implement` entskippt ihn später, nachdem der Code nachgezogen ist.
37+
- **removed** → den Test entfernen (sonst rote verwaiste `REQ-`-Referenz im Trace-Gate).
38+
Unveränderte Tests **nicht** anfassen (sie bleiben aktiv und grün). Das läuft alles auf dem **Spec-PR** (`devloop/spec/<slug>`); dort darfst du Tests autoren/ändern/re-skippen — `verify-unskip` greift dort nicht.
39+
2840
## Grenzen
2941

3042
- Du bist **nicht** die Implement-Station; deine Unabhängigkeit von ihr ist der Sinn der Trennung.

agents/devloop-specify.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ Du erzeugst die **`spec.md`** — die Wurzel des Vertrauens (§5.1). Alles Spät
2020
(Liegt die Map an einem ungewöhnlichen Ort, alternativ `{"touched":[...],"tierMap":<inhalt>}` durchreichen.) Schreibe das Ergebnis als `Tier:`-Feld in die `spec.md`. **Achtung:** dieses Tier ist **vorläufig/advisorisch** — es steuert nur Critic-Tiefe und Stopp-Strenge in der inneren Schleife. **Autoritativ** wird das Tier **aus dem tatsächlichen Diff auf CI** berechnet (§9/§10, derselbe `derive-tier` server-seitig), nie aus deiner Deklaration. Du kannst das Tier also nicht herunterspielen.
2121
3. Schreibe `spec.md` (User Story, Tier, Liste der `REQ-`-Kriterien mit EARS-Typ-Tag).
2222

23+
## Spec-Änderung (Amend-Modus)
24+
25+
Existiert die `spec.md` schon (Feature wird geändert, nicht neu angelegt): **lade sie und ändere gezielt** die betroffenen `REQ-`-Kriterien (ändern/hinzufügen/entfernen) — `REQ-`-IDs **stabil halten** (geänderter REQ behält seine ID, damit `spec-to-tests`/Trace die Zuordnung halten). Tier neu ableiten. Der Rest der Kette läuft identisch (Spec-PR → Review → implement); die Stationen erkennen die Änderung am Delta.
26+
2327
## Grenzen
2428

2529
- **Kein Produktcode, keine Tests.** Nur die Spec.

bin/devloop

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const ALLOWED = [
1616
"verify-review",
1717
"verify-unskip",
1818
"check-codeowners",
19+
"req-delta",
1920
"cleanup",
2021
];
2122
const [sub, ...rest] = process.argv.slice(2);

dist/cli/req-delta.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// CLI: which REQs changed between two spec.md versions -> {added, changed, removed}. spec-to-tests
2+
// uses it to amend exactly the affected tests on a spec change (surgical re-derivation).
3+
// Usage: req-delta <oldSpecPath> <newSpecPath> (oldSpecPath may be absent -> treated as empty).
4+
import { readFileSync } from "node:fs";
5+
import { reqDelta } from "../core/req-delta.js";
6+
const [, , oldPath, newPath] = process.argv;
7+
if (!newPath) {
8+
process.stderr.write("usage: req-delta <oldSpecPath> <newSpecPath>\n");
9+
process.exit(64);
10+
}
11+
const readOr = (p, fallback) => {
12+
try {
13+
return p ? readFileSync(p, "utf8") : fallback;
14+
}
15+
catch {
16+
return fallback;
17+
}
18+
};
19+
process.stdout.write(JSON.stringify(reqDelta(readOr(oldPath, ""), readOr(newPath, "")), null, 2) + "\n");

dist/cli/verify-unskip.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,16 @@
55
//
66
// Usage: verify-unskip <repoPath> <baseRef> (baseRef e.g. origin/main)
77
import { execFileSync } from "node:child_process";
8-
import { isAllowedTestEdit } from "../core/unskip.js";
8+
import { isAllowedTestEdit, isSpecBranch } from "../core/unskip.js";
99
const repo = process.argv[2] ?? ".";
1010
const base = process.argv[3] ?? "origin/main";
11+
const headBranch = process.argv[4] ?? "";
12+
// Spec-PR: spec-to-tests is the legitimate test author here (gated by spec-review + vitest +
13+
// mutation). The unskip seam applies only to the Impl-PR. No-op pass on devloop/spec/* branches.
14+
if (isSpecBranch(headBranch)) {
15+
process.stdout.write(JSON.stringify({ ok: true, skipped: "spec-PR (devloop/spec/*)" }) + "\n");
16+
process.exit(0);
17+
}
1118
const gitSafe = (args) => {
1219
try {
1320
return execFileSync("git", ["-C", repo, ...args], { encoding: "utf8" });

dist/core/req-delta.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// Spec-change delta: which EARS criteria (REQ-<CTX>-<nr>) were added, changed, or removed
2+
// between two spec.md versions. Lets spec-to-tests touch exactly the affected REQs (surgical
3+
// re-derivation) instead of the whole feature (design: spec-change loopback, §5.1).
4+
//
5+
// One criterion per line (the EARS convention): a line carrying a REQ id maps that id to its
6+
// criterion text (everything after the id, minus an optional ":" / "-" / "—" separator).
7+
// Comparison is whitespace-normalised.
8+
const REQ_LINE = /\b(REQ-[A-Z0-9]+-\d+)\b\s*[:\-]?\s*(.*)$/;
9+
const normalize = (s) => s.trim().replace(/\s+/g, " ");
10+
function parseReqs(spec) {
11+
const reqs = new Map();
12+
for (const line of spec.split("\n")) {
13+
const m = line.match(REQ_LINE);
14+
if (m)
15+
reqs.set(m[1], normalize(m[2]));
16+
}
17+
return reqs;
18+
}
19+
export function reqDelta(oldSpec, newSpec) {
20+
const before = parseReqs(oldSpec);
21+
const after = parseReqs(newSpec);
22+
const delta = { added: [], changed: [], removed: [] };
23+
for (const [id, text] of after) {
24+
if (!before.has(id))
25+
delta.added.push(id);
26+
else if (before.get(id) !== text)
27+
delta.changed.push(id);
28+
}
29+
for (const id of before.keys()) {
30+
if (!after.has(id))
31+
delta.removed.push(id);
32+
}
33+
return delta;
34+
}

dist/core/unskip.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
// isUnskipOnly(old, new) is true iff `new` can be produced from `old` by DELETING zero or more
66
// `.skip` tokens — nothing else. Editing an assertion/title, adding/removing a test, or ADDING
77
// a `.skip` anywhere (sneaking a test off — even while unskipping another) is forbidden.
8+
// Spec-PRs (devloop/spec/<slug>) are where spec-to-tests legitimately authors/amends/re-skips
9+
// tests; the unskip seam constrains only the Impl-PR. The driver controls the branch prefix.
10+
export const isSpecBranch = (branch) => branch.startsWith("devloop/spec/");
811
const SKIP = ".skip";
912
const isWordChar = (c) => c !== undefined && /\w/.test(c);
1013
// A `.skip` token only counts at a word boundary (so `.skipped` is not treated as `.skip`).

0 commit comments

Comments
 (0)