diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
index cbd3fad..b03ed1f 100644
--- a/.claude-plugin/marketplace.json
+++ b/.claude-plugin/marketplace.json
@@ -5,14 +5,14 @@
},
"metadata": {
"description": "Torque Loop — a single-plugin marketplace.",
- "version": "0.4.0"
+ "version": "0.5.0"
},
"plugins": [
{
"name": "ratchet",
"source": "./",
"description": "Torque Loop: turns ambiguous work into shipped artifacts through target locking, adversarial validation, patching, and state serialization, plus a bounded /ratchet:evolve loop.",
- "version": "0.4.0",
+ "version": "0.5.0",
"author": {
"name": "Danny Gillespie"
},
diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json
index 107cb7b..64047d8 100644
--- a/.claude-plugin/plugin.json
+++ b/.claude-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"name": "ratchet",
"displayName": "Torque Loop",
- "version": "0.4.0",
+ "version": "0.5.0",
"description": "Torque Loop turns ambiguous work into shipped artifacts through target locking, adversarial validation, patching, and state serialization — plus a bounded /ratchet:evolve loop. A stateful advancement engine, not a prompt library. Not affiliated with Anthropic.",
"author": {
"name": "Danny Gillespie"
diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json
index 05006f5..8dedc43 100644
--- a/.codex-plugin/plugin.json
+++ b/.codex-plugin/plugin.json
@@ -1,6 +1,6 @@
{
"name": "torque-loop",
- "version": "0.4.0",
+ "version": "0.5.0",
"description": "Torque Loop evolves ambiguous work into shipped, tested, serialized artifacts through evidence-gated loops.",
"author": {
"name": "Danny Gillespie",
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b6a50aa..609b212 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [0.5.0] - 2026-07-04 — Receipt
+
+0.2 gated proof; 0.3 gated the *seam* of that proof; 0.4 metered the *depth* of the loop.
+0.5 gives the loop a single cockpit: **one read — `ratchet receipt` — that says what is
+true, what changed, what was proven, what is at risk, whether it is safe to ship, and
+whether the receipt's own steering can be trusted.**
+
+### Added
+
+- **The receipt (`ratchet receipt`)** — one stable, always-same-shape read with eight fixed
+ sections (TARGET · DELTA · PROOF · VERDICT · RISK · AUTHORITY · STATE · NEXT), joining session
+ state, the evolve journal, the QA ledger, and git. Emptiness is stated, never omitted, so the
+ shape never shifts between commands or sessions. `--json` emits the same structure for
+ consumers; `--save` writes `.ratchet/current.json` + `current.md` as a gitignored
+ source-of-truth index.
+- **Three-layer confidence** — `ratchet score confidence` scores artifact · session · ledger
+ independently, each naming its scope. A verified artifact stays ship-ready even when unrelated
+ debt tanks session confidence — killing the "verified green but reported blocked" gaslight.
+- **Cold-start control-plane scan** — the receipt runs the cold-start poison scan inline and
+ renders `Control-plane scan: FAIL|WARN|clean` under STATE (also exposed as a top-level
+ `controlPlane` field in `--json`), surfacing stale steering (retracted work still being pointed
+ at) and misleading configured operator surfaces (e.g. unqualified git counts) in the one cold
+ read — no separate doctor run. Project surfaces are an opt-in adapter declared in
+ `.ratchet/cold-start.json`; no workspace path is hardcoded.
+
+### Changed
+
+- **`state reset` now requires `--force`** — an ungated canonical wipe is refused; the receipt
+ AUTHORITY card renders the gates in force.
+- **Agents are propose-only** — only the scribe writes canonical state; the builder and auditor
+ are refused mutating verbs at the CLI boundary via `RATCHET_AGENT` (they read and propose).
+
## [0.4.0] - 2026-07-03 — Aperture
0.2 gated proof; 0.3 gated the *seam* of that proof. 0.4 adds the dial that decides how
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..4a6bc9f
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,56 @@
+
+You are the continuation maintainer of Torque Loop, resuming it in a fresh context. Torque Loop is a zero-dependency Claude Code + Codex plugin (Node ≥ 18) that turns ambiguous work into shipped, tested, serialized artifacts through evidence-gated loops. Operate the project by its own discipline: frame → choose → build → attack → patch → serialize → advance.
+
+
+
+No proof → no keep. No keep → no progress claim. Every change you keep is justified by evidence, not assertion, and the diff is the smallest one that clears the defect.
+
+
+
+- Repo: the Torque Loop plugin itself (skills/, agents/, hooks/, bin/, src/, test/, templates/).
+- Current release: v0.4.0 "Aperture". Version is aligned across package.json and every manifest; `npm test` runs three zero-dependency suites (cli, evolve, plugin-shape) and MUST stay green.
+- Already shipped: 0.2 proof gate ("no proof → no keep"), 0.3 seam gate ("wrong proof → no ship"), 0.4 aperture dial (meter loop depth to the uncertainty it earns).
+- IGNORE the `.ratchet/current.json` and `current.md` in the tree: that is dogfooding residue from running ratchet on an unrelated project ("lucidia" / F4 prepare-turn), NOT Torque Loop's roadmap. Do not adopt its open defect or next-action as ours.
+
+
+
+- Zero new runtime dependencies; adding one requires an explicit, stated justification.
+- Every behavior change ships with a test that would fail without it.
+- Keep diffs minimal — REMOVE / ADD / CHANGE only what the change requires; never rewrite working code for elegance.
+- Match the surrounding voice: terse, concrete, active, no filler. CommonJS, Node ≥ 18, no build step.
+
+
+
+1. Establish Torque Loop's own locked target for this session (not the borrowed lucidia state). If I have not named one, infer the highest-leverage next refinement from CHANGELOG's [Unreleased] section plus the current source, state your assumption, and proceed.
+2. Run one full evidence-gated loop on that target:
+ - LOCK it (object, outcome, proof-of-done, forbidden scope).
+ - Read ground truth first (relevant SKILL.md, src/*.js, test/*.js) before editing.
+ - BUILD the smallest change → ATTACK with the five-voice hostile board → PATCH only what failed → VERIFY with `npm test` (or the narrowest relevant suite), never self-grade.
+ - Keep only if proven; otherwise revert and record the lesson.
+3. SERIALIZE the result into a compact handoff the next fresh context can resume from with zero re-derivation.
+
+
+
+- reference/PROMPTS.md — canonical intent each skill implements (source of truth over the skills).
+- CHANGELOG.md [Unreleased] — where the roadmap points next.
+- src/scoring.js, src/evolve/schema.js, src/receipt.js — load-bearing logic and the gates.
+- test/*.test.js — the falsifiable contract you must keep green.
+
+
+
+Return exactly:
+
+LOCKED TARGET:
+GROUND TRUTH READ:
+CHANGE:
+ATTACK:
+VERIFY:
+VERDICT: KEEP | REVERT | REVERTED_AND_LEARNED —
+REMAINING RISK:
+NEXT EDGE:
+HANDOFF SENTENCE:
+
+
+
+The turn is complete only when an artifact exists, `npm test` is green (or a failure is honestly reported), and the handoff sentence would let a cold agent take the correct next action without asking you anything.
+
\ No newline at end of file
diff --git a/README.md b/README.md
index cc464ea..346ae5f 100644
--- a/README.md
+++ b/README.md
@@ -297,6 +297,33 @@ builder's own search method is rejected as not independent.
| `ratchet git status-refs` | Ahead/behind vs every base ref — each one named. |
| `ratchet doctor cold-start` | Scan for stale steering (opt-in surfaces via `.ratchet/cold-start.json`). |
+### The receipt — one control surface (`ratchet receipt`)
+
+`ratchet receipt` is the cockpit: one stable read a cold human or agent can parse in under a
+minute, so state never lives only in the transcript. Eight fixed sections, same order every
+time, emptiness stated rather than omitted:
+
+```text
+TARGET · DELTA · PROOF · VERDICT · RISK · AUTHORITY · STATE · NEXT
+```
+
+- **PROOF** carries the KEEP evidence card and the seam (tested → ships). If the seam is a
+ proxy and not waived, it says **"Cannot justify ship decision"** out loud — proxy proof
+ never masquerades as ship proof.
+- **VERDICT** splits confidence into three independently-scoped layers so a verified patch is
+ never gaslit to *blocked* by unrelated debt:
+
+ | Layer | Answers | Scope |
+ | --- | --- | --- |
+ | Artifact confidence | Is *this* patch good? | the current artifact's own holes, attached defects, and verification evidence |
+ | Session confidence | Can the loop stop? | active open defects, untested assumptions, next action |
+ | Ledger health | Is the record clean? | historical open/stale defects and failing tests |
+
+- **AUTHORITY** names where the work sits on the ladder — `uncommitted → committed-local →
+ pushed → released` — plus every irreversible action's owner and the gates in force.
+- `ratchet receipt --save` writes `.ratchet/current.json` + `.ratchet/current.md` — the
+ always-current source-of-truth index a new agent reads first.
+
---
## How it works
@@ -316,10 +343,11 @@ The skills carry the reasoning; the CLI carries the state. A skill loads context
the CLI, does its work, and writes the result back:
```bash
+ratchet receipt # one stable resume read: target·delta·proof·seam·verdict·authority·state·next
ratchet status # what the ratchet knows right now
ratchet snapshot repo # cheap ground-truth read of the codebase
ratchet score friction '[...]' # rank obstacles: Leverage × Certainty × Time × Risk (1–10)
-ratchet score confidence # session confidence + whether the loop may stop
+ratchet score confidence # three scoped layers: artifact · session · ledger health
ratchet artifact add '{...}' # record an artifact
ratchet defect add '{...}' # record a defect (also lands in the QA ledger)
ratchet export markdown # the full compile / handoff
@@ -333,6 +361,13 @@ Run `ratchet --help` for the complete surface.
- **`ratchet-auditor`** — attacks artifacts, assumptions, and self-serving reasoning.
- **`ratchet-scribe`** — serializes state, decisions, defects, and next moves.
+**Memory isolation by role.** The registered agents have isolated memory enforced at the CLI
+boundary: only the scribe writes canonical state. Builder and auditor are *propose-only* —
+run under `RATCHET_AGENT=`, their mutating verbs are refused, so they emit the exact
+`ratchet …` command for the caller (or the scribe) to run instead of clobbering the shared
+record. Read verbs (`ratchet receipt`, `status`, `snapshot`, `score`) stay open to every
+agent. One writer, many proposers — agents cannot overwrite each other's memory.
+
### The hooks (conservative by design)
Ratchet creates pressure, not surprise. The hooks never run tests or edits on their own:
diff --git a/agents/ratchet-auditor.md b/agents/ratchet-auditor.md
index b31923d..bee9b1b 100644
--- a/agents/ratchet-auditor.md
+++ b/agents/ratchet-auditor.md
@@ -36,6 +36,12 @@ Run the artifact past five reviewers. Speak in each voice; do not blur them.
5. **Kill false confidence.** If the artifact is genuinely fine on a dimension, say so in
one word and move on. Do not manufacture findings, but default to skepticism.
+6. **Propose, never write.** You have **propose-only memory**: emit the exact
+ `ratchet defect add '{...}'` line for each real defect and let the caller (or the
+ `ratchet-scribe`) serialize it. Run under `RATCHET_AGENT=ratchet-auditor` and the CLI
+ enforces this — mutating verbs are refused, so your audit cannot mutate the record it is
+ auditing. Read verbs stay open.
+
## Output shape
```
diff --git a/agents/ratchet-builder.md b/agents/ratchet-builder.md
index 83189f9..294e03a 100644
--- a/agents/ratchet-builder.md
+++ b/agents/ratchet-builder.md
@@ -26,8 +26,12 @@ or produce plans-about-plans. You produce the thing.
5. **Match the surrounding code.** When editing a repo, read neighbors first and mirror
their idiom, naming, and comment density. Never introduce a new style.
-6. **Record it.** When done, propose the exact `ratchet artifact add '{...}'` line so the
- caller can serialize the artifact into state.
+6. **Record it — propose, never write.** When done, propose the exact
+ `ratchet artifact add '{...}'` line so the caller (or the `ratchet-scribe`) can serialize
+ it. You have **propose-only memory**: you do not run canonical-state mutations yourself.
+ Run under `RATCHET_AGENT=ratchet-builder` and the CLI enforces this — mutating verbs are
+ refused so you cannot clobber the shared record or another agent's work. Read verbs
+ (`ratchet receipt`, `status`, `snapshot`, `score`) stay open to you.
## Output shape
diff --git a/agents/ratchet-scribe.md b/agents/ratchet-scribe.md
index 27e83cc..385692a 100644
--- a/agents/ratchet-scribe.md
+++ b/agents/ratchet-scribe.md
@@ -14,9 +14,12 @@ re-deriving anything. You are precise and you never embellish.
defects actually found, loops actually left open. If it was discussed but not decided,
it is an open loop, not a decision.
-2. **Write through the CLI.** Persist via `ratchet state set`, `ratchet state append`,
- `ratchet artifact add`, and `ratchet defect add`. Read current state first with
- `ratchet status --json` so you extend rather than overwrite.
+2. **Write through the CLI — you are the sole writer.** Persist via `ratchet state set`,
+ `ratchet state append`, `ratchet artifact add`, and `ratchet defect add`. Read current
+ state first with `ratchet status --json` so you extend rather than overwrite. Among the
+ registered agents, only you mutate canonical state (builder and auditor are propose-only,
+ enforced by the CLI). Run unnamespaced, or set `RATCHET_AGENT=scribe` — either way the
+ guard lets you write; that single-writer rule is what keeps agent memory from colliding.
3. **Every compile ends with a single next action and a single next command.** If you
cannot name the next action, that itself is the finding — say the objective is unclear.
diff --git a/package.json b/package.json
index 5efa47d..fd23320 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@torque-loop/plugin",
- "version": "0.4.0",
+ "version": "0.5.0",
"description": "Torque Loop — a Claude Code and Codex plugin that evolves ambiguity into shipped, tested, serialized artifacts through evidence-gated loops.",
"license": "MIT",
"author": "Danny Gillespie",
diff --git a/skills/handoff/SKILL.md b/skills/handoff/SKILL.md
index d666678..34acf4f 100644
--- a/skills/handoff/SKILL.md
+++ b/skills/handoff/SKILL.md
@@ -12,11 +12,15 @@ start acting immediately — no re-reading the session, no re-deriving decisions
## Step 0 — Load state
```
+ratchet receipt
ratchet export markdown
ratchet score confidence
```
-Delegate to the `ratchet-scribe` subagent for a long or high-stakes session.
+Start from `ratchet receipt` — its eight fixed sections (target · delta · proof · seam ·
+verdict · authority · state · next) are already the handoff spine; the steps below expand
+each one for the receiver. Delegate to the `ratchet-scribe` subagent for a long or
+high-stakes session.
## Procedure
diff --git a/skills/status/SKILL.md b/skills/status/SKILL.md
index ca76121..63dcba7 100644
--- a/skills/status/SKILL.md
+++ b/skills/status/SKILL.md
@@ -10,15 +10,21 @@ where the ratchet sits, so you resume instead of restart.
## Procedure
-Read the serialized state and render it:
+Lead with the **receipt** — one stable read that answers *what is true, what changed, what
+is safe, what is blocked, and what happens next* without transcript archaeology:
```
-ratchet status
+ratchet receipt
```
-Add the confidence read and repo ground-truth when useful:
+The receipt always shows the same eight sections in the same order — **target · delta ·
+proof · seam · verdict · authority · state · next** — so a cold human or agent can resume in
+under a minute. Emptiness is stated, never omitted.
+
+For the detailed state view or a specific read, add:
```
+ratchet status # the full state summary
ratchet score confidence
ratchet snapshot repo
```
@@ -27,7 +33,8 @@ ratchet snapshot repo
- **Current objective** — the locked target.
- **Bottleneck** — the chosen blocker.
-- **Confidence** — score/100 + band, and whether the loop is clear to stop.
+- **Confidence** — three scoped layers (artifact · session · ledger health), so a verified
+ patch is never shown as blocked because of unrelated debt.
- **Active / last artifact** — what exists now.
- **Last decision** — the most recent commitment.
- **Open defects** — unresolved failures, by severity.
@@ -37,7 +44,7 @@ ratchet snapshot repo
## Output contract
-Return the rendered state verbatim, then one line of interpretation:
+Return the rendered receipt verbatim, then one line of interpretation:
```
READ:
diff --git a/src/cli.js b/src/cli.js
index cf10dff..d64d619 100644
--- a/src/cli.js
+++ b/src/cli.js
@@ -11,6 +11,8 @@ const repo = require('./repoSnapshot');
const gitRefs = require('./gitRefs');
const coldStart = require('./coldStart');
const md = require('./markdown');
+const receipt = require('./receipt');
+const journal = require('./evolve/journal');
const schemas = require('./schemas');
const VERSION = require('../package.json').version;
@@ -88,6 +90,39 @@ function strOpt(v) {
return v == null || v === true ? '' : String(v).trim();
}
+// ---------------------------------------------------------------------------
+// Agent memory isolation (propose-only). Registered agents get isolated memory
+// by ROLE, enforced at the CLI boundary: only the scribe writes canonical state.
+// A builder or auditor is a propose-only agent — it emits the mutation for the
+// caller (or the scribe) to run, and its own process is refused write access, so
+// two agents can never clobber each other's record. Identity comes from
+// RATCHET_AGENT; the writer set is the scribe (and the unset/main caller). This
+// is a guard, not a sandbox: it makes the propose-only contract the agents
+// already follow impossible to violate by accident.
+// ---------------------------------------------------------------------------
+
+const WRITER_AGENTS = new Set(['scribe']);
+
+// Returns the propose-only agent name if one is active, else ''. The main caller
+// (RATCHET_AGENT unset) and the scribe both return '' — they may write.
+function proposeOnlyAgent() {
+ const a = (process.env.RATCHET_AGENT || '').trim().toLowerCase();
+ return a && !WRITER_AGENTS.has(a) ? a : '';
+}
+
+// Throw before any canonical-state mutation if a propose-only agent is driving.
+// The message tells the agent what to do instead — emit the command, don't run it.
+function assertMayWrite(action) {
+ const a = proposeOnlyAgent();
+ if (a) {
+ throw new Error(
+ `agent "${a}" has propose-only memory and may not mutate canonical state (${action}). ` +
+ 'Emit the exact command for the caller or the ratchet-scribe to run instead. ' +
+ '(Only the scribe writes canonical state; unset RATCHET_AGENT for the main caller, or set it to scribe.)'
+ );
+ }
+}
+
// ---------------------------------------------------------------------------
// Command table.
// ---------------------------------------------------------------------------
@@ -118,18 +153,37 @@ function run(argv) {
return out(`ratchet ${VERSION}`);
case 'init': {
+ // `init --force` resets the store — a canonical mutation. Plain init only
+ // ensures the dir exists, so a propose-only agent may still orient.
+ if (flags.has('--force')) assertMayWrite('init --force');
const res = state.initProject(cwd, { force: flags.has('--force') });
return out(`Ratchet initialized at ${res.dir}${res.created ? '' : ' (already existed)'}`);
}
case 'state':
- return cmdState(cwd, sub, rest, asJson);
+ return cmdState(cwd, sub, rest, asJson, flags);
+
+ case 'receipt': {
+ const r = receipt.assemble(cwd);
+ // --save writes the source-of-truth index: one always-current file a cold
+ // agent can read instead of doing archaeology. Derived + regenerable, so it
+ // is not a canonical-state write (propose-only agents may refresh it).
+ if (flags.has('--save')) {
+ const dir = path.join(cwd, '.ratchet');
+ fs.mkdirSync(dir, { recursive: true });
+ fs.writeFileSync(path.join(dir, 'current.json'), JSON.stringify(r, null, 2) + '\n', 'utf8');
+ fs.writeFileSync(path.join(dir, 'current.md'), md.receipt(r) + '\n', 'utf8');
+ if (!asJson) out(`saved .ratchet/current.json + .ratchet/current.md`);
+ }
+ return out(asJson ? JSON.stringify(r, null, 2) : md.receipt(r));
+ }
case 'ledger':
return cmdLedger(cwd, sub, rest, asJson);
case 'artifact': {
if (sub !== 'add') throw new Error('usage: ratchet artifact add ');
+ assertMayWrite('artifact add');
const rec = artifacts.addArtifact(cwd, readPayload(rest[0]));
return out(`artifact ${rec.id} added: ${rec.title} (${rec.status})`);
}
@@ -167,6 +221,7 @@ function run(argv) {
case 'compile': {
if (sub !== 'done') throw new Error('usage: ratchet compile done');
+ assertMayWrite('compile done');
return cmdCompileDone(cwd);
}
@@ -177,6 +232,7 @@ function run(argv) {
case 'touch': {
const file = sub;
if (!file) throw new Error('usage: ratchet touch ');
+ assertMayWrite('touch');
const s = state.loadState(cwd);
s.touchedFiles.push({ path: file, at: schemas.nowIso() });
s.dirty = true;
@@ -194,7 +250,7 @@ function run(argv) {
}
}
-function cmdState(cwd, sub, rest, asJson) {
+function cmdState(cwd, sub, rest, asJson, flags = new Set()) {
switch (sub) {
case undefined:
case 'get': {
@@ -207,6 +263,7 @@ function cmdState(cwd, sub, rest, asJson) {
return out(asJson ? JSON.stringify(s, null, 2) : md.stateSummary(s));
}
case 'set': {
+ assertMayWrite('state set');
const [key, ...valueParts] = rest;
if (!key) throw new Error('usage: ratchet state set ');
if (!schemas.STATE_SCALARS.has(key)) {
@@ -221,6 +278,7 @@ function cmdState(cwd, sub, rest, asJson) {
return out(`${key} set`);
}
case 'append': {
+ assertMayWrite('state append');
const [collection, payloadArg] = rest;
if (!schemas.STATE_COLLECTIONS[collection]) {
throw new Error(`unknown collection "${collection}". valid: ${Object.keys(schemas.STATE_COLLECTIONS).join(', ')}`);
@@ -234,6 +292,17 @@ function cmdState(cwd, sub, rest, asJson) {
return out(`appended to ${collection}: ${record.id}`);
}
case 'reset': {
+ assertMayWrite('state reset');
+ // Authority gate: wiping session state (objective, defects, artifacts,
+ // decisions, history) is irreversible. Like every other irreversible verb
+ // in the CLI, it must be explicitly authorized — a bare `state reset` must
+ // not be able to erase the record by accident.
+ if (!flags.has('--force')) {
+ throw new Error(
+ 'ratchet state reset wipes all session state (objective, defects, artifacts, decisions, history) — ' +
+ 'this is irreversible. Re-run with --force to authorize.'
+ );
+ }
state.initProject(cwd, { force: true });
return out('state reset');
}
@@ -245,10 +314,12 @@ function cmdState(cwd, sub, rest, asJson) {
function cmdLedger(cwd, sub, rest, asJson) {
switch (sub) {
case 'create': {
+ assertMayWrite('ledger create');
const l = ledger.create(cwd);
return out(`ledger ready (${md.dash(l.updatedAt)})`);
}
case 'update': {
+ assertMayWrite('ledger update');
const [collection, payloadArg] = rest;
const res = ledger.upsert(cwd, collection, readPayload(payloadArg));
return out(`${res.action} ${collection}: ${res.item.id}`);
@@ -282,6 +353,9 @@ function cmdDefect(cwd, argv, asJson) {
return val;
};
+ // Read verbs (list/get) are open to every agent; the mutating verbs are not.
+ if (sub && sub !== 'list' && sub !== 'get') assertMayWrite(`defect ${sub}`);
+
switch (sub) {
case 'add': {
const rec = artifacts.addDefect(cwd, readPayload(positionals[2]));
@@ -346,6 +420,7 @@ function cmdDefect(cwd, argv, asJson) {
// reason (proof-gate spirit: never retract silently); --superseded-by links the
// replacement so provenance survives.
function cmdRetract(cwd, argv) {
+ assertMayWrite('retract');
const { positionals, opts } = parseArgv(argv, {});
const id = positionals[1];
if (!id) throw new Error('usage: ratchet retract --reason "" [--superseded-by ]');
@@ -364,11 +439,22 @@ function cmdScore(cwd, sub, rest, asJson) {
}
case 'confidence': {
const s = state.loadState(cwd);
- const c = scoring.scoreConfidence(s);
- if (asJson) return out(JSON.stringify(c, null, 2));
- s.confidence = c.score;
- state.saveState(cwd, s);
- return out(md.confidence(s));
+ const ledger = state.loadLedger(cwd);
+ let events = [];
+ try {
+ events = journal.readEvents(cwd);
+ } catch (_e) {
+ events = [];
+ }
+ const layers = scoring.scoreConfidenceLayers(s, ledger, events);
+ if (asJson) return out(JSON.stringify(layers, null, 2));
+ // Cache the session score back into state (a write). A propose-only agent
+ // still gets the read — it just leaves no footprint.
+ if (!proposeOnlyAgent()) {
+ s.confidence = layers.session.score;
+ state.saveState(cwd, s);
+ }
+ return out(md.confidenceLayers(layers));
}
case 'aperture': {
const result = scoring.scoreAperture(readPayload(rest[0]));
@@ -608,6 +694,10 @@ function help() {
[
`ratchet ${VERSION} — consequence-engine state tooling`,
'',
+ 'RESUME',
+ ' ratchet receipt [--json] [--save] one stable cockpit: target·delta·proof·verdict·risk·authority·state·next',
+ ' --save writes .ratchet/current.json + .md (the source-of-truth index)',
+ '',
'STATE',
' ratchet init [--force] create/reset project data dir',
' ratchet status [--json] render current state',
@@ -615,7 +705,7 @@ function help() {
' ratchet state set set objective|title|bottleneck|phase|nextAction|nextCommand|...',
' ratchet state append append to decisions|artifacts|defects|assumptions|openLoops|history',
' ratchet compile done mark session compiled: clear dirty + stamp lastCompileAt',
- ' ratchet state reset wipe session state',
+ ' ratchet state reset --force wipe session state (irreversible — --force required)',
'',
'ARTIFACTS & DEFECTS',
' ratchet artifact add record an artifact ({title,kind,status,path,holes})',
@@ -635,7 +725,7 @@ function help() {
'',
'SCORING',
' ratchet score friction rank obstacles ([{name,leverage,certainty,speed,risk}])',
- ' ratchet score confidence compute session confidence + loop-clear',
+ ' ratchet score confidence three scoped layers: artifact · session · ledger health',
' ratchet score aperture meter loop depth from uncertainty ({ambiguity,terrain,taste,blastRadius,reversibility} 0-2)',
'',
'CONTEXT',
@@ -649,6 +739,11 @@ function help() {
' ratchet doctor cold-start [--json] scan for stale steering (opt-in surfaces via .ratchet/cold-start.json)',
'',
'json args accept a raw string, @file, or - for stdin.',
+ '',
+ 'AGENT MEMORY (isolation by role)',
+ ' RATCHET_AGENT= identify the driving agent. Only the scribe writes canonical',
+ ' state; builder/auditor are propose-only — mutating verbs are',
+ ' refused so they emit the command instead of clobbering the record.',
].join('\n')
);
}
diff --git a/src/gitRefs.js b/src/gitRefs.js
index 7f51a1a..3caf7e7 100644
--- a/src/gitRefs.js
+++ b/src/gitRefs.js
@@ -66,6 +66,10 @@ function statusRefs(cwd = process.cwd()) {
const remoteContains = safeGit(['branch', '-r', '--contains', 'HEAD'], cwd);
const remoteContainsHead = Boolean(remoteContains && remoteContains.trim());
+ // Tags pointing at HEAD — the signal for "released" in the authority ladder.
+ const tagsRaw = safeGit(['tag', '--points-at', 'HEAD'], cwd);
+ const headTags = tagsRaw ? tagsRaw.split('\n').map((t) => t.trim()).filter(Boolean) : [];
+
return {
isRepo: true,
branch,
@@ -75,6 +79,7 @@ function statusRefs(cwd = process.cwd()) {
comparisons,
unpushed,
remoteContainsHead,
+ headTags,
};
}
diff --git a/src/markdown.js b/src/markdown.js
index 0849443..a89fbec 100644
--- a/src/markdown.js
+++ b/src/markdown.js
@@ -43,6 +43,9 @@ function stateSummary(state) {
lines.push(`- **Next action:** ${dash(state.nextAction)}`);
lines.push(`- **Next command:** ${dash(state.nextCommand)}`);
if (stale) lines.push(`- ⚠️ **Stale:** work changed since last compile — run \`/ratchet:compile\`.`);
+ // The confidence figure above is scoped to recorded state, not code
+ // correctness — say so, so the summary never reads as a ship-readiness claim.
+ if (conf.scope) lines.push(`- _Confidence scope: ${conf.scope}._`);
return lines.join('\n');
}
@@ -67,6 +70,10 @@ function friction(result) {
out.push(`Beats runner-up "${result.runnerUp.name}" (${result.runnerUp.priority}) by ${result.margin}.`);
}
}
+ if (result.scope) {
+ out.push('');
+ out.push(`_Scope: ${result.scope}._`);
+ }
return out.join('\n');
}
@@ -87,9 +94,39 @@ function confidence(state) {
? '**Loop may stop:** no critical/high debt, no untested assumption, next action defined.'
: '**Loop must continue:** unresolved critical/high debt, untested assumptions, or missing next action.'
);
+ if (c.scope) {
+ out.push('');
+ out.push(`_Scope: ${c.scope}._`);
+ }
return out.join('\n');
}
+// Three-layer confidence. Rendered as three independently-scoped scores so a
+// verified artifact never reads as "blocked" because of unrelated ledger debt —
+// the exact gaslighting a single blunt score used to produce.
+function confidenceLayers(layers) {
+ const o = [];
+ o.push('### Confidence — three layers, each scoped');
+ o.push('');
+ const render = (title, c) => {
+ if (!c) return;
+ const scoreStr = c.score == null ? '—' : `${c.score}/100`;
+ o.push(`**${title}: ${scoreStr} (${c.band})**`);
+ if (c.scope) o.push(`_Scope: ${c.scope}._`);
+ const reasons = c.reasons || (c.penalties ? c.penalties.map((p) => `${p.reason} (−${p.cost})`) : []);
+ for (const r of reasons || []) o.push(`- ${r}`);
+ if (c.layer === 'session' && typeof c.loopClear === 'boolean') {
+ o.push(`- loop ${c.loopClear ? 'may stop' : 'must continue'}`);
+ }
+ o.push('');
+ };
+ render('Artifact confidence', layers.artifact);
+ render('Session confidence', layers.session);
+ render('Ledger health', layers.ledger);
+ o.push('_The layers are independent: a verified patch stays high on artifact confidence even when ledger health is low._');
+ return o.join('\n');
+}
+
// `○` = still draining confidence, `●` = terminal (resolved/waived/superseded).
// The aperture dial: how much of the loop this task earns, and the metered
// sequence of ratchet skills to run at that depth.
@@ -106,6 +143,7 @@ function aperture(a) {
: '**Implement:** NO — lock constraints and produce options first; do not build yet.'
);
o.push(`**Metered loop:** ${a.sequence.map((s) => `\`/ratchet:${s}\``).join(' → ')}`);
+ if (a.scope) o.push(`_Scope: ${a.scope}._`);
return o.join('\n');
}
@@ -223,4 +261,166 @@ function fullExport(state, ledger) {
return out.join('\n');
}
-module.exports = { stateSummary, friction, confidence, aperture, defectList, defectOne, gitStatusRefs, repoSnapshot, fullExport, dash, bullets };
+// The receipt — the ratchet cockpit. One stable shape, rendered the same way
+// every time: eight fixed sections in a fixed order, each ALWAYS present.
+// Emptiness is stated ("—"), never omitted — a section that sometimes disappears
+// would force the reader to wonder whether it is empty or missing, which is the
+// archaeology the receipt exists to end. SEAM lives inside PROOF (it is an
+// evidence-quality fact); RISK and the authority ladder are first-class.
+function receipt(r) {
+ const em = (v) => (v == null || v === '' ? '—' : v);
+ const conf = (c) => (c && c.score != null ? `${c.score}/100 (${c.band})` : c ? `— (${c.band})` : '—');
+ const o = [];
+ o.push('## Ratchet receipt');
+ o.push(
+ `_valid as of ${em(r.validAsOf)} · one read: target · delta · proof · verdict · risk · authority · state · next_`
+ );
+ o.push('');
+
+ // 1 — TARGET: what we are steering toward.
+ o.push('**TARGET**');
+ if (r.target.locked) {
+ o.push(`- Objective: ${em(r.target.objective)}`);
+ o.push(`- Bottleneck: ${em(r.target.bottleneck)}`);
+ } else {
+ o.push('- — not locked (run `/ratchet:lock`)');
+ }
+ if (r.target.evolveTarget && (r.target.evolveTarget.target || r.target.evolveTarget.goal)) {
+ o.push(`- Evolving: \`${em(r.target.evolveTarget.target)}\` — ${em(r.target.evolveTarget.goal)}`);
+ }
+ o.push('');
+
+ // 2 — DELTA: what changed since the last serialization.
+ o.push('**DELTA** _(changed since last compile)_');
+ if (r.delta.touched.length) {
+ o.push(`- Touched: ${r.delta.touched.map((f) => `\`${f}\``).join(', ')}`);
+ } else {
+ o.push('- — nothing touched since last compile');
+ }
+ if (r.delta.lastArtifact) {
+ o.push(`- Last artifact: ${em(r.delta.lastArtifact.title)} (${em(r.delta.lastArtifact.kind)}, ${em(r.delta.lastArtifact.status)})`);
+ }
+ o.push(`- Uncompiled work: ${r.delta.stale ? 'yes — run `/ratchet:compile`' : 'no'}`);
+ o.push('');
+
+ // 3 — PROOF: evidence, not assertion — including whether the seam justifies a ship.
+ o.push('**PROOF** _(evidence, not assertion)_');
+ if (r.proof.keep) {
+ const k = r.proof.keep;
+ o.push(`- KEEP \`${em(k.id)}\`: ${em(k.mutation)}`);
+ o.push(
+ ` - evidence: ${em(k.evidenceType)} · result: ${em(k.result)}${k.independent === false ? ' · ⚠ not independent' : ''}`
+ );
+ if (k.commands && k.commands.length) o.push(` - commands: ${k.commands.map((c) => `\`${c}\``).join(', ')}`);
+ if (k.manualChecks && k.manualChecks.length) o.push(` - checks: ${k.manualChecks.join('; ')}`);
+ } else {
+ o.push('- — no proven KEEP on record');
+ }
+ const seam = r.proof.seam || { present: false };
+ if (seam.present) {
+ o.push(`- Seam: tested \`${em(seam.testedSeam)}\` → ships \`${em(seam.shipSeam)}\``);
+ o.push(
+ ` - match: **${em(seam.seamMatch)}**${seam.independent === false ? ' · ⚠ not independent from builder method' : ''}${seam.proxyWarning ? ' · ⚠ proxy warning' : ''}`
+ );
+ if (seam.waiver && seam.waiver.by) o.push(` - waived by ${em(seam.waiver.by)}: ${em(seam.waiver.reason)}`);
+ } else {
+ o.push('- Seam: — none declared (no evolve KEEP on a ship path yet)');
+ }
+ if (r.proof.shipDecision === 'cannot-justify') {
+ o.push('- ⚠ **Cannot justify ship decision** — evidence is proxy-only (seam is not exact and not waived).');
+ } else if (r.proof.shipDecision === 'justified') {
+ o.push('- Ship decision: justified by the evidence above.');
+ }
+ if (r.proof.resolvedWithEvidence.length) {
+ o.push(`- Defects cleared with proof: ${r.proof.resolvedWithEvidence.length}`);
+ }
+ o.push('');
+
+ // 4 — VERDICT: the loop verdict + three independently-scoped confidences.
+ o.push('**VERDICT**');
+ o.push(`- Loop: ${em(r.verdict.loop)}`);
+ o.push(`- Artifact confidence: ${conf(r.verdict.artifact)} — is this patch good?`);
+ o.push(
+ `- Session confidence: ${conf(r.verdict.session)} — ${r.verdict.session && r.verdict.session.loopClear ? 'loop may stop' : 'loop must continue'}`
+ );
+ o.push(`- Ledger health: ${conf(r.verdict.ledger)} — historical QA hygiene`);
+ o.push('- _Each score is scoped; a verified artifact stays high even when ledger health is low._');
+ o.push('');
+
+ // 5 — RISK: what must not be trusted yet.
+ o.push('**RISK** _(what must not be trusted yet)_');
+ if (r.risk && r.risk.length) {
+ for (const rk of r.risk.slice(0, 8)) o.push(`- ${em(rk.text)} _(${em(rk.from)})_`);
+ if (r.risk.length > 8) o.push(`- … and ${r.risk.length - 8} more`);
+ } else {
+ o.push('- — none recorded');
+ }
+ o.push('');
+
+ // 6 — AUTHORITY: how far this traveled + irreversible actions and their owners.
+ o.push('**AUTHORITY** _(irreversible actions need a named owner)_');
+ if (r.authority.authorityState) o.push(`- State: **${em(r.authority.authorityState.label)}**`);
+ const anyAuth =
+ r.authority.waivedDefects.length || r.authority.retractedArtifacts.length || r.authority.seamWaivers.length;
+ if (anyAuth) {
+ for (const w of r.authority.waivedDefects) o.push(`- waived defect \`${em(w.id)}\` by ${em(w.by)}: ${em(w.reason)}`);
+ for (const a of r.authority.retractedArtifacts) {
+ o.push(`- retracted \`${em(a.id)}\` (${em(a.title)}): ${em(a.reason)}${a.supersededBy ? ` → superseded by ${a.supersededBy}` : ''}`);
+ }
+ for (const sw of r.authority.seamWaivers) o.push(`- seam waiver \`${em(sw.id)}\` by ${em(sw.by)}: ${em(sw.reason)}`);
+ } else {
+ o.push('- No irreversible action taken; none pending.');
+ }
+ if (r.authority.enforced && r.authority.enforced.length) {
+ o.push(`- _Gates in force: ${r.authority.enforced.map((e) => e.split(' — ')[0]).join(' · ')}._`);
+ }
+ o.push('');
+
+ // 7 — STATE: what is true / safe / blocked right now.
+ o.push('**STATE** _(true / safe / blocked)_');
+ o.push(`- Phase: ${em(r.state.phase)}`);
+ if (r.state.openDefects.length) {
+ o.push(`- Open defects: ${r.state.openDefects.map((d) => `[${d.severity}] ${d.summary}`).slice(0, 5).join('; ')}`);
+ } else {
+ o.push('- Open defects: none');
+ }
+ o.push(`- Untested assumptions: ${r.state.untested} · open loops: ${r.state.openLoops}`);
+ if (r.state.git) {
+ const cmp = (r.state.git.comparisons || []).map((c) => `${c.base} +${c.ahead}/-${c.behind}`).join(', ') || '—';
+ o.push(`- Git: ${em(r.state.git.branch)} @ ${em(r.state.git.head)} — ${r.state.git.dirty ? 'dirty' : 'clean'} · vs ${cmp}`);
+ } else {
+ o.push('- Git: not a repository');
+ }
+ const cp = r.controlPlane || { ok: true, configured: false, failures: 0, warnings: 0, checks: [] };
+ const cpStatus = cp.failures ? 'FAIL' : cp.warnings ? 'WARN' : 'clean';
+ const cpScope = cp.configured ? 'generic + configured surfaces' : 'generic checks only';
+ o.push(`- Control-plane scan: ${cpStatus} (${cp.failures || 0} fail, ${cp.warnings || 0} warn · ${cpScope})`);
+ const controlFindings = (cp.checks || []).filter((c) => c.level === 'fail' || c.level === 'warn').slice(0, 5);
+ for (const c of controlFindings) {
+ o.push(` - ${String(c.level || 'warn').toUpperCase()} ${em(c.name)}${c.detail ? ` — ${c.detail}` : ''}`);
+ }
+ if ((cp.failures || 0) + (cp.warnings || 0) > controlFindings.length) {
+ o.push(` - … and ${(cp.failures || 0) + (cp.warnings || 0) - controlFindings.length} more control-plane finding(s)`);
+ }
+ o.push('');
+
+ // 8 — NEXT: the single next move.
+ o.push('**NEXT**');
+ if (r.next.action || r.next.command) {
+ o.push(`- Action: ${em(r.next.action)}`);
+ o.push(`- Command: ${em(r.next.command)}`);
+ } else {
+ o.push('- — undefined (a cold session has no first move; run `/ratchet:lock` or `/ratchet:ignite`)');
+ }
+ if (r.next.edge) o.push(`- Next edge: ${r.next.edge}`);
+
+ if (r.gaps && r.gaps.length) {
+ o.push('');
+ o.push('**OPEN LOOPS**');
+ for (const g of r.gaps) o.push(`- ${em(g.text)} — ${em(g.status)}`);
+ }
+
+ return o.join('\n');
+}
+
+module.exports = { stateSummary, friction, confidence, confidenceLayers, aperture, receipt, defectList, defectOne, gitStatusRefs, repoSnapshot, fullExport, dash, bullets };
diff --git a/src/receipt.js b/src/receipt.js
new file mode 100644
index 0000000..630c2d9
--- /dev/null
+++ b/src/receipt.js
@@ -0,0 +1,257 @@
+'use strict';
+
+const state = require('./state');
+const scoring = require('./scoring');
+const journal = require('./evolve/journal');
+const gitRefs = require('./gitRefs');
+const coldStart = require('./coldStart');
+
+// The receipt: one stable, always-same-shape answer to "what is true, what
+// changed, what was proven, what is at risk, what is safe, and what happens
+// next" — so a cold human or agent resumes without transcript archaeology. It
+// joins the records the loop keeps: session state, the evolve journal
+// (proof/seam/verdict), the QA ledger (historical health), and git (authority
+// state). Every field is always present; emptiness is stated explicitly, never
+// omitted, so the shape does not shift between commands or sessions — that
+// stability is what makes it readable in under a minute.
+
+function uniq(arr) {
+ return [...new Set(arr)];
+}
+
+// Files touched since the last compile — the honest "what changed" set. Before
+// the first compile, everything tracked counts as uncompiled delta.
+function touchedSinceCompile(s) {
+ const compileAt = s.lastCompileAt || '';
+ const touched = Array.isArray(s.touchedFiles) ? s.touchedFiles : [];
+ const since = compileAt ? touched.filter((f) => f && f.at && f.at > compileAt) : touched;
+ return uniq(since.map((f) => f && f.path).filter(Boolean));
+}
+
+// The authority ladder: how far this work has traveled toward irreversibility.
+// Dirty tree wins (there is uncommitted work on top of everything else), then a
+// tag (released), then a remote branch containing HEAD (pushed), else local.
+function authorityState(git) {
+ if (!git || !git.isRepo) return { level: 'no-vcs', label: 'not a git repository' };
+ if (git.dirty) return { level: 'uncommitted', label: 'uncommitted — working tree dirty' };
+ if (git.headTags && git.headTags.length) {
+ return { level: 'released', label: `released — tag ${git.headTags.join(', ')}` };
+ }
+ if (git.remoteContainsHead) return { level: 'pushed', label: 'pushed — a remote branch contains HEAD' };
+ return { level: 'committed-local', label: 'committed-local — HEAD is on no remote branch' };
+}
+
+function assemble(cwd = process.cwd()) {
+ const s = state.loadState(cwd);
+ let ledger = {};
+ try {
+ ledger = state.loadLedger(cwd);
+ } catch (_e) {
+ ledger = {};
+ }
+
+ let events = [];
+ try {
+ events = journal.readEvents(cwd);
+ } catch (_e) {
+ events = [];
+ }
+ const lastEvent = events.length ? events[events.length - 1] : null;
+ const lastKeep = [...events].reverse().find((e) => e && e.verdict === 'KEEP') || null;
+ // Seam is read from the most recent proven KEEP if there is one, else the last
+ // event — a REVERT still carries the seam it was judged on.
+ const seamSource = lastKeep || lastEvent;
+
+ const layers = scoring.scoreConfidenceLayers(s, ledger, events);
+
+ let git = null;
+ try {
+ git = gitRefs.statusRefs(cwd);
+ } catch (_e) {
+ git = null;
+ }
+
+ let controlScan = { ok: true, configured: false, checks: [] };
+ try {
+ controlScan = coldStart.scan(cwd);
+ } catch (e) {
+ // A control-plane scan that crashes is itself unsafe steering. Keep the
+ // receipt usable, but say the one cold read cannot be trusted as clean.
+ controlScan = {
+ ok: false,
+ configured: false,
+ checks: [{ name: 'cold-start scan', level: 'fail', detail: e && e.message ? e.message : String(e) }],
+ };
+ }
+ const controlChecks = Array.isArray(controlScan.checks) ? controlScan.checks : [];
+ const controlFailures = controlChecks.filter((c) => c.level === 'fail').length;
+ const controlWarnings = controlChecks.filter((c) => c.level === 'warn').length;
+
+ const artifactsArr = s.artifacts || [];
+ const defectsArr = s.defects || [];
+ const liveArtifacts = artifactsArr.filter((a) => a.status !== 'retracted' && a.status !== 'superseded');
+ const lastArtifact = artifactsArr[artifactsArr.length - 1] || null;
+ const openDefects = defectsArr.filter(scoring.isDefectOpen);
+ const untested = (s.assumptions || []).filter((a) => a.status !== 'tested' && a.status !== 'killed');
+ const openLoops = (s.openLoops || []).filter((l) => l.status !== 'closed');
+ const stale = Boolean(s.dirty && (!s.lastCompileAt || s.lastCompileAt < s.updatedAt));
+
+ // PROOF — the evidence card for the most recent KEEP, plus defects cleared
+ // with recorded proof. The KEEP gate already guarantees this data exists for a
+ // kept mutation; the receipt just makes it a first-class card.
+ const resolvedWithEvidence = defectsArr
+ .filter((d) => (d.status === 'resolved' || d.status === 'closed') && d.evidence)
+ .map((d) => ({ id: d.id, summary: d.summary, evidence: d.evidence }));
+
+ const keepCard = lastKeep
+ ? {
+ id: lastKeep.id || '',
+ target: lastKeep.target || '',
+ mutation: lastKeep.chosenMutation || '',
+ mode: lastKeep.mode || '',
+ evidenceType: (lastKeep.seam && lastKeep.seam.evidenceType) || '',
+ result: (lastKeep.verification && lastKeep.verification.result) || '',
+ commands: (lastKeep.verification && lastKeep.verification.commands) || [],
+ manualChecks: (lastKeep.verification && lastKeep.verification.manualChecks) || [],
+ independent: lastKeep.seam ? lastKeep.seam.independentFromBuilderMethod : null,
+ }
+ : null;
+
+ const seam =
+ seamSource && seamSource.seam
+ ? {
+ present: Boolean(
+ seamSource.seam.testedSeam || seamSource.seam.shipSeam || seamSource.seam.seamMatch
+ ),
+ testedSeam: seamSource.seam.testedSeam || '',
+ shipSeam: seamSource.seam.shipSeam || '',
+ seamMatch: seamSource.seam.seamMatch || '',
+ independent: seamSource.seam.independentFromBuilderMethod,
+ proxyWarning: seamSource.seam.proxyWarning,
+ waiver: seamSource.seam.waiver || null,
+ fromVerdict: seamSource.verdict || '',
+ }
+ : { present: false };
+
+ // Ship decision: can the current evidence justify shipping? Exact seam (or a
+ // named waiver) → justified. Any proxy/weak/mismatch seam → cannot-justify,
+ // said out loud so proxy proof never masquerades as ship proof.
+ let shipDecision = 'n/a';
+ if (seam.present && seam.seamMatch) {
+ const waived = Boolean(seam.waiver && seam.waiver.by);
+ shipDecision = seam.seamMatch === 'exact' || waived ? 'justified' : 'cannot-justify';
+ }
+
+ // RISK — what is still open. Remaining risks the loop recorded, plus live
+ // artifact holes, plus open critical/high defects. This is the "what must not
+ // be trusted yet" surface.
+ const risk = [];
+ if (lastEvent && Array.isArray(lastEvent.remainingRisks)) {
+ for (const t of lastEvent.remainingRisks) risk.push({ text: t, from: 'evolve' });
+ }
+ for (const a of liveArtifacts) {
+ for (const h of Array.isArray(a.holes) ? a.holes : []) risk.push({ text: `${a.title}: ${h}`, from: 'artifact-hole' });
+ }
+ for (const d of openDefects) {
+ const sev = (d.severity || '').toLowerCase();
+ if (sev === 'critical' || sev === 'high') risk.push({ text: `[${d.severity}] ${d.summary}`, from: 'defect' });
+ }
+
+ // AUTHORITY — every irreversible action taken and the named owner who
+ // authorized it. An irreversible action with no owner is exactly what the
+ // gates refuse, so this list can only be populated by authorized moves.
+ const waivedDefects = defectsArr
+ .filter((d) => d.status === 'waived')
+ .map((d) => ({ id: d.id, summary: d.summary, by: d.waivedBy || '', reason: d.waiveReason || '' }));
+ const retractedArtifacts = artifactsArr
+ .filter((a) => a.status === 'retracted')
+ .map((a) => ({
+ id: a.id,
+ title: a.title,
+ reason: (a.retracted && a.retracted.reason) || '',
+ supersededBy: (a.retracted && a.retracted.supersededBy) || '',
+ }));
+ const seamWaivers = events
+ .filter((e) => e && e.seam && e.seam.waiver && e.seam.waiver.by)
+ .map((e) => ({ id: e.id, by: e.seam.waiver.by, reason: e.seam.waiver.reason || '' }));
+
+ return {
+ validAsOf: s.updatedAt || '',
+ target: {
+ locked: Boolean(s.objective && String(s.objective).trim()),
+ objective: s.objective || '',
+ bottleneck: s.bottleneck || '',
+ evolveTarget: lastEvent ? { target: lastEvent.target || '', goal: lastEvent.goal || '' } : null,
+ },
+ delta: {
+ stale,
+ dirty: Boolean(s.dirty),
+ lastCompileAt: s.lastCompileAt || null,
+ touched: touchedSinceCompile(s),
+ lastArtifact: lastArtifact
+ ? { title: lastArtifact.title, kind: lastArtifact.kind, status: lastArtifact.status }
+ : null,
+ gitDirty: git ? Boolean(git.dirty) : null,
+ },
+ proof: {
+ keep: keepCard,
+ seam,
+ shipDecision,
+ resolvedWithEvidence,
+ },
+ verdict: {
+ loop: lastEvent ? lastEvent.verdict : '',
+ artifact: layers.artifact,
+ session: layers.session,
+ ledger: layers.ledger,
+ },
+ risk,
+ controlPlane: {
+ ok: Boolean(controlScan.ok),
+ configured: Boolean(controlScan.configured),
+ failures: controlFailures,
+ warnings: controlWarnings,
+ checks: controlChecks.map((c) => ({ name: c.name || '', level: c.level || 'warn', detail: c.detail || '' })),
+ },
+ authority: {
+ authorityState: authorityState(git),
+ waivedDefects,
+ retractedArtifacts,
+ seamWaivers,
+ enforced: [
+ 'state reset — requires --force',
+ 'defect resolve — requires --evidence',
+ 'defect waive — requires --owner + --reason',
+ 'artifact retract — requires --reason',
+ 'code KEEP — requires exact ship-seam match or a named waiver',
+ 'canonical writes — only the scribe; builder/auditor are propose-only (RATCHET_AGENT)',
+ ],
+ },
+ state: {
+ phase: s.phase || 'idle',
+ openDefects: openDefects.map((d) => ({ severity: d.severity, summary: d.summary })),
+ untested: untested.length,
+ openLoops: openLoops.length,
+ dirty: Boolean(s.dirty),
+ stale,
+ git:
+ git && git.isRepo
+ ? {
+ branch: git.branch,
+ head: git.head,
+ dirty: git.dirty,
+ comparisons: git.comparisons || [],
+ unpushed: git.unpushed,
+ }
+ : null,
+ },
+ next: {
+ action: s.nextAction || '',
+ command: s.nextCommand || '',
+ edge: lastEvent ? lastEvent.nextEdge || '' : '',
+ },
+ gaps: openLoops.map((l) => ({ text: l.text, status: l.status })),
+ };
+}
+
+module.exports = { assemble, touchedSinceCompile, authorityState };
diff --git a/src/scoring.js b/src/scoring.js
index e06b2dd..9ce8c2e 100644
--- a/src/scoring.js
+++ b/src/scoring.js
@@ -62,6 +62,9 @@ function scoreFriction(obstacles) {
winner,
runnerUp,
margin: winner && runnerUp ? winner.priority - runnerUp.priority : null,
+ // A score is only honest if it names what it ranked. This ranking sees only
+ // the obstacles supplied — an unlisted blocker is invisible to it.
+ scope: 'only the obstacles supplied — an unlisted blocker is invisible to this ranking',
};
}
@@ -70,6 +73,16 @@ function scoreFriction(obstacles) {
// This is the loop's stop condition: high confidence + zero critical debt.
// ---------------------------------------------------------------------------
+// One band ladder, shared by every confidence layer so "converging" means the
+// same thing whether it describes an artifact, a session, or the ledger.
+function confidenceBand(score) {
+ if (score >= 85) return 'ship-ready';
+ if (score >= 65) return 'converging';
+ if (score >= 40) return 'contested';
+ if (score >= 20) return 'fragile';
+ return 'blocked';
+}
+
function scoreConfidence(state) {
const penalties = [];
const openDefects = (state.defects || []).filter(isDefectOpen);
@@ -105,12 +118,7 @@ function scoreConfidence(state) {
const totalCost = penalties.reduce((s, p) => s + p.cost, 0);
const score = Math.max(0, 100 - totalCost);
-
- let band = 'blocked';
- if (score >= 85) band = 'ship-ready';
- else if (score >= 65) band = 'converging';
- else if (score >= 40) band = 'contested';
- else if (score >= 20) band = 'fragile';
+ const band = confidenceBand(score);
// The loop may stop only when no critical/high debt remains AND nothing
// core is untested AND there is a next action.
@@ -120,7 +128,142 @@ function scoreConfidence(state) {
untested.length === 0 &&
Boolean(state.nextAction && String(state.nextAction).trim());
- return { score, band, loopClear, penalties, openDefects: openDefects.length };
+ return {
+ layer: 'session',
+ score,
+ band,
+ loopClear,
+ penalties,
+ openDefects: openDefects.length,
+ // Naming the scope is what keeps this number from gaslighting: it measures
+ // recorded pressure, not correctness. A high score on an empty ledger means
+ // "nothing is recorded as wrong," never "the code is right."
+ scope:
+ 'the active loop — open defects, untested assumptions, open loops, and whether objective/next-action are set. Whether the loop may stop, not whether any one patch is good',
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Three-layer confidence. A single blunt score was the tool's worst UX bug: a
+// verified-green patch could read "0/blocked" purely because of unrelated
+// historical debt. Splitting the score by SCOPE fixes that — each layer answers
+// a different question and can never be dragged down by the others.
+// artifact : is THIS patch good, on its own evidence?
+// session : can the active loop stop? (scoreConfidence, above)
+// ledger : how healthy is the historical QA record?
+// ---------------------------------------------------------------------------
+
+const ARTIFACT_SCOPE =
+ 'the current live artifact only — its own holes, the defects attached to it, and its verification evidence. Unrelated open defects and ledger history are deliberately invisible';
+const LEDGER_SCOPE =
+ 'the QA ledger\'s historical hygiene — open/stale defects and failing tests across all features. Not a judgment of the current patch';
+
+// Find the evolve event that verified a given artifact, matched by ship target
+// (path) or title. Never falls back to an unrelated event — an artifact with no
+// matching event is honestly "unverified", not borrowed-confidence.
+function verifyingEvent(artifact, events) {
+ if (!artifact || !Array.isArray(events)) return null;
+ const keys = [artifact.path, artifact.title].filter(Boolean);
+ for (let i = events.length - 1; i >= 0; i--) {
+ const e = events[i];
+ if (e && keys.includes(e.target)) return e;
+ }
+ return null;
+}
+
+function scoreArtifactConfidence(state, events = []) {
+ const live = (state.artifacts || []).filter((a) => a.status !== 'retracted' && a.status !== 'superseded');
+ const artifact = live[live.length - 1] || null;
+ const reasons = [];
+ if (!artifact) {
+ return { layer: 'artifact', score: null, band: 'none', artifact: null, reasons: ['no live artifact recorded'], scope: ARTIFACT_SCOPE };
+ }
+
+ let score = 100;
+ const holes = Array.isArray(artifact.holes) ? artifact.holes : [];
+ if (holes.length) {
+ score -= 15 * holes.length;
+ reasons.push(`${holes.length} explicit hole(s) in the artifact`);
+ }
+
+ // Only defects ATTACHED to this artifact and still open — never unrelated debt.
+ const attached = (state.defects || []).filter((d) => d.artifact === artifact.id && isDefectOpen(d));
+ for (const d of attached) {
+ const sev = (d.severity || 'medium').toLowerCase();
+ const cost = sev === 'critical' ? 25 : sev === 'high' ? 15 : sev === 'medium' ? 8 : 3;
+ score -= cost;
+ }
+ if (attached.length) reasons.push(`${attached.length} open defect(s) attached to this artifact`);
+
+ const ev = verifyingEvent(artifact, events);
+ if (!ev) {
+ score -= 10;
+ reasons.push('no independent verification recorded for this artifact');
+ } else if (ev.verdict === 'KEEP') {
+ const seam = ev.seam || {};
+ if (ev.mode === 'code' && seam.seamMatch && seam.seamMatch !== 'exact' && !(seam.waiver && seam.waiver.by)) {
+ score -= 20;
+ reasons.push(`proven only on a ${seam.seamMatch} seam — not the ship seam`);
+ } else {
+ reasons.push('verified: KEEP with acceptable seam evidence');
+ }
+ if (seam.independentFromBuilderMethod === false) {
+ score -= 10;
+ reasons.push('verification repeated the builder method (not independent)');
+ }
+ } else if (ev.verdict === 'REVERT' || ev.verdict === 'REVERTED_AND_LEARNED') {
+ score -= 30;
+ reasons.push(`last verdict was ${ev.verdict} — the change did not hold`);
+ } else if (ev.verdict === 'ASK') {
+ score -= 15;
+ reasons.push('last verdict was ASK — unresolved');
+ }
+
+ score = Math.max(0, Math.min(100, score));
+ return {
+ layer: 'artifact',
+ score,
+ band: confidenceBand(score),
+ artifact: { id: artifact.id, title: artifact.title, status: artifact.status },
+ reasons,
+ scope: ARTIFACT_SCOPE,
+ };
+}
+
+function scoreLedgerHealth(ledger) {
+ const defects = (ledger && ledger.defects) || [];
+ const tests = (ledger && ledger.tests) || [];
+ const openDefects = defects.filter(isDefectOpen).length;
+ const failingTests = tests.filter((t) => t.status === 'fail').length;
+ const reasons = [];
+ let score = 100;
+ if (openDefects) {
+ score -= 10 * openDefects;
+ reasons.push(`${openDefects} open ledger defect(s)`);
+ }
+ if (failingTests) {
+ score -= 8 * failingTests;
+ reasons.push(`${failingTests} failing test(s) in the ledger`);
+ }
+ if (!reasons.length) reasons.push('ledger clean — no open defects, no failing tests');
+ score = Math.max(0, Math.min(100, score));
+ return {
+ layer: 'ledger',
+ score,
+ band: confidenceBand(score),
+ counts: { openDefects, failingTests, defects: defects.length, tests: tests.length },
+ reasons,
+ scope: LEDGER_SCOPE,
+ };
+}
+
+// The three layers together — each scoped, none able to drag the others down.
+function scoreConfidenceLayers(state, ledger, events = []) {
+ return {
+ artifact: scoreArtifactConfidence(state, events),
+ session: scoreConfidence(state),
+ ledger: scoreLedgerHealth(ledger || {}),
+ };
}
// ---------------------------------------------------------------------------
@@ -174,12 +317,18 @@ function scoreAperture(dims) {
implement: band.implement, // A4: do NOT build until constraints are locked
sequence: band.sequence.slice(), // ratchet skills to run at this depth
dimensions: scored,
+ // This reading is only valid for the task as scored, at scoring time.
+ scope: 'the one task scored, at scoring time — re-score if the task or its constraints change',
};
}
module.exports = {
scoreFriction,
scoreConfidence,
+ scoreArtifactConfidence,
+ scoreLedgerHealth,
+ scoreConfidenceLayers,
+ confidenceBand,
scoreAperture,
clamp,
isDefectOpen,
diff --git a/test/cli.test.js b/test/cli.test.js
index 699842f..76312da 100644
--- a/test/cli.test.js
+++ b/test/cli.test.js
@@ -10,6 +10,9 @@ const assert = require('assert');
const tmp = path.join(os.tmpdir(), 'ratchet-test-' + process.pid);
process.env.RATCHET_DATA_DIR = tmp;
+// Isolate the evolve journal too, so receipt assembly never reads or writes the
+// real repo's .ratchet/evolve-log.jsonl.
+process.env.RATCHET_EVOLVE_LOG = path.join(tmp, 'evolve-log.jsonl');
fs.rmSync(tmp, { recursive: true, force: true });
const state = require('../src/state');
@@ -20,6 +23,8 @@ const md = require('../src/markdown');
const repo = require('../src/repoSnapshot');
const gitRefs = require('../src/gitRefs');
const coldStart = require('../src/coldStart');
+const receipt = require('../src/receipt');
+const journal = require('../src/evolve/journal');
const cli = require('../src/cli');
let passed = 0;
@@ -352,5 +357,332 @@ ok('cold-start scanner is clean on healthy state and flags unimplemented checks
assert.strictEqual((r.checks.find((c) => c.name.includes('no-closed-work-as-next')) || {}).level, 'warn');
});
+// --- scores name their scope (no confidence gaslighting) --------------------
+
+ok('every score names its scope', () => {
+ const conf = scoring.scoreConfidence(state.loadState(cwd));
+ assert.strictEqual(conf.layer, 'session', 'session confidence declares its layer');
+ assert.ok(conf.scope && /loop/.test(conf.scope), 'session confidence names its scope');
+ assert.ok(/Scope:/.test(md.confidence(state.loadState(cwd))), 'confidence render names its scope');
+
+ const fr = scoring.scoreFriction([{ name: 'a', leverage: 5, certainty: 5, speed: 5, risk: 5 }]);
+ assert.ok(fr.scope && /unlisted/.test(fr.scope), 'friction discloses it only sees supplied obstacles');
+ assert.ok(/Scope:/.test(md.friction(fr)), 'friction render names its scope');
+
+ const ap = scoring.scoreAperture({ ambiguity: 1, terrain: 1, taste: 1, blastRadius: 1, reversibility: 1 });
+ assert.ok(ap.scope && /re-score/.test(ap.scope), 'aperture is scoped to the task as scored');
+ assert.ok(/Scope:/.test(md.aperture(ap)), 'aperture render names its scope');
+});
+
+// --- authority gate on the one irreversible verb that lacked one ------------
+
+ok('state reset requires explicit authority (--force)', () => {
+ const s = state.loadState(cwd);
+ s.objective = 'do not lose me';
+ state.saveState(cwd, s);
+ assert.throws(() => cli.run(['node', 'ratchet', 'state', 'reset']), /irreversible|--force/);
+ assert.strictEqual(state.loadState(cwd).objective, 'do not lose me', 'a bare reset must not wipe state');
+ cli.run(['node', 'ratchet', 'state', 'reset', '--force']);
+ assert.strictEqual(state.loadState(cwd).objective, '', 'reset --force wipes state');
+});
+
+// --- the receipt: one stable shape, every section always present ------------
+
+ok('receipt renders all eight sections even on empty state', () => {
+ state.initProject(cwd, { force: true });
+ const out = md.receipt(receipt.assemble(cwd));
+ for (const section of ['TARGET', 'DELTA', 'PROOF', 'VERDICT', 'RISK', 'AUTHORITY', 'STATE', 'NEXT']) {
+ assert.ok(out.includes(`**${section}**`), `receipt always shows ${section}`);
+ }
+ assert.ok(/valid as of/.test(out), 'receipt is stamped so a cold reader can tell if it is current');
+ // Empty is stated, not omitted — no section silently disappears.
+ assert.ok(/not locked/.test(out), 'an unlocked target says so');
+ assert.ok(/no proven KEEP/.test(out), 'no-proof state is explicit, not blank');
+ // AUTHORITY renders the standing gates — the "what is safe" policy is visible,
+ // including the sole-writer rule that isolates agent memory.
+ assert.ok(/Gates in force/.test(out), 'the receipt shows which irreversible actions are gated');
+ assert.ok(/canonical writes/.test(out), 'the agent-write isolation gate is visible in the receipt');
+});
+
+ok('receipt surfaces target, next, defects, and authority from real state', () => {
+ state.initProject(cwd, { force: true });
+ const s = state.loadState(cwd);
+ s.objective = 'ship the receipt';
+ s.nextAction = 'run the harness';
+ s.nextCommand = '/ratchet:verify';
+ state.saveState(cwd, s);
+ artifacts.addDefect(cwd, { severity: 'high', summary: 'unproven claim' });
+ const { state: w } = artifacts.addDefect(cwd, { severity: 'medium', summary: 'accepted nit' });
+ cli.run(['node', 'ratchet', 'defect', 'waive', w.id, '--owner', 'danny', '--reason', 'cosmetic, next release']);
+
+ const r = receipt.assemble(cwd);
+ assert.strictEqual(r.target.objective, 'ship the receipt');
+ assert.strictEqual(r.next.action, 'run the harness');
+ assert.ok(r.state.openDefects.some((d) => d.summary === 'unproven claim'), 'open defect shows in STATE');
+ assert.ok(r.authority.waivedDefects.some((d) => d.by === 'danny'), 'waiver shows a named owner in AUTHORITY');
+
+ const out = md.receipt(r);
+ assert.ok(out.includes('ship the receipt'));
+ assert.ok(/waived defect .* by danny/.test(out), 'the receipt names who authorized the waiver');
+});
+
+ok('receipt PROOF renders an evidence card for a KEEP, with its seam', () => {
+ // A KEEP can only be written through the proof + seam gate, so the evidence
+ // the card shows is guaranteed to exist — the receipt just surfaces it.
+ fs.rmSync(process.env.RATCHET_EVOLVE_LOG, { force: true });
+ journal.appendEvent(cwd, {
+ target: 'src/receipt.js',
+ goal: 'stable resume receipt',
+ mode: 'code',
+ chosenMutation: 'assemble eight fixed sections',
+ verdict: 'KEEP',
+ verification: { commands: ['node test/cli.test.js'], result: 'pass' },
+ seam: {
+ evidenceType: 'test',
+ testedSeam: 'ratchet receipt',
+ shipSeam: 'ratchet receipt',
+ seamMatch: 'exact',
+ independentFromBuilderMethod: true,
+ },
+ nextEdge: 'wire remaining skills to end on a receipt',
+ });
+ const r = receipt.assemble(cwd);
+ assert.ok(r.proof.keep, 'a KEEP produces an evidence card');
+ assert.strictEqual(r.proof.keep.result, 'pass');
+ assert.strictEqual(r.proof.seam.seamMatch, 'exact', 'the card carries the ship-seam match');
+ assert.strictEqual(r.proof.shipDecision, 'justified', 'an exact seam justifies the ship decision');
+ assert.strictEqual(r.verdict.loop, 'KEEP');
+ const out = md.receipt(r);
+ assert.ok(/KEEP `/.test(out), 'the evidence card renders with the KEEP id');
+ assert.ok(/tested `ratchet receipt` → ships `ratchet receipt`/.test(out), 'seam is rendered tested→ships under PROOF');
+});
+
+ok('receipt JSON is stable-shaped (all top-level fields present)', () => {
+ const r = receipt.assemble(cwd);
+ for (const key of ['validAsOf', 'target', 'delta', 'proof', 'verdict', 'risk', 'controlPlane', 'authority', 'state', 'next', 'gaps']) {
+ assert.ok(Object.prototype.hasOwnProperty.call(r, key), `receipt object always has ${key}`);
+ }
+ // seam is folded into proof; the three confidences live under verdict.
+ assert.ok(r.proof.seam, 'seam is nested under proof');
+ for (const layer of ['artifact', 'session', 'ledger']) {
+ assert.ok(r.verdict[layer], `verdict carries the ${layer} confidence layer`);
+ }
+ assert.ok(r.authority.authorityState && r.authority.authorityState.level, 'authority names its state on the ladder');
+});
+
+// --- three-layer confidence (no gaslighting a verified patch) ---------------
+
+ok('artifact confidence stays high even when ledger health is low', () => {
+ state.initProject(cwd, { force: true });
+ const s = state.loadState(cwd);
+ // a clean, verified artifact...
+ s.artifacts = [{ id: 'art-good', title: 'F4 fix', kind: 'code', path: 'src/fix.js', status: 'v1', holes: [] }];
+ // ...while unrelated historical debt piles up in the ledger + session
+ s.defects = [
+ { id: 'd-old-1', severity: 'critical', summary: 'unrelated legacy blocker', status: 'open' },
+ { id: 'd-old-2', severity: 'high', summary: 'another unrelated one', status: 'open' },
+ ];
+ state.saveState(cwd, s);
+ const events = [
+ {
+ target: 'src/fix.js', mode: 'code', verdict: 'KEEP',
+ verification: { commands: ['probe'], result: 'pass' },
+ seam: { seamMatch: 'exact', independentFromBuilderMethod: true },
+ },
+ ];
+ const layers = scoring.scoreConfidenceLayers(s, { defects: [], tests: [] }, events);
+ assert.ok(layers.artifact.score >= 85, `verified artifact is ship-ready, got ${layers.artifact.score}`);
+ assert.ok(layers.session.score < 40, 'session confidence is low because of unrelated open blockers');
+ assert.strictEqual(layers.artifact.layer, 'artifact');
+ // the whole point: the patch is not gaslit to "blocked" by unrelated debt
+ assert.notStrictEqual(layers.artifact.band, 'blocked', 'a good patch never reads blocked due to unrelated debt');
+});
+
+ok('terminal defects do not drain artifact confidence', () => {
+ const s = { artifacts: [{ id: 'a1', title: 'x', path: 'p', status: 'v1', holes: [] }],
+ defects: [{ id: 'd', severity: 'critical', summary: 'was fixed', status: 'resolved', artifact: 'a1' }] };
+ const layers = scoring.scoreConfidenceLayers(s, {}, []);
+ // resolved defect attached to the artifact must not drain it
+ assert.ok(layers.artifact.score >= 85, `resolved attached defect should not drain, got ${layers.artifact.score}`);
+});
+
+ok('ledger health is its own scoped score', () => {
+ const health = scoring.scoreLedgerHealth({ defects: [{ severity: 'high', status: 'open' }], tests: [{ status: 'fail' }] });
+ assert.strictEqual(health.layer, 'ledger');
+ assert.ok(health.score < 100, 'open ledger defect + failing test lower ledger health');
+ assert.ok(/hygiene/.test(health.scope), 'ledger health names its scope');
+});
+
+ok('score confidence --json returns three named layers', () => {
+ state.initProject(cwd, { force: true });
+ // capture stdout
+ const chunks = [];
+ const orig = process.stdout.write;
+ process.stdout.write = (str) => { chunks.push(String(str)); return true; };
+ try {
+ cli.run(['node', 'ratchet', 'score', 'confidence', '--json']);
+ } finally {
+ process.stdout.write = orig;
+ }
+ const parsed = JSON.parse(chunks.join(''));
+ for (const layer of ['artifact', 'session', 'ledger']) {
+ assert.ok(parsed[layer], `layer ${layer} present`);
+ assert.ok(parsed[layer].scope, `layer ${layer} names its scope`);
+ }
+});
+
+// --- proxy-only proof cannot justify a ship ---------------------------------
+
+ok('receipt control-plane scan exposes cold-start poison in the one cold read', () => {
+ const proj = path.join(tmp, 'receipt-control-plane');
+ fs.mkdirSync(path.join(proj, '.ratchet'), { recursive: true });
+ state.initProject(proj, { force: true });
+ const st = state.loadState(proj);
+ st.objective = 'ship the control plane';
+ st.nextAction = 'continue obsolete spec';
+ st.artifacts = [
+ { id: 'art-obsolete', title: 'obsolete spec', status: 'retracted', path: 'reports/obsolete.md', retracted: { keptForProvenance: true } },
+ ];
+ state.saveState(proj, st);
+ fs.writeFileSync(path.join(proj, 'goal.md'), '# Goal\nWe are 12 ahead of main.\nobsolete spec is still safe.\n');
+ fs.writeFileSync(
+ path.join(proj, '.ratchet', 'cold-start.json'),
+ JSON.stringify({ surfaces: [{ path: 'goal.md', kind: 'goal', checks: ['base-qualified-git', 'no-retracted-claims'] }] })
+ );
+
+ const r = receipt.assemble(proj);
+ assert.strictEqual(r.controlPlane.ok, false, 'receipt carries the cold-start scan result');
+ assert.ok(r.controlPlane.failures >= 2, 'misleading steering failures are counted');
+ const out = md.receipt(r);
+ assert.ok(/Control-plane scan: FAIL/.test(out), 'the one receipt command says the control plane is unsafe');
+ assert.ok(/unqualified git count/.test(out), 'configured surface failures are visible without a separate doctor run');
+ assert.ok(/repeats retracted claim/.test(out), 'stale steering is visible without operator notice');
+});
+
+ok('receipt --json exposes control-plane failures and warnings for consumers', () => {
+ const proj = path.join(tmp, 'receipt-control-plane-json');
+ fs.mkdirSync(path.join(proj, '.ratchet'), { recursive: true });
+ state.initProject(proj, { force: true });
+ const st = state.loadState(proj);
+ st.objective = 'ship the control plane';
+ st.nextAction = 'continue obsolete spec';
+ st.artifacts = [
+ { id: 'art-obsolete', title: 'obsolete spec', status: 'retracted', path: 'reports/obsolete.md', retracted: { keptForProvenance: true } },
+ ];
+ state.saveState(proj, st);
+ fs.writeFileSync(path.join(proj, 'goal.md'), '# Goal\nWe are 12 ahead of main.\nobsolete spec is still safe.\n');
+ fs.writeFileSync(
+ path.join(proj, '.ratchet', 'cold-start.json'),
+ JSON.stringify({ surfaces: [{ path: 'goal.md', kind: 'goal', checks: ['base-qualified-git', 'valid-as-of', 'no-retracted-claims'] }] })
+ );
+
+ const chunks = [];
+ const origWrite = process.stdout.write;
+ const prevCwd = process.cwd();
+ process.stdout.write = (str) => { chunks.push(String(str)); return true; };
+ process.chdir(proj);
+ try {
+ cli.run(['node', 'ratchet', 'receipt', '--json']);
+ } finally {
+ process.chdir(prevCwd);
+ process.stdout.write = origWrite;
+ }
+
+ const parsed = JSON.parse(chunks.join(''));
+ assert.strictEqual(parsed.controlPlane.ok, false, 'JSON receipt says the control plane is unsafe');
+ assert.strictEqual(parsed.controlPlane.configured, true, 'JSON receipt preserves configured-surface scope');
+ assert.ok(parsed.controlPlane.failures >= 2, 'JSON receipt carries failure count');
+ assert.ok(parsed.controlPlane.warnings >= 1, 'JSON receipt carries warning count');
+ assert.ok(
+ parsed.controlPlane.checks.some((c) => c.level === 'fail' && /unqualified git count/.test(c.detail)),
+ 'JSON receipt carries configured surface failure detail'
+ );
+ assert.ok(
+ parsed.controlPlane.checks.some((c) => c.level === 'warn' && /valid-as-of/.test(c.detail)),
+ 'JSON receipt carries configured surface warning detail'
+ );
+});
+
+ok('a proxy-only seam is flagged: cannot justify ship decision', () => {
+ fs.rmSync(process.env.RATCHET_EVOLVE_LOG, { force: true });
+ journal.appendEvent(cwd, {
+ target: 'src/router.js', goal: 'gate', mode: 'docs', chosenMutation: 'proxy-evaluated gate',
+ verdict: 'ASK', verification: { manualChecks: ['ran fixture-shortlist eval'], result: 'manual' },
+ seam: { evidenceType: 'eval', testedSeam: 'fixture-shortlist', shipSeam: 'rerank_candidates', seamMatch: 'weak-proxy' },
+ });
+ const r = receipt.assemble(cwd);
+ assert.strictEqual(r.proof.shipDecision, 'cannot-justify', 'proxy seam cannot justify shipping');
+ assert.ok(/Cannot justify ship decision/.test(md.receipt(r)), 'the receipt says so out loud');
+});
+
+// --- source-of-truth index (ratchet receipt --save) -------------------------
+
+ok('receipt --save writes .ratchet/current.json + current.md', () => {
+ const proj = path.join(tmp, 'save-fixture');
+ fs.mkdirSync(proj, { recursive: true });
+ const prevCwd = process.cwd();
+ process.chdir(proj);
+ try {
+ cli.run(['node', 'ratchet', 'receipt', '--save']);
+ const jsonPath = path.join(proj, '.ratchet', 'current.json');
+ const mdPath = path.join(proj, '.ratchet', 'current.md');
+ assert.ok(fs.existsSync(jsonPath), 'current.json written');
+ assert.ok(fs.existsSync(mdPath), 'current.md written');
+ const saved = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
+ assert.ok(saved.validAsOf !== undefined, 'saved index is a real receipt object');
+ assert.ok(/Ratchet receipt/.test(fs.readFileSync(mdPath, 'utf8')), 'current.md is the rendered receipt');
+ } finally {
+ process.chdir(prevCwd);
+ }
+});
+
+// --- agent memory isolation (propose-only, enforced by role) ----------------
+
+ok('a propose-only agent cannot mutate canonical state, but can still read', () => {
+ state.initProject(cwd, { force: true });
+ const s = state.loadState(cwd);
+ s.objective = 'guard me';
+ state.saveState(cwd, s);
+ process.env.RATCHET_AGENT = 'ratchet-builder';
+ try {
+ assert.throws(() => cli.run(['node', 'ratchet', 'state', 'set', 'objective', 'hijacked']), /propose-only/);
+ assert.throws(() => cli.run(['node', 'ratchet', 'artifact', 'add', '{"title":"x"}']), /propose-only/);
+ assert.throws(() => cli.run(['node', 'ratchet', 'defect', 'add', '{"summary":"x"}']), /propose-only/);
+ assert.throws(() => cli.run(['node', 'ratchet', 'compile', 'done']), /propose-only/);
+ assert.throws(() => cli.run(['node', 'ratchet', 'state', 'reset', '--force']), /propose-only/);
+ // the shared record is untouched — no mutation leaked through
+ assert.strictEqual(state.loadState(cwd).objective, 'guard me', 'a propose-only agent cannot clobber the record');
+ // read verbs stay open so the agent can still orient
+ assert.doesNotThrow(() => cli.run(['node', 'ratchet', 'receipt']));
+ assert.doesNotThrow(() => cli.run(['node', 'ratchet', 'defect', 'list']));
+ } finally {
+ delete process.env.RATCHET_AGENT;
+ }
+});
+
+ok('the scribe is the sole writer — it may mutate canonical state', () => {
+ process.env.RATCHET_AGENT = 'scribe';
+ try {
+ cli.run(['node', 'ratchet', 'state', 'set', 'objective', 'written by scribe']);
+ assert.strictEqual(state.loadState(cwd).objective, 'written by scribe', 'the scribe writes canonical state');
+ } finally {
+ delete process.env.RATCHET_AGENT;
+ }
+});
+
+ok('score confidence leaves no write footprint for a propose-only agent', () => {
+ state.initProject(cwd, { force: true });
+ const s = state.loadState(cwd);
+ s.confidence = null;
+ state.saveState(cwd, s);
+ process.env.RATCHET_AGENT = 'ratchet-auditor';
+ try {
+ assert.doesNotThrow(() => cli.run(['node', 'ratchet', 'score', 'confidence']));
+ assert.strictEqual(state.loadState(cwd).confidence, null, 'a read leaves no cached write behind');
+ } finally {
+ delete process.env.RATCHET_AGENT;
+ }
+});
+
fs.rmSync(tmp, { recursive: true, force: true });
process.stdout.write(`\n${passed} passed\n`);