Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
9deaa46
docs(ledger): REQ-LEDGER-11 — Runde ohne offene blocker/major wird ni…
TMogdans Nov 13, 2026
9a29bef
test(ledger): Verträge für Befund-Ledger, Critic-Verdikt und Draft-Bi…
TMogdans Nov 13, 2026
6b8c333
feat(ledger): Befund-Ledger, Critic-Verdikt im Kern, Draft-Bindung (s…
TMogdans Nov 13, 2026
c6e6341
docs(ledger): Prosa an Ledger, critic-verdict und Draft/Ready-Bindung…
TMogdans Nov 13, 2026
9b06139
test(resume): facts() mit draft:false, Typ-Waechter gegen optionales …
TMogdans Nov 13, 2026
00ff23c
Merge Testbranch (draft-Pflichtfeld-Wächter) in feat/befund-ledger
TMogdans Nov 13, 2026
f8371f9
fix(resume): PrFacts.draft Pflichtfeld, pr-state fail-closed bei fehl…
TMogdans Nov 13, 2026
13b4b9c
test(resume): Kommentarzeile darf keine ts-expect-error-Direktive sein
TMogdans Nov 13, 2026
04d8c42
Merge Testbranch (Kommentarfix Typ-Wächter)
TMogdans Nov 13, 2026
44a490e
test: pr-state-Verhaltenstest, Escape-/Key-Luecken, Vorher-Nachweis a…
TMogdans Nov 14, 2026
4d14324
Merge Testbranch (pr-state-Tests, Escapes, gepinnter Vorher-Nachweis)
TMogdans Nov 14, 2026
4533331
test(critic): ungerade Zahl escapter Quotes toetet o18, Matrix korrig…
TMogdans Nov 14, 2026
d7739c9
docs(matrix): o18 korrigiert
TMogdans Nov 14, 2026
f4abc44
Merge Testbranch (o18-Killer, Matrix)
TMogdans Nov 14, 2026
686ee22
docs(spec): Nachtrag REQ-GATE-1..3 (Gate-Runden in den Ledger), Probe…
TMogdans Nov 14, 2026
fa61255
Merge Spec-Nachtrag REQ-GATE
TMogdans Nov 14, 2026
7475f77
test: Gate-Runden im Kern (REQ-GATE-1..3), Proben (m) und (n)
TMogdans Nov 14, 2026
69e10c9
Merge Tests REQ-GATE-1..3
TMogdans Nov 14, 2026
53171fc
feat: Gate-Runden im Kern (REQ-GATE-1..3)
TMogdans Nov 14, 2026
c63cdb7
docs(matrix): Proben m/n fuer REQ-GATE nachgemessen
TMogdans Nov 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions agents/devloop-critic.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,20 @@ Du bist die adversariale Linse (Säule 3). Dein Auftrag ist **nicht** zu bestät

## Output: strukturiertes Verdikt

Liefere ein maschinenlesbares Verdikt, z.B.:
Liefere ein maschinenlesbares JSON-Objekt. Jeder Befund hat **alle** Pflichtfelder (REQ-KEY-2):
```json
{ "verdict": "rejected" | "accepted",
"findings": [ { "req": "REQ-AUTH-1", "severity": "high", "claim": "...", "evidence": "datei:zeile" } ] }
{ "findings": [ {
"req": "REQ-AUTH-1",
"klasse": "req-uncovered",
"datei": "src/auth.ts",
"severity": "blocker",
"claim": "...",
"evidence": "src/auth.ts:42"
} ] }
```
Im Zweifel **rejected** mit konkretem Befund. Ein leeres „sieht gut aus" ist kein Verdikt.
- `req`: betroffene REQ-ID oder `-`.
- `klasse` (Enum): `req-uncovered` | `happy-path-only` | `spec-deviation` | `convention` | `security` | `other`.
- `datei`: eine Datei (Pfad). `req` + `klasse` + `datei` bilden die Befund-Identität über Runden — kein Zeilennummer-/Freitext-Bestandteil; die Zeile gehört nach `evidence`.
- `severity`: `blocker` | `major` | `nit`.

Ein Selbst-Label (`verdict`) ist **unerheblich**: Der Kern ignoriert es und **leitet** das Verdikt aus dem Ledger ab (`accepted` genau dann, wenn kein blocker/major offen ist). Fehlt ein Pflichtfeld, ist ein Enum verletzt oder ist die Ausgabe kein JSON, gilt der Lauf als `not-run` — nie als „keine Befunde". Ein leeres „sieht gut aus" ist kein Verdikt; ohne Befunde gib ausdrücklich `{ "findings": [] }` aus.
4 changes: 2 additions & 2 deletions agents/devloop-implement.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,12 @@ Du läufst in der **Sandbox** auf dem Dev-Rechner: Lichter aus (`--dangerously-s
nie auf `main` und nie im geteilten Arbeitsbaum — so laufen parallele Sessions kollisionsfrei.
Gemergte Branches/Worktrees räumt `/devloop:cleanup` später sicher auf.
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*.
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.
3. Erst wenn lokal grün: öffne einen **PR** auf den Feature-Branch (die Capability-Grenze; nie Push auf main) — als **Draft** (`gh pr create --draft`, REQ-BIND-1). Erst der Driver setzt ihn nach akzeptiertem Critic auf „ready"; ein Nicht-Draft-PR würde bei T0/T1 den Auto-Merge vor dem Critic armieren. Das **Verdikt von Rang** kommt vom **geschützten Runner** (CI = Gate of Record), nicht von deinem lokalen Lauf.

## Spec-Änderung (Amend-Modus)

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>`).

## Rückkante (äußere Schleife — CI)

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).
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). Kommen **Critic-Befunde** (Schlüssel `review:<REQ>:<klasse>:<datei>`) zurück, melde in deinem Ergebnis **je adressiertem Befund** den Key **und** `datei:zeile` der Korrektur (REQ-IMPL-1) — der Driver führt sie als `fixed-claimed` (unverifiziert); nichts zu melden heißt nichts adressiert. Stagniert es, übernimmt der Driver (frischer Kontext / Eskalation).
27 changes: 27 additions & 0 deletions dist/cli/critic-verdict.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// CLI: raw critic output(s) -> validated status, ledger round, DERIVED verdict (REQ-CRITIC-1/2/5).
// stdin: { rawOutputs: string[], ledger: Ledger, addressed: string[] }. Any `status` / `verdict`
// in the input is ignored — status comes from schema validation of rawOutputs, the verdict from
// the ledger.
import { mergeCriticRuns, parseCriticOutput } from "../core/critic.js";
import { applyRound, deriveVerdict } from "../core/ledger.js";
import { readStdin } from "./_stdin.js";
let input = {};
try {
input = JSON.parse(await readStdin());
}
catch {
/* unparseable input -> not-run below */
}
const raws = Array.isArray(input.rawOutputs) ? input.rawOutputs : [];
const runs = raws.map((r) => parseCriticOutput(typeof r === "string" ? r : ""));
const merged = mergeCriticRuns(runs);
if (merged.status !== "completed") {
process.stdout.write(JSON.stringify({ status: "not-run" }) + "\n");
}
else {
const addressed = Array.isArray(input.addressed)
? input.addressed.filter((k) => typeof k === "string")
: [];
const round = applyRound(input.ledger ?? {}, { findings: merged.findings, addressed });
process.stdout.write(JSON.stringify({ status: "completed", verdict: deriveVerdict(round.ledger), round }) + "\n");
}
24 changes: 24 additions & 0 deletions dist/cli/gate-round.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// CLI: structured gate findings -> ledger round (REQ-GATE-1..3).
// stdin: { raws, ran, ledger, addressed }. Invalid input -> message on stderr, exit 2, nothing
// on stdout. Only these four fields are read; judged/progress/oscillation come from the ledger.
import { applyGateRound, parseGateRoundInput } from "../core/gate-round.js";
import { readStdin } from "./_stdin.js";
function fail(msg) {
process.stderr.write(`gate-round: ${msg}\n`);
process.exit(2);
}
let parsed;
try {
parsed = JSON.parse(await readStdin());
}
catch {
fail("stdin is not valid JSON");
}
const input = parseGateRoundInput(parsed);
if (!input)
fail("invalid input: need raws[], ran[], addressed[] with known gate kinds and required fields");
const ledger = parsed.ledger;
if (typeof ledger !== "object" || ledger === null || Array.isArray(ledger))
fail("invalid input: ledger must be an object");
const round = applyGateRound(ledger, input);
process.stdout.write(JSON.stringify({ round }) + "\n");
7 changes: 6 additions & 1 deletion dist/cli/pr-state.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,12 @@ if (!pr) {
process.stderr.write("usage: pr-state <repoPath> <prNumber>\n");
process.exit(64);
}
const raw = execFileSync("gh", ["pr", "view", pr, "--json", "headRefName,state,reviewDecision,statusCheckRollup"], { cwd: repo, encoding: "utf8" });
const raw = execFileSync("gh", ["pr", "view", pr, "--json", "headRefName,state,reviewDecision,statusCheckRollup,isDraft"], { cwd: repo, encoding: "utf8" });
const v = JSON.parse(raw);
if (typeof v.isDraft !== "boolean") {
process.stderr.write("pr-state: gh returned no boolean isDraft; refusing to guess (fail-closed)\n");
process.exit(1);
}
const reviewMap = {
APPROVED: "approved",
CHANGES_REQUESTED: "changes-requested",
Expand All @@ -20,5 +24,6 @@ const facts = {
merged: v.state === "MERGED",
reviewDecision: reviewMap[v.reviewDecision ?? ""] ?? "none",
checks: rollupStatus(v.statusCheckRollup ?? []),
draft: v.isDraft,
};
process.stdout.write(JSON.stringify({ pr: Number(pr), facts, reconstructed: reconstructState(facts) }, null, 2) + "\n");
93 changes: 93 additions & 0 deletions dist/core/critic.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// Critic output parsing + merging (REQ-KEY-2, REQ-CRITIC-2/3). The status comes ONLY from
// validating the raw output against the schema — never from a field the caller (or the critic)
// supplies. Anything that does not validate is "not-run", never "completed without findings".
import { CRITIC_KLASSEN, criticFindingKey } from "./finding-key.js";
const NOT_RUN = { status: "not-run", findings: [] };
const SEVERITIES = ["blocker", "major", "nit"];
const RANK = { nit: 0, major: 1, blocker: 2 };
// End index (exclusive) of the balanced JSON object starting at `start`, string-aware; -1 if none.
function objectEnd(s, start) {
let depth = 0;
let inStr = false;
for (let i = start; i < s.length; i++) {
const c = s[i];
if (inStr) {
if (c === "\\")
i++;
else if (c === '"')
inStr = false;
}
else if (c === '"')
inStr = true;
else if (c === "{")
depth++;
else if (c === "}" && --depth === 0)
return i + 1;
}
return -1;
}
// First JSON object in the text that has a `findings` field (text/fences around it are fine).
function findVerdictObject(raw) {
let i = raw.indexOf("{");
while (i !== -1) {
const end = objectEnd(raw, i);
if (end !== -1) {
try {
const v = JSON.parse(raw.slice(i, end));
if (v && typeof v === "object" && !Array.isArray(v)) {
if ("findings" in v)
return v;
i = raw.indexOf("{", end);
continue;
}
}
catch {
/* not JSON -> try the next brace */
}
}
i = raw.indexOf("{", i + 1);
}
return undefined;
}
function toFinding(x) {
if (!x || typeof x !== "object")
return undefined;
const f = x;
const { req, klasse, datei, severity, claim, evidence } = f;
if (typeof req !== "string" || req === "")
return undefined;
if (typeof datei !== "string" || datei === "")
return undefined;
if (typeof claim !== "string" || typeof evidence !== "string")
return undefined;
if (typeof klasse !== "string" || !CRITIC_KLASSEN.includes(klasse))
return undefined;
if (typeof severity !== "string" || !SEVERITIES.includes(severity))
return undefined;
return { key: criticFindingKey({ req, klasse, datei }), source: "critic", severity: severity };
}
export function parseCriticOutput(raw) {
const obj = findVerdictObject(raw);
if (!obj || !Array.isArray(obj.findings))
return NOT_RUN;
const findings = [];
for (const x of obj.findings) {
const f = toFinding(x);
if (!f)
return NOT_RUN;
findings.push(f);
}
return { status: "completed", findings };
}
export function mergeCriticRuns(runs) {
if (runs.length === 0 || runs.some((r) => r.status !== "completed"))
return NOT_RUN;
const byKey = new Map();
for (const r of runs)
for (const f of r.findings) {
const cur = byKey.get(f.key);
if (!cur || RANK[f.severity] > RANK[cur.severity])
byKey.set(f.key, f);
}
return { status: "completed", findings: [...byKey.values()] };
}
40 changes: 33 additions & 7 deletions dist/core/driver.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
// 5. The back-edge feedback is a defect SIGNAL, never a solution (type-enforced).
// 6. T3's irreversible step stays human-gated (the auto-loop runs only on reversible
// pre-stages; merge is the t3-merge stop).
// 7. (REQ-BIND-5) No path to merge-pending / DONE without a DERIVED "accepted" of a
// "completed" critic round: MARK_READY (draft -> ready, which arms auto-merge) only comes
// from critiqued + completed + accepted; merge-pending without ready === true goes back to
// the critic; a not-run critic is retried once, then ESCALATE (never read as "no findings").
import { nextLoopDecision } from "./loop.js";
function handleRedGate(state) {
if (!state.loop || !state.loopParams) {
Expand Down Expand Up @@ -46,6 +50,15 @@ function handleMerge(state) {
: { kind: "STOP_FOR_HUMAN", stop: "t3-merge" };
}
}
// A ready PR: a human "changes requested" is a defect signal -> re-implement (the loop feeds the
// review comments back), and the PR goes back to draft before the next push (REQ-BIND-3).
// Resumable across sessions: the decision lives on GitHub. Otherwise the tier-gated merge.
function handleReady(state) {
if (state.reviewDecision === "changes-requested") {
return { kind: "RE_GEN", feedback: "defect-signal", freshContext: false, markDraft: true };
}
return handleMerge(state);
}
export function nextAction(state) {
// Invariant 1: without the guardians, the autonomous loop is refused outright.
if (!state.guardians.ok) {
Expand Down Expand Up @@ -87,13 +100,26 @@ export function nextAction(state) {
return state.gateVerdict === "green"
? { kind: "SPAWN_STATION", station: "critic" }
: handleRedGate(state);
case "merge-pending":
// A human "changes requested" on the impl PR is a defect signal -> re-implement (the loop
// feeds the review comments back). Resumable across sessions: the decision lives on GitHub.
if (state.reviewDecision === "changes-requested") {
return { kind: "RE_GEN", feedback: "defect-signal", freshContext: false };
case "critiqued": {
const c = state.critic;
if (!c)
return { kind: "SPAWN_STATION", station: "critic" };
if (c.status === "not-run") {
// Exactly one restart; a second not-run is an escalation, never "no findings".
return c.retried
? { kind: "ESCALATE", reason: "critic-not-run" }
: { kind: "SPAWN_STATION", station: "critic" };
}
// Invariant 3 & 6: T3 merge is human-gated; T0/T1 auto-merge.
return handleMerge(state);
if (c.verdict === "rejected")
return handleRedGate(state); // back-edge via nextLoopDecision
// accepted: mark ready first (arms auto-merge), then behave like merge-pending.
return state.ready === true ? handleReady(state) : { kind: "MARK_READY" };
}
case "merge-pending":
// Invariant 7: merge-pending is only legitimate for a ready PR (= accepted critic round).
// Anything else goes back to the critic.
if (state.ready !== true)
return { kind: "SPAWN_STATION", station: "critic" };
return handleReady(state);
}
}
31 changes: 31 additions & 0 deletions dist/core/finding-key.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Finding identity (REQ-KEY-1/2). A key identifies a defect across rounds. It contains NO line
// number and NO free text — only stable coordinates (file / function / rule / REQ-ID).
export const CRITIC_KLASSEN = [
"req-uncovered",
"happy-path-only",
"spec-deviation",
"convention",
"security",
"other",
];
export function gateFindingKey(raw) {
switch (raw.kind) {
case "test":
return `test:${raw.file}:${raw.testName}`;
case "mutant":
return `mutant:${raw.file}:${raw.fn}:${raw.mutator}`;
case "semgrep":
return `semgrep:${raw.rule}:${raw.file}:${raw.fn}`;
case "trace":
return `trace:${raw.req}`;
}
}
// Deliberately file-granular (not function-granular): fresh critics localise the same defect
// differently. Extra fields (line, claim, evidence) are accepted and ignored.
export function criticFindingKey(c) {
return `review:${c.req}:${c.klasse}:${c.datei}`;
}
// Gate findings are deterministic and always blockers.
export function gateFinding(raw) {
return { key: gateFindingKey(raw), source: "gate", severity: "blocker" };
}
61 changes: 61 additions & 0 deletions dist/core/gate-round.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Gate rounds in the core (REQ-GATE-1..3). Pure: translates structured gate findings into keys,
// folds them into the ledger via applyRound, and leaves entries of gate kinds that did NOT run
// this round untouched (absence of a skipped job is no evidence, REQ-GATE-2).
import { gateFinding } from "./finding-key.js";
import { applyRound } from "./ledger.js";
const GATE_KINDS = ["test", "mutant", "semgrep", "trace"];
const isKind = (x) => typeof x === "string" && GATE_KINDS.includes(x);
const nonEmpty = (x) => typeof x === "string" && x.length > 0;
const isObj = (x) => typeof x === "object" && x !== null && !Array.isArray(x);
function parseRaw(x) {
if (!isObj(x))
return undefined;
switch (x.kind) {
case "test":
return nonEmpty(x.file) && nonEmpty(x.testName) ? { kind: "test", file: x.file, testName: x.testName } : undefined;
case "mutant":
return nonEmpty(x.file) && nonEmpty(x.fn) && nonEmpty(x.mutator)
? { kind: "mutant", file: x.file, fn: x.fn, mutator: x.mutator }
: undefined;
case "semgrep":
return nonEmpty(x.rule) && nonEmpty(x.file) && nonEmpty(x.fn)
? { kind: "semgrep", rule: x.rule, file: x.file, fn: x.fn }
: undefined;
case "trace":
return nonEmpty(x.req) ? { kind: "trace", req: x.req } : undefined;
default:
return undefined;
}
}
// REQ-GATE-3: strict; anything invalid -> undefined (caller aborts, never a silent empty round).
// Only raws/ran/addressed are read; any other field is dropped.
export function parseGateRoundInput(x) {
if (!isObj(x) || !Array.isArray(x.raws) || !Array.isArray(x.ran) || !Array.isArray(x.addressed))
return undefined;
if (!x.ran.every(isKind) || !x.addressed.every((k) => typeof k === "string"))
return undefined;
const raws = [];
for (const r of x.raws) {
const p = parseRaw(r);
if (!p || !x.ran.includes(p.kind))
return undefined;
raws.push(p);
}
return { raws, ran: [...x.ran], addressed: [...x.addressed] };
}
const kindOf = (key) => key.slice(0, key.indexOf(":"));
export function applyGateRound(prev, input) {
const held = {};
const active = {};
for (const [k, e] of Object.entries(prev)) {
const kind = kindOf(k);
if (e.source === "gate" && isKind(kind) && !input.ran.includes(kind))
held[k] = { ...e };
else
active[k] = e;
}
const r = applyRound(active, { findings: input.raws.map(gateFinding), addressed: input.addressed });
// A held open blocker/major was open at round start: the round is judged (REQ-LEDGER-11).
const heldOpen = Object.values(held).some((e) => e.severity !== "nit" && (e.state === "open" || e.state === "reopened"));
return { ...r, judged: r.judged || heldOpen, ledger: { ...r.ledger, ...held } };
}
Loading
Loading