From 7d5f5cddb37bd8557fe2915fc78c6237b7460c7d Mon Sep 17 00:00:00 2001 From: Danny Gillespie Date: Sat, 4 Jul 2026 15:46:05 +0100 Subject: [PATCH 1/2] feat(receipt): the receipt cockpit + inline cold-start control-plane scan The receipt (`ratchet receipt`) is now the loop's single cold read: eight fixed sections joining session state, the evolve journal, the QA ledger, and git, with a `--json` twin and a `--save` source-of-truth index. Confidence splits into three scoped layers (artifact/session/ledger) so a verified artifact stays ship-ready under unrelated debt. The cold-start poison scan is folded inline into the receipt STATE (Control-plane scan: FAIL|WARN|clean) so one read says when its own steering is stale. `state reset` now requires --force; builder/auditor agents are propose-only via RATCHET_AGENT, leaving the scribe the sole canonical writer. Tests: cli 48 / evolve 19 / plugin-shape 13, all green. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 56 +++++++ README.md | 37 ++++- agents/ratchet-auditor.md | 6 + agents/ratchet-builder.md | 8 +- agents/ratchet-scribe.md | 9 +- skills/handoff/SKILL.md | 6 +- skills/status/SKILL.md | 17 +- src/cli.js | 113 +++++++++++-- src/gitRefs.js | 5 + src/markdown.js | 202 ++++++++++++++++++++++- src/receipt.js | 257 +++++++++++++++++++++++++++++ src/scoring.js | 163 ++++++++++++++++++- test/cli.test.js | 332 ++++++++++++++++++++++++++++++++++++++ 13 files changed, 1182 insertions(+), 29 deletions(-) create mode 100644 CLAUDE.md create mode 100644 src/receipt.js 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/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`); From a3b501b42352f9ca61771eda7b47cd0b4b10460f Mon Sep 17 00:00:00 2001 From: Danny Gillespie Date: Sat, 4 Jul 2026 15:46:13 +0100 Subject: [PATCH 2/2] chore(release): v0.5.0 receipt Bump 0.4.0 -> 0.5.0 across package.json and every plugin manifest (claude plugin + marketplace metadata/plugins, codex plugin); the CLI VERSION derives from package.json. Finalize CHANGELOG [0.5.0] "Receipt". Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude-plugin/marketplace.json | 4 ++-- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- CHANGELOG.md | 32 ++++++++++++++++++++++++++++++++ package.json | 2 +- 5 files changed, 37 insertions(+), 5 deletions(-) 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/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",