diff --git a/README.md b/README.md index 2223e35..e5c87a4 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ fail-closed CI-Required-Check und GitHub-Branch-Protection. - `/devloop:specify` — führt zur `spec.md` (EARS + `REQ-`-IDs + deterministisch abgeleitetes Tier) - `/devloop:spec-to-tests` — Test-Skeletons je `REQ-`-ID, geroutet nach EARS-Typ +- `/devloop:spec-to-twin` — *(optional, `.devloop` `twin.enabled`)* unabhängiges Verhaltens-Orakel (triviales Referenzmodell + REQ-getaggte Invarianten + fast-check model-based) aus Domänen-Wahrheiten — Korrektheit statt nur Spec-Treue - `/devloop:implement` — konsumiert Spec+Tests, öffnet PR (schreibt Spec/Tests nicht selbst) - `/devloop:critic` — adversarial, frischer Kontext, strukturiertes Verdikt - `/devloop:loop` — der Driver (orchestriert die Stationen als isolierte Subagenten) diff --git a/USAGE.md b/USAGE.md index 7dfbeab..9d98f03 100644 --- a/USAGE.md +++ b/USAGE.md @@ -90,7 +90,12 @@ Was passiert (Spec-PR-zuerst; der Driver gehorcht dem getesteten Kern, trifft ni 2. **specify** (Subagent) → `spec.md` (User Story, EARS-Kriterien mit `REQ-`-IDs, vorläufiges Tier). 3. **spec-to-tests** (eigener Subagent) → zu jeder `REQ-`-ID **vollständige, aber `.skip`'te** Tests (nach EARS-Typ). `main` bleibt grün (Trace zählt Skips, Vitest rötet nicht). -4. **Spec-PR öffnen** (`OPEN_SPEC_PR`) → Spec + geskippte Tests als eigener PR gegen `main`. + - *(optional, nur bei `.devloop` `twin.enabled`)* **spec-to-twin** läuft als **eigener** Subagent + (sieht die Tests **nicht**) und legt ein unabhängiges Verhaltens-**Orakel** dazu: triviales + Referenzmodell + REQ-getaggte Invarianten + Adapter + fast-check `modelRun`, `.skip`'t, im + geschützten Twin-Pfad — aus Domänen-Wahrheiten, **nicht** aus den EARS-Kriterien (Anti-Re-Anchor). + Wandert mit auf den Spec-PR (prüft Korrektheit, nicht nur Spec-Treue). +4. **Spec-PR öffnen** (`OPEN_SPEC_PR`) → Spec + geskippte Tests (+ ggf. Twin) als eigener PR gegen `main`. 5. **▣ STOPP: Spec-Review** — der Driver beendet den Turn. **Du/ein zweiter Mensch** reviewst den Spec-PR (Spec *und* Tests zusammen) und gibst ihn per **GitHub-CODEOWNER-Review** frei (§3). 6. **Spec mergen** (`MERGE_SPEC_PR`) → Spec-PR nach `main`, `git pull`. `implement` baut auf `main`. @@ -169,7 +174,7 @@ Stand gebunden. ## 4. Einzelne Stationen ohne Orchestrierung Jede Station gibt es auch als Einzel-Skill (ohne die harten Stopps), z.B. zum Üben: -`/devloop:specify`, `/devloop:spec-to-tests`, `/devloop:implement`, `/devloop:critic`. +`/devloop:specify`, `/devloop:spec-to-tests`, `/devloop:spec-to-twin` *(optional)*, `/devloop:implement`, `/devloop:critic`. Für den echten, abgesicherten Lauf nimm `/devloop:loop`. --- diff --git a/agents/devloop-spec-to-twin.md b/agents/devloop-spec-to-twin.md new file mode 100644 index 0000000..a654c89 --- /dev/null +++ b/agents/devloop-spec-to-twin.md @@ -0,0 +1,44 @@ +--- +name: devloop-spec-to-twin +description: Leitet aus einer spec.md ein UNABHÄNGIGES Verhaltens-Orakel ab (triviales Referenzmodell + REQ-getaggte Invarianten + Adapter + fast-check model-based Harness), .skip't, im geschützten Twin-Pfad. Aus DOMÄNEN-WAHRHEITEN abgeleitet, NICHT aus den EARS-Kriterien abgeschrieben (Anti-Re-Anchor). Eigener isolierter Subagent — NICHT spec-to-tests, NICHT implement; sieht die generierten Tests nicht. Optional (nur bei .devloop twin.enabled). Teil des Spec-PR. +tools: Read, Write, Glob, Grep, Bash +--- + +# Station: spec-to-twin + +Du baust das **Korrektheits-Orakel** zur `spec.md`: einen *digitalen Zwilling*, gegen den der spätere Code laufen muss. Wo `spec-to-tests` die **Treue zur Spec** prüft (hand-gewählte Beispiele, Erwartungswert aus *einer* Lesart geschrieben), prüfst du **Übereinstimmung zweier unabhängiger Ableitungen des Verhaltens** — Erwartungswert *berechnet* statt geschrieben, Eingaben *generiert* statt aufgezählt. Das ist die Unabhängigkeit aus §11, **eine Ebene höher**: nicht „wer schreibt die Tests", sondern „woher kommt ‚korrekt'". Dein Output wandert `.skip't` in den **Spec-PR** und wird vom Menschen mitreviewt (vor jedem Code). + +## Auftrag + +Aus der **reviewten** `spec.md` (+ Contract), **ohne die von `spec-to-tests` erzeugten Tests zu lesen** (Anker-Vermeidung), erzeugst du im geschützten Twin-Pfad (`/twin/`, aus `.devloop` `twin.area`): + +1. **Referenzmodell** — die absichtlich **triviale**, per Blick als korrekt durchschaubare Re-Implementierung des Domänen-Verhaltens. Vertrauenswürdig *weil* trivial, nicht weil verifiziert. In-Memory, keine Cleverness, keine I/O. +2. **Invarianten** — Domänen-Wahrheiten als Properties (Summen-Identität, „nie negativ", append-only …), **je mit REQ-Tag** im Test-Titel fürs Trace-Gate. +3. **Adapter** — `setup`/`reset`/`execute`/`teardown` gegen die **spezifizierte** Schnittstelle/den Contract (nicht gegen eine Implementierung — die gibt es noch nicht). Weicht `implement` später vom Contract ab, verkabelt der Adapter nicht → ein Divergenz-Signal. +4. **Harness** — fast-check `commands` + `modelRun`: würfelt Sequenzen, wendet jede auf **Modell und reales System** an, vergleicht nach **jedem** Schritt. Argumente **inkl. Randwerte** (≤ 0, nicht-ganzzahlig, fehlende Entität …), damit auch die Ablehnungs-Parität (400/404/409) mitgeprüft wird. + +## Die Naht — kritisch (§4, §11) + +- **Leite aus Domänen-Wahrheiten ab, NICHT aus den EARS-Kriterien.** Schreib die Spec nicht ab — sonst re-ankert das Orakel auf dieselbe Lesart und die Dekorrelation (der ganze Sinn) verschwindet. Frag: „Was ist *offensichtlich wahr* über diese Domäne?", nicht „Was sagt REQ-x?". Den REQ-Tag setzt du zur Rückverfolgbarkeit; die **Herleitung** bleibt unabhängig. +- **Du liest die Tests von `spec-to-tests` nicht.** Eure Unabhängigkeit ist der Sinn der Trennung; du bist eine eigene Instanz mit frischem Kontext. +- Markiere **jeden** model-based Test mit **`.skip` + REQ-Tag im Titel** — das sanktionierte Skip-Idiom (wie `spec-to-tests`): Trace-Gate zählt ihn als Abdeckung, Vitest rötet nicht (red-before-green), die Semgrep-Fluchttür lässt ihn durch. Das reale System existiert vor `implement` nicht — der Twin **muss** geskippt sein. +- **Du schreibst KEINEN Produktcode.** `implement` darf später **ausschließlich das `.skip` entfernen** — nie dein Modell, deine Invarianten oder Assertions ändern (maschinell: `verify-unskip` + der CODEOWNERS-Twin-Pfad). Das Orakel ist für den Produzenten **unerreichbar** — Gewaltenteilung, eine Ebene über den Gates. + +## Spec-Änderung (Amend-Modus) + +Ändert sich eine bestehende Spec, fasst du **nur die betroffenen Invarianten** an. Delta deterministisch: +``` +node "${CLAUDE_PLUGIN_ROOT}"/dist/cli/req-delta.js # {added, changed, removed} +``` +(alte Spec: `git show :`). Dann je Fall: +- **added** → neue Invariante, `.skip`'t. +- **changed** → die Invariante gleicher REQ-ID ändern **und `.skip` wieder setzen**. +- **removed** → Invariante entfernen (sonst verwaiste REQ-Referenz → rotes Trace-Gate). +Unveränderte Invarianten **nicht** anfassen. Läuft auf dem Spec-PR (`devloop/spec/`); dort darfst du autoren/ändern/re-skippen — `verify-unskip` greift dort nicht. + +## Grenzen + +- Du bist **nicht** `spec-to-tests` und **nicht** `implement`; deine Unabhängigkeit von beiden ist der Sinn. +- Erfinde keine Domäne dazu, die die Spec nicht hergibt — aber schreib die Spec auch nicht ab. Ist die Spec widersprüchlich/lückenhaft, sodass „korrektes Verhalten" nicht ableitbar ist, ist das ein **Spec-Defekt** → zurückmelden, nicht raten. +- **Das Orakel bleibt projekt-lokal.** Generalisiere nie das Modell — nur der Runner ist (später) wiederverwendbar. Ein „generisches Modell" wäre eine generische Spec, also kein unabhängiges Orakel. +- **Repo-seitige Annahmen** wie bei `spec-to-tests`: das Trace-/Coverage-Gate zählt `.skip`'te Tests als Abdeckung; API-Referenzen in noch-nicht-implementierten Tests folgen demselben Muster wie dort. Du läufst überhaupt nur, wenn `.devloop` `twin.enabled` gesetzt ist (Station ist optional, Kern bleibt schlank). diff --git a/dist/core/driver.js b/dist/core/driver.js index 847eb01..6e42910 100644 --- a/dist/core/driver.js +++ b/dist/core/driver.js @@ -59,6 +59,13 @@ export function nextAction(state) { // reviewer sees spec + its (skipped) tests together. No code yet -> §5.1 preserved. return { kind: "SPAWN_STATION", station: "spec-to-tests" }; case "tests-written": + // When the twin is enabled, the independent oracle (reference model + invariants) is + // authored by its OWN isolated station BEFORE the spec PR, so it is reviewed together with + // the spec + tests. Default off -> straight to the spec PR (chain unchanged). + return state.twinEnabled + ? { kind: "SPAWN_STATION", station: "spec-to-twin" } + : { kind: "OPEN_SPEC_PR" }; + case "twin-written": return { kind: "OPEN_SPEC_PR" }; case "spec-pr-open": // Invariant 2: the spec-review stop is hard for every tier (§5.1 root of trust). It is diff --git a/dist/core/init.js b/dist/core/init.js index f3d86df..4febd8a 100644 --- a/dist/core/init.js +++ b/dist/core/init.js @@ -57,7 +57,10 @@ export function initRepo(targetRepo, ciTemplate, opts = {}) { writeIfAbsent(".devloop/bot-logins.json", BOT_LOGINS_SKELETON + "\n"); // Anchor (b) is the default: CI is authoritative. Recorded explicitly so the local merge // hook defers to CI instead of demanding the (anchor-a) local token. - writeIfAbsent(".devloop/config.json", JSON.stringify({ anchor: "b" }, null, 2) + "\n"); + // anchor: CI is authoritative (b). twin: the optional spec-to-twin station, disabled by default + // (discoverable here; core stays schlank). Enable via twin.enabled=true + twin.area (the protected + // oracle path, e.g. "services/foo/twin") — see the spec-to-twin station. + writeIfAbsent(".devloop/config.json", JSON.stringify({ anchor: "b", twin: { enabled: false } }, null, 2) + "\n"); // Tier-map: NEVER shadow an existing one. resolveTierMapPath prefers .devloop/, so writing a // default there would silently override a repo's own tools/tier-map.json -> gate regression. const existingTierMap = resolveTierMapPath(targetRepo); diff --git a/docs/2026-06-23-spec-to-twin-station-design.md b/docs/2026-06-23-spec-to-twin-station-design.md new file mode 100644 index 0000000..c40f360 --- /dev/null +++ b/docs/2026-06-23-spec-to-twin-station-design.md @@ -0,0 +1,135 @@ +# spec-to-twin Station — Design + +> devloop. Adds an **optional** sibling to `spec-to-tests` that produces a *digital twin* — a +> spec-independent behavioural oracle (framework Säule 4). Where `spec-to-tests` proves *fidelity +> to the spec*, the twin proves *agreement of two independent derivations of the behaviour*. +> Closes the gap the chain itself admits: the loop verifies fidelity-to-spec, not whether the +> spec is right. Preserves both invariants (spec-review §5.1, test↔code independence §11 #3). + +## Leitidee + +`spec-to-tests` writes hand-picked, REQ-tagged example tests whose *expected values are authored* +from one reading of the spec. A buggy spec, or a misread, is encoded identically by the test +author and the implementer — different agents, **same root**. The twin removes that shared root: +a deliberately-trivial **reference model** computes the expected behaviour independently, and a +**model-based** harness (fast-check `commands` + `modelRun`) runs thousands of generated command +sequences against model *and* real system, comparing observable outcomes after each step. The +expected value is *computed, not written*; the input space is *generated, not enumerated*. + +Same principle as the existing `spec-to-tests`↔`implement` split (independence / separation of +powers) — **one level up**: not "who authors the tests" but "where the notion of correct comes +from." + +> **Generalisable is the mechanism, never the oracle.** A model that fits every project is a +> generic spec — i.e. no independent oracle. The reference model, invariants and adapter stay +> project-local and in the protected set; only the runner is reusable (→ `@devloop/twin`, later, +> by rule-of-three — not extracted from a single use). + +## Where it sits + +Sibling of `spec-to-tests`, on the **Spec-PR**, before any code. Isolated subagent, fresh +context. Critically, it runs **independent of `spec-to-tests`**: it sees the reviewed `spec.md` +(and the contract), **not** the generated tests — otherwise it anchors on that station's reading +and the decorrelation shrinks. + +`specify` → { `spec-to-tests` ∥ `spec-to-twin` (if enabled) } → Spec-PR on `devloop/spec/` +→ **spec-review stop (human adjudicates intent here)** → merge → `implement` on `devloop/` +→ gates (incl. `twin`) → `critic` → **impl-merge stop** → merge. + +Two human gates, unchanged. **The driver state machine (`src/core/loop.ts`) is unchanged** — +`spec-to-twin` is one more station the driver spawns *conditionally*; `nextLoopDecision` does not +change. (Same minimal-impact stance as the spec-change-loopback design.) + +## What it produces + +All under a protected twin path (e.g. `/twin/`, added to `CODEOWNERS`), all `.skip`'d in +the Spec-PR — the real system does not exist yet, so the twin cannot run until `implement`: + +1. **Reference model** — the deliberately-trivial, eyeball-correct re-derivation of the domain + behaviour (the oracle). Trusted *because* trivial, not because verified. +2. **Invariants** — domain truths as properties (e.g. "sum identity", "never negative", + "append-only"), each **REQ-tagged** for the trace-gate. +3. **Adapter** — `setup` / `reset` / `execute` against the **specified** interface/contract. If + `implement` deviates from the contract, the adapter fails to wire → a divergence signal. +4. **Harness wiring** — fast-check `commands` + `modelRun`, generating args **including boundary + values** (≤0, non-integer, …) so rejection-parity is checked too. + +`implement` may **only remove `.skip`** — never alter the model, invariants or assertions. This +is the same `verify-unskip` seam as `spec-to-tests`: the producer cannot reach the oracle. + +## The distinguishing mandate (vs. spec-to-tests) + +`spec-to-tests`: *"map exactly the REQ-IDs, invent nothing."* +`spec-to-twin`: the **opposite** — *"derive model + invariants from the **domain truths**, **not** +by transcribing the EARS criteria"* (anti-re-anchor). Still cross-reference REQ-IDs on the +invariants for the trace-gate, but the derivation must be independent. This reversed instruction +is exactly why it is a **separate station** and not a flag on `spec-to-tests`: one mandate is +"reproduce the spec faithfully," the other is "re-derive correctness independently." Merging them +muddies both. + +## EARS routing — the twin as a new gate-sort + +Extends the `spec-to-tests` routing table: + +| EARS type | Gate sort | +|---|---| +| When / If / While / Where | Vitest (+ fast-check for invariants) | +| **Invariant / property over op-sequences** | **twin: reference-model `modelRun`** | +| Performance | bench / load | +| Architektur | ArchUnitTS | +| Contract | AsyncAPI / PACT | + +A criterion like REQ-SPD-11 ("over any sequence of valid ops, the balance is never negative and +equals the signed sum") routes to the twin, not to a single example test. + +## Optionality — core stays schlank + +The loop spawns `spec-to-twin` **only if the target repo opts in** — a flag in its `.devloop/` +config (e.g. `twin: { enabled: true, area: "services/wallet-service" }`). Default off: repos that +don't want it see no new station, no new gate, no extra agent. The `twin` CI job is required +**only when enabled**. This honours the "twin as a *pluggable* capability, core stays schlank" +decision. + +## Gate & protected set + +- New CI job `twin` (when enabled), threshold **zero divergence** (any divergence = red), scope + grows with the domain — same discipline as the mutation ratchet ("a cage is maintained, not + finished"). +- Oracle path under `CODEOWNERS` as its **own** entry, so a feature-PR touching the oracle is a + *visible alarm* ("agent changes a gate instead of code"), not a buried diff line. The + drift-watcher (`check-codeowners`) keeps this fail-closed, as it already does for tier paths. +- Twin tests carry REQ-tags (trace-gate, like every other test). + +## Amend-mode (spec change) + +Mirrors `spec-to-tests`: on a spec change, take the deterministic delta +(`dist/cli/req-delta.js ` → {added, changed, removed}) and touch **only** affected +invariants. Because invariants are REQ-tagged, the same selector works: *added* → new invariant, +`.skip`'d; *changed* → amend the same-REQ invariant and re-`.skip`; *removed* → delete (else an +orphan REQ-ref reddens the trace-gate). Runs on the Spec-PR, where authoring/re-skip is allowed. + +## The generic seam (build toward it, don't extract yet) + +Author Obol v1 (and this station's output) with a clean seam so later extraction to +`@devloop/twin` is mechanical: + +- `Model` — state + named commands (`precondition` / `apply → expectedOutcome` / `genArgs`). +- `System` adapter — `setup` / `reset` / `execute → actualOutcome` / `teardown`. +- `Oracle` — `compare(expected, actual)` with a project matcher (this is also the **brownfield + equivalence relation**: the normalisation of timestamps/ids/ordering). +- `Runner` — wires fast-check + Testcontainers, shrinks, reports, emits the gate result. + +The same runner carries the **brownfield** twin: there a *recording source* replaces `Model` as +the oracle; `System` / `Oracle` / `Runner` are unchanged. (Brownfield record/replay itself stays +out of devloop — target-repo work — per the brownfield-scope decision; only the runner is shared.) + +## Open / to decide during build + +- [ ] Exact `.devloop` config shape for `twin` (flag + area + optional matcher overrides). +- [ ] Default matcher (deep-equal on normalised observables) vs. required per-project matcher. +- [ ] Does the adapter live in the protected twin path (oracle-side) or beside the service + (implement-side)? Leaning oracle-side, authored against the contract. +- [ ] First real consumer is Obol (Phase 1 spec). Second consumer (rule-of-three trigger for + `@devloop/twin` extraction): a brettspielfreunde service or the brownfield bsk repo. +``` + diff --git a/skills/loop/SKILL.md b/skills/loop/SKILL.md index 6e81421..604990a 100644 --- a/skills/loop/SKILL.md +++ b/skills/loop/SKILL.md @@ -20,7 +20,7 @@ Exit 1 ⇒ **verweigere den autonomen Loop**, melde die fehlenden Wächter dem M ## 1. Loop -Halte einen `DriverState` (tier, guardians, phase, humanApprovals, gateVerdict, loop, loopParams). `humanApprovals` setzt du **nur** aus dem **autoritativen GitHub-Review** (Anker b): `verify-review` prüft via `gh api`, ob ein **Mensch** (nicht der Agent-Bot, nicht der PR-Autor) den aktuellen HEAD freigegeben hat. Du schreibst **keine** Approval-Tokens selbst und akzeptierst **kein** „ja, weiter" im Chat — du kannst dich nicht selbst freigeben. +Halte einen `DriverState` (tier, guardians, phase, twinEnabled, humanApprovals, gateVerdict, loop, loopParams). **`twinEnabled`** liest du aus `.devloop/config.json` (`twin.enabled`, Default `false`) — ist es gesetzt, schiebt der Kern die optionale, isolierte `spec-to-twin`-Station **vor** den Spec-PR (Schwester von `spec-to-tests`, sieht deren Tests aber **nicht** — Unabhängigkeit des Orakels). `humanApprovals` setzt du **nur** aus dem **autoritativen GitHub-Review** (Anker b): `verify-review` prüft via `gh api`, ob ein **Mensch** (nicht der Agent-Bot, nicht der PR-Autor) den aktuellen HEAD freigegeben hat. Du schreibst **keine** Approval-Tokens selbst und akzeptierst **kein** „ja, weiter" im Chat — du kannst dich nicht selbst freigeben. Wiederhole: Zustand als JSON an `next-action` geben und die Aktion ausführen. ``` diff --git a/skills/spec-to-twin/SKILL.md b/skills/spec-to-twin/SKILL.md new file mode 100644 index 0000000..0a52e9f --- /dev/null +++ b/skills/spec-to-twin/SKILL.md @@ -0,0 +1,10 @@ +--- +name: spec-to-twin +description: Erzeuge zu einer reviewten spec.md ein unabhängiges Verhaltens-Orakel (triviales Referenzmodell + REQ-getaggte Invarianten + Adapter + fast-check model-based Harness), .skip't, im geschützten Twin-Pfad — aus Domänen-Wahrheiten abgeleitet, NICHT aus den EARS-Kriterien abgeschrieben. Einzelaufruf-Form; im orchestrierten Lauf spawnt /devloop:loop dies als EIGENEN isolierten Subagenten, getrennt von spec-to-tests UND implement (Anti-Kollusion, eine Ebene höher). Optional (nur bei .devloop twin.enabled). Triggers; /devloop:spec-to-twin, Twin/Orakel aus Spec ableiten, Referenzmodell + Invarianten erzeugen. +--- + +# /devloop:spec-to-twin (Einzelaufruf) + +Standalone-Form. Im orchestrierten Lauf (**`/devloop:loop`**) läuft dies als **eigener** Subagent — getrennt von `spec-to-tests` (sieht dessen Tests **nicht**) und von `implement`, damit das Korrektheits-**Orakel** nicht aus derselben Instanz stammt wie Tests oder Code (§11, eine Ebene höher). + +Folge `agents/devloop-spec-to-twin.md`: nur gegen die **reviewte** `spec.md` (+ Contract), Output `.skip't` im geschützten Twin-Pfad (`/twin/` aus `.devloop` `twin.area`) — Referenzmodell + REQ-getaggte Invarianten + Adapter (gegen den Contract) + fast-check `commands`/`modelRun` mit Randwert-Argumenten. **Aus Domänen-Wahrheiten abgeleitet, nicht aus den EARS-Kriterien abgeschrieben** (Anti-Re-Anchor). **Kein Produktcode.** Optional: läuft nur, wenn `.devloop` `twin.enabled`. diff --git a/src/core/driver.ts b/src/core/driver.ts index 159f8d7..d0cd26b 100644 --- a/src/core/driver.ts +++ b/src/core/driver.ts @@ -21,12 +21,13 @@ import type { Tier } from "./tier.js"; import type { Stop } from "./types.js"; import { nextLoopDecision, type LoopState, type LoopParams } from "./loop.js"; -export type Station = "specify" | "spec-to-tests" | "implement" | "critic"; +export type Station = "specify" | "spec-to-tests" | "spec-to-twin" | "implement" | "critic"; export type Phase = | "init" | "specified" | "tests-written" + | "twin-written" | "spec-pr-open" | "spec-merged" | "implemented" @@ -48,6 +49,9 @@ export interface DriverState { tier: Tier; guardians: { ok: boolean; missing: string[] }; phase: Phase; + // Optional spec-to-twin station: only enabled when the target repo opts in (.devloop twin.enabled). + // Default off keeps the core chain schlank — no extra station, no extra gate, for repos that don't want it. + twinEnabled?: boolean; humanApprovals: Partial>; // derived from verifyApproval()=="ok", NOT prompt-set // The human's PR review decision (from GitHub). "changes-requested" is a defect signal -> rework. reviewDecision?: "approved" | "changes-requested"; @@ -103,6 +107,14 @@ export function nextAction(state: DriverState): Action { return { kind: "SPAWN_STATION", station: "spec-to-tests" }; case "tests-written": + // When the twin is enabled, the independent oracle (reference model + invariants) is + // authored by its OWN isolated station BEFORE the spec PR, so it is reviewed together with + // the spec + tests. Default off -> straight to the spec PR (chain unchanged). + return state.twinEnabled + ? { kind: "SPAWN_STATION", station: "spec-to-twin" } + : { kind: "OPEN_SPEC_PR" }; + + case "twin-written": return { kind: "OPEN_SPEC_PR" }; case "spec-pr-open": diff --git a/src/core/init.ts b/src/core/init.ts index 9f15d7c..d95f8a8 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -88,7 +88,13 @@ export function initRepo( writeIfAbsent(".devloop/bot-logins.json", BOT_LOGINS_SKELETON + "\n"); // Anchor (b) is the default: CI is authoritative. Recorded explicitly so the local merge // hook defers to CI instead of demanding the (anchor-a) local token. - writeIfAbsent(".devloop/config.json", JSON.stringify({ anchor: "b" }, null, 2) + "\n"); + // anchor: CI is authoritative (b). twin: the optional spec-to-twin station, disabled by default + // (discoverable here; core stays schlank). Enable via twin.enabled=true + twin.area (the protected + // oracle path, e.g. "services/foo/twin") — see the spec-to-twin station. + writeIfAbsent( + ".devloop/config.json", + JSON.stringify({ anchor: "b", twin: { enabled: false } }, null, 2) + "\n", + ); // Tier-map: NEVER shadow an existing one. resolveTierMapPath prefers .devloop/, so writing a // default there would silently override a repo's own tools/tier-map.json -> gate regression. diff --git a/test/core/driver.test.ts b/test/core/driver.test.ts index 9d1d44d..48ee1c7 100644 --- a/test/core/driver.test.ts +++ b/test/core/driver.test.ts @@ -145,6 +145,23 @@ test("changes-requested does not apply to T0/T1 auto-merge (no review there)", ( expect(nextAction(base({ tier: "T1", phase: "merge-pending", gateVerdict: "green" }))).toEqual({ kind: "DONE" }); }); +// --- Optional spec-to-twin station (twin as a pluggable capability) ----------- +test("tests-written spawns spec-to-twin when the twin is enabled (before the spec PR)", () => { + expect(nextAction(base({ phase: "tests-written", twinEnabled: true }))).toEqual({ + kind: "SPAWN_STATION", + station: "spec-to-twin", + }); +}); + +test("twin-written opens the spec PR (twin artifacts join spec + tests on the spec PR)", () => { + expect(nextAction(base({ phase: "twin-written", twinEnabled: true }))).toEqual({ kind: "OPEN_SPEC_PR" }); +}); + +test("twin disabled (default) preserves the chain: tests-written goes straight to the spec PR", () => { + expect(nextAction(base({ phase: "tests-written" }))).toEqual({ kind: "OPEN_SPEC_PR" }); + expect(nextAction(base({ phase: "tests-written", twinEnabled: false }))).toEqual({ kind: "OPEN_SPEC_PR" }); +}); + // --- Invariant 4: the driver never produces artifacts inline ------------------ test("nextAction never produces artifacts inline (SPAWN_STATION is the only producer)", () => { const ALLOWED: Action["kind"][] = [ @@ -162,6 +179,7 @@ test("nextAction never produces artifacts inline (SPAWN_STATION is the only prod "init", "specified", "tests-written", + "twin-written", "spec-pr-open", "spec-merged", "implemented", diff --git a/test/core/init.test.ts b/test/core/init.test.ts index fb6baee..245e8fd 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -33,6 +33,12 @@ test("init records the anchor explicitly as b (config.json), so the local hook d expect(JSON.parse(readFileSync(cfgPath, "utf8")).anchor).toBe("b"); }); +test("init scaffolds the optional twin flag, disabled by default (discoverable, core stays schlank)", () => { + initRepo(repo, TEMPLATE); + const cfg = JSON.parse(readFileSync(join(repo, ".devloop/config.json"), "utf8")); + expect(cfg.twin).toEqual({ enabled: false }); +}); + test("the bootstrapped repo then satisfies the precondition-check guardian", async () => { // After init, the workflow marker is present -> checkGuardians no longer reports it. initRepo(repo, TEMPLATE);