From d7bf2b9b0d02496bb22cf34c9adb982072867dcf Mon Sep 17 00:00:00 2001 From: Danny Gillespie Date: Mon, 6 Jul 2026 15:01:02 +0100 Subject: [PATCH 1/2] feat(probe): build-for-learn closure + CLI-boundary fog lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some fog only appears by touching, and fog the dial named could live only on stdout. The probe closes both: a build whose proof-of-done is knowledge, not code. - Probe as first-class map closure (user | territory | probe | parked | OPEN) with no new schema: artifact kind:"probe" + hole "disposal: pending". The hole is injected at artifact-add (invariant, not convention); disposal reuses the gated retract verb and must state its outcome (disposed:/ promoted:, promotion requires --superseded-by). templates/probe-card.md carries the card; build gained build-for-learn mode; handoff reports probe outcomes. - Undrained-fog fix: score aperture with mapRequired serializes a "fog: pre-build map required" open loop for writer callers on BOTH output modes (JSON carries recordedFog); artifact add kind:"unknown-map" closes it. Prefix shared via schemas.FOG_LOOP_PREFIX so open/close cannot drift. - Cold-start fog checks: retracted probe = completed (exempt from the dead-steering FAIL), live probe = residue warning, open fog + \bbuild\b steering = FAIL. - Receipt STATE gains a fog card (maps + OPEN counts, fog loops, probes live/disposed) — emptiness stated. - Map convergence rule: every OPEN item names its route out; surprise tripwires (prompt-level) in attack and build. - Session confidence scope now reads "recorded loop pressure, not correctness". Review-hardened (PATCH-THEN-KEEP): --json fog write, probe hole invariant, gated retract outcomes; reviewer item 4 (objective-fingerprint fog matching) parked as an open loop in ratchet state. npm test green (cli 60 · evolve 19 · plugin-shape 16); new tests fail without the change (verified against HEAD src). No new dependencies, schemas, or commands. Author-model: claude-fable-5 Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 55 +++++++++++ README.md | 9 +- reference/PROMPTS.md | 5 +- skills/attack/SKILL.md | 6 ++ skills/build/SKILL.md | 17 ++++ skills/handoff/SKILL.md | 5 + skills/map/SKILL.md | 59 ++++++++++-- src/artifacts.js | 31 ++++++ src/cli.js | 42 ++++++++- src/coldStart.js | 36 ++++++- src/markdown.js | 11 +++ src/receipt.js | 16 ++++ src/schemas.js | 7 ++ src/scoring.js | 2 +- templates/probe-card.md | 43 +++++++++ templates/unknowns-map.md | 6 +- test/cli.test.js | 194 ++++++++++++++++++++++++++++++++++++++ test/plugin-shape.test.js | 15 +++ 18 files changed, 546 insertions(+), 13 deletions(-) create mode 100644 templates/probe-card.md diff --git a/CHANGELOG.md b/CHANGELOG.md index b666a29..f9036fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,61 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +0.6 gated the fog: *no map → no confident build*. It left two holes: fog the dial named +could live only on stdout (skipped maps leave no trace, so unrecorded uncertainty never +drains confidence), and some fog cannot be mapped by reading or asking at all — it only +appears by touching. The probe closes both: **a probe is a build whose proof-of-done is +knowledge, not code.** + +### Added + +- **The probe — build-for-learn as a first-class map closure.** Known unknowns now close + by `user | territory | probe | parked | OPEN`, and `/ratchet:map` commissions a probe + (`templates/probe-card.md`: unknown · hypothesis · smallest reversible touch · allowed + surfaces · proof of learning · disposal rule · durable output · promotion rule · stop + condition) when only touching the repo can answer. No new schema: a probe is an + artifact (`kind:"probe"`, hole `disposal: pending`) whose hole drains confidence until + the code is disposed via the existing gated verb (`ratchet retract --reason`) or + explicitly promoted through a fresh `/ratchet:build` under the full proof/seam gates. + Both invariants are CLI-enforced, not conventions: `artifact add` injects the + `disposal: pending` hole on any probe that omits it, and a probe retraction must state + its outcome (`--reason` starting `disposed:` or `promoted:`; a promotion requires + `--superseded-by `). + **Probe code dies; probe findings live** — as a decision, assumption, open loop, + defect, or map delta. `/ratchet:build` gained the build-for-learn mode (probe code is + never implementation progress); `/ratchet:handoff` surfaces probe outcomes so a + receiver cannot mistake residue for kept work. +- **The undrained-fog fix: scored fog can no longer live only on stdout.** + `ratchet score aperture` with `mapRequired` now serializes the fog as an open loop + (`fog: pre-build map required …`) for writer callers on **both output modes** — text + and `--json` alike, so programmatic consumers cannot bypass the write (the JSON result + carries `recordedFog`); propose-only agents still get a footprint-free read — and + `artifact add kind:"unknown-map"` closes that loop when the map lands. Both ends live + at the CLI boundary, so fog the dial named drains confidence, warns cold starts, and + survives handoff even if the session never runs the map. +- **Cold-start fog checks.** The control-plane scan now warns on live probe artifacts + (residue the next session could mistake for kept work) and FAILs when steering says + build while a `fog:` loop is open. A retracted probe is a *completed* probe, not dead + steering — the live-steering check exempts `kind:"probe"`. +- **Receipt fog card.** STATE now carries `fog` (live unknown-maps with OPEN item counts, + unmapped fog loops, probes live/disposed) and renders it — emptiness stated + (`Fog: none recorded`), never omitted. +- **Map convergence rule.** Every OPEN item leaving handover names its route out + (ask-user · probe · park owner+reason · assumption+killTest · defect). An OPEN item + with no route is a stall with a receipt, not mapped fog. +- **Surprise tripwires for fog the dial never saw.** `/ratchet:attack` flags + wrong-premise findings as fog to serialize (not merely defects) and recommends + re-entering `/ratchet:map` at two or more; `/ratchet:build` re-runs the aperture after + two deviations (or one that reshapes the locked target) and stops when it says map. + These are **prompt-level** guidance, unlike the CLI-enforced pieces above (fog-loop + write/close, probe invariants, cold-start checks) — a session that skips the skills + skips the tripwires; only the dial's own reads are boundary-recorded. + +### Changed + +- Session confidence names its epistemics out loud: the scope now reads **recorded loop + pressure, not correctness** — unrecorded fog is invisible to it. + ## [0.6.0] - 2026-07-06 — Fog Gate 0.2 gated proof (*no proof → no keep*); 0.3 gated the *seam* of that proof diff --git a/README.md b/README.md index 6a8e7a7..9871a00 100644 --- a/README.md +++ b/README.md @@ -209,8 +209,13 @@ when it isn't. In Codex, ask it to use the matching Torque Loop skill, for examp > the map until constraints are locked. The aperture read raises **`Pre-build map: > required`** for exactly these cases — including a high-`taste` or unfamiliar-`terrain` > task the summed score would under-rate — so `/ratchet:ignite` routes into the map on its -> own. What you deliver is the map, not a build — a wrong assumption caught on the map is a -> one-line fix; caught mid-build it is a rewrite. +> own, and it records that fog as an open loop so it drains confidence until the map lands. +> What you deliver is the map, not a build — a wrong assumption caught on the map is a +> one-line fix; caught mid-build it is a rewrite. When an unknown can only be answered by +> touching the repo, the map commissions a **probe**: a time-boxed, reversible, +> build-for-learn spike whose code dies and whose finding lives as a map delta +> (`templates/probe-card.md`). Probe code never ships by inertia — keeping it requires an +> explicit promotion through the normal proof/seam gates. ### Specialized diff --git a/reference/PROMPTS.md b/reference/PROMPTS.md index 9be3185..715df8a 100644 --- a/reference/PROMPTS.md +++ b/reference/PROMPTS.md @@ -147,7 +147,10 @@ then walk four quadrants in order, naming the current one: assumption separately and say you will treat it as true until I correct it. 2. Known unknowns — ask ONE question at a time, widest-impact first. Give lettered options with a recommended answer so I react in a few characters. Close each by: - user answer | territory (you researched it, show question + finding) | OPEN (+ what unblocks it). + user answer | territory (you researched it, show question + finding) | probe (a time-boxed + throwaway build whose only durable output is the finding — probe code dies, probe findings + live) | OPEN (+ what unblocks it AND the route that will close it: ask-user, probe, + park with owner, assumption + kill test, or defect). 3. Unknown knowns — surface my tacit taste and context. Don't make me imagine it; put something concrete in front of me to react to (sample data, a throwaway mock, competing design directions). Record what each reaction reveals. diff --git a/skills/attack/SKILL.md b/skills/attack/SKILL.md index 8199ada..d8926eb 100644 --- a/skills/attack/SKILL.md +++ b/skills/attack/SKILL.md @@ -37,6 +37,12 @@ Speak in each voice distinctly. Do not blur them into generic "concerns". the contradicting case or mark the claim unproven. - **Name the smallest patch** each finding needs — REMOVE / ADD / CHANGE. You specify the delta; you do not apply it here. +- **Flag fog, not just defects.** A finding that says the *premise* was wrong — a hidden + convention, wrong-by-default data, a claim that contradicts the locked target or the + unknowns-map — is fog the aperture missed, not merely a bug. Serialize it + (`ratchet state append assumptions ...` with a kill test, or an `openLoops` entry + prefixed `fog:`), and at two or more such findings recommend `/ratchet:map` before any + further patching: you are patching inside unmapped terrain. ## Output contract diff --git a/skills/build/SKILL.md b/skills/build/SKILL.md index 248f4a3..b69487f 100644 --- a/skills/build/SKILL.md +++ b/skills/build/SKILL.md @@ -46,6 +46,23 @@ assumed, do not silently absorb it. Record a deviation (`templates/deviation-not `decision`, `openLoop`, or `defect`, and add it to the map's *Deviations during build* section. The map is a living record through the build, not a pre-build formality. +**Re-score on surprise.** Two deviations — or one that reshapes the locked target — mean +the aperture was scored too low. Re-run `ratchet score aperture`; if it now says +`Pre-build map: required`, stop building and run `/ratchet:map`. Patching deeper into +unmapped terrain is the confident-build-into-fog the dial exists to refuse. + +**Build-for-learn (probe mode).** If the active task is a probe (a live `kind:"probe"` +artifact — see `templates/probe-card.md`), you are building to learn, not to keep: + +- Build only the smallest artifact that produces the card's proof-of-learning; stay + inside its allowed surfaces; stop at its stop condition. +- The probe's completion is a map/state delta — a decision, assumption, open loop, or + defect — never a code diff. Probe code existing is not implementation progress; do not + claim it as such. +- Then dispose: revert the code and `ratchet retract --reason "disposed: …"`. + Keeping probe code is a **promotion** — a fresh build-for-keep under the full proof/seam + gates, never a default. Undisposed probes drain confidence and flag the cold-start scan. + For large or code-heavy artifacts, delegate to the `ratchet-builder` subagent. ## Output contract diff --git a/skills/handoff/SKILL.md b/skills/handoff/SKILL.md index 03a52e6..ea76db6 100644 --- a/skills/handoff/SKILL.md +++ b/skills/handoff/SKILL.md @@ -40,6 +40,10 @@ to skim past: 9. **Deviations from the map** — if `/ratchet:map` ran, where the build diverged from its plan (map said X → code revealed Y → call made) and which calls still need the user's judgment. This is what stops the receiver from re-litigating what you already discovered. +10. **Probe outcomes** — for each probe: the unknown it attacked, the finding, and whether + its code was **disposed** or explicitly **promoted**. An undisposed probe is a + watch-out, not an asset — the receiver must not mistake build-for-learn residue for + kept work. ## Rules @@ -61,6 +65,7 @@ NEXT ACTION: CONTEXT TO ACT: WATCH-OUTS: DEVIATIONS: (each, if a map ran) +PROBES: (each, if any ran) NEXT COMMAND: /ratchet: ``` diff --git a/skills/map/SKILL.md b/skills/map/SKILL.md index 26806ff..81c3cba 100644 --- a/skills/map/SKILL.md +++ b/skills/map/SKILL.md @@ -48,7 +48,10 @@ the moment you hit them; never close a quadrant off-screen. question first (the answer that reshapes the most), not a wall of them. Give lettered options with a **recommended** answer. Close each question exactly one way, in front of the user: **user answer**, **territory** (you researched it, then show question + - finding), or **OPEN** (deferred, with what would unblock it). + finding), **probe** (only touching the repo can answer it — commission a probe card, + below), or **OPEN** (deferred, with what would unblock it *and the route that will close + it*: ask-user, probe, park with owner+reason, promote to an assumption with a kill test, + or a defect). 3. **Unknown knowns — extract the tacit.** The user cannot articulate what they don't know they know. Do not ask abstractly. Put something concrete in their hands — sample data, a @@ -70,10 +73,46 @@ the moment you hit them; never close a quadrant off-screen. 5. **Hand over the map.** One page, all four quadrants — shape it like `templates/unknowns-map.md` and write it to `.ratchet/unknowns-map.md`. OPEN items live - **on the map**, not buried in chat. Add a tweakable build plan ordered by *what might - change* (judgment calls first, routine work compressed at the bottom) and a copy-paste - implementation prompt. Then stop — implementation is a separate engagement. The map stays - live through the build: `/ratchet:build` records deviations back onto it. + **on the map**, not buried in chat — and every OPEN item names its route out (ask-user, + probe, park with owner+reason, assumption+killTest, or defect). An OPEN item with no + route is a stall with a receipt, not mapped fog. Add a tweakable build plan ordered by + *what might change* (judgment calls first, routine work compressed at the bottom) and a + copy-paste implementation prompt. Then stop — implementation is a separate engagement. + The map stays live through the build: `/ratchet:build` records deviations back onto it. + +## Probes — when the map must touch (build-for-learn) + +Some fog only appears by touching: an unknown unknown no read can surface, a taste no +question can extract. For those, do not ask harder — commission a **probe**: a time-boxed, +reversible, throwaway build whose proof-of-done is knowledge, not code. Shape it like +`templates/probe-card.md`: + +``` +PROBE: +- Hypothesis: +- Smallest reversible touch: +- Allowed surfaces: +- Proof of learning: +- Stop condition: +``` + +**Probe code dies; probe findings live.** Serialize the lifecycle: + +``` +ratchet artifact add '{"kind":"probe","title":"probe: ","status":"v0","holes":["disposal: pending"]}' +# run it via /ratchet:build in build-for-learn mode, then — findings first, then dispose: +ratchet state append decisions '{"choice":"","rejected":"","tripwire":""}' +ratchet retract --reason "disposed: code reverted; finding recorded as " +``` + +The `disposal: pending` hole drains confidence until the probe is retracted, and the +cold-start scan flags a live probe as residue — undisposed probe code cannot read clean. +If probe code turns out worth keeping, that is a **promotion**: a fresh `/ratchet:build` +under the full proof/seam gates, then `ratchet retract --reason "promoted: " --superseded-by `. The CLI enforces both: a probe retraction +must start `disposed:` or `promoted:`, and a promotion must name its superseder. Nothing +ships because a probe happened to leave it behind. An unfinished probe (stop condition +hit) closes its item as OPEN with what it *did* learn. ## Output contract @@ -85,7 +124,10 @@ KNOWN KNOWNS: - — treated as true until challenged KNOWN UNKNOWNS: -- Q: | recommend: | closed by: user | territory | OPEN | unblocks: +- Q: | recommend: | closed by: user | territory | probe | parked(owner) | OPEN + route | unblocks: + +PROBES (if any commissioned): +- probe: | hypothesis: | touch: | stop: UNKNOWN KNOWNS: - tacit constraint: | reshaped the target: @@ -120,6 +162,11 @@ The map's OPEN loops, untested assumptions, and artifact holes all drain `ratchet score confidence` on purpose — an unclosed unknown is tracked pressure, not a forgotten one. The score cannot read ship-ready while the fog is still on the board. +The CLI holds both ends of the fog loop itself: `ratchet score aperture` records +`fog: pre-build map required` as an open loop the moment `mapRequired` fires (writer +callers), and `ratchet artifact add` with `kind:"unknown-map"` closes it when the map +lands — scored fog can never live only on stdout. + ## Meter — when to reach for this Run `/ratchet:map` when the aperture earns it, not on a reversible one-liner: diff --git a/src/artifacts.js b/src/artifacts.js index f778ed0..eea48e1 100644 --- a/src/artifacts.js +++ b/src/artifacts.js @@ -18,9 +18,27 @@ function addArtifact(cwd, item) { status: item.status || 'v0', holes: Array.isArray(item.holes) ? item.holes : item.holes ? [item.holes] : [], }; + // A probe's drain is an invariant, not a convention: build-for-learn code + // must cost confidence until disposed or promoted, even when the caller + // omits the hole. + if (record.kind === 'probe' && !record.holes.some((h) => /disposal:\s*pending/i.test(String(h)))) { + record.holes.push('disposal: pending'); + } s.artifacts.push(record); s.dirty = true; s.history.push({ id: state.makeId('hist'), at: record.at, event: 'artifact.add', note: record.title }); + // The map landing is the fog entering durable state: close the fog loop that + // `score aperture` opened, so the drain tracks reality instead of nagging past + // the point the map answered it. (The map's own OPEN items keep draining as + // this artifact's holes.) + if (record.kind === 'unknown-map') { + for (const l of s.openLoops || []) { + if (l.status !== 'closed' && String(l.text || '').startsWith(schemas.FOG_LOOP_PREFIX)) { + l.status = 'closed'; + l.closedBy = record.id; + } + } + } state.saveState(cwd, s); return record; } @@ -122,6 +140,19 @@ function retractArtifact(cwd, id, { reason = '', supersededBy = '' } = {}) { const s = state.loadState(cwd); const a = (s.artifacts || []).find((x) => x.id === id); if (!a) throw new Error(`no artifact with id "${id}"`); + // A probe retraction is its lifecycle exit and must state which one: the + // code died (disposed) or was explicitly rebuilt for keep (promoted). A + // vague reason would let residue stop draining without either outcome. + if (a.kind === 'probe') { + if (!/^(disposed|promoted):/i.test(reason)) { + throw new Error( + 'a probe retraction must state its outcome: --reason must start with "disposed:" (code reverted, finding recorded) or "promoted:" (rebuilt for keep)' + ); + } + if (/^promoted:/i.test(reason) && !supersededBy) { + throw new Error('a promoted probe requires --superseded-by — the build-for-keep that replaced it'); + } + } const now = schemas.nowIso(); a.status = 'retracted'; a.retracted = { at: now, reason, supersededBy, keptForProvenance: true }; diff --git a/src/cli.js b/src/cli.js index d64d619..7a69927 100644 --- a/src/cli.js +++ b/src/cli.js @@ -431,6 +431,35 @@ function cmdRetract(cwd, argv) { return out(`artifact ${id} retracted${supersededBy ? ` (superseded by ${supersededBy})` : ''}`); } +// Serialize the fog the moment the dial names it (a write). Steering the state +// never saw cannot drain confidence, warn a cold start, or survive a handoff. +// A propose-only agent still gets the read — no footprint; an already-open fog +// loop or a live unknown-map means the fog is already on the record. The loop +// closes itself when the unknown-map artifact lands (artifacts.js). Returns +// true only when a fog loop was actually written. +function recordApertureFog(cwd, result) { + if (!result.mapRequired || proposeOnlyAgent()) return false; + const s = state.loadState(cwd); + const openFog = (s.openLoops || []).some( + (l) => l.status !== 'closed' && String(l.text || '').startsWith(schemas.FOG_LOOP_PREFIX) + ); + const liveMap = (s.artifacts || []).some( + (a) => a.kind === 'unknown-map' && a.status !== 'retracted' && a.status !== 'superseded' + ); + if (openFog || liveMap) return false; + const now = schemas.nowIso(); + s.openLoops.push({ + id: state.makeId('loop'), + at: now, + text: `${schemas.FOG_LOOP_PREFIX} (aperture ${result.level}, score ${result.score}/10) — run /ratchet:map; closes when the unknown-map artifact lands`, + status: 'open', + }); + s.dirty = true; + s.history.push({ id: state.makeId('hist'), at: now, event: 'fog.recorded', note: `aperture ${result.level} raised mapRequired` }); + state.saveState(cwd, s); + return true; +} + function cmdScore(cwd, sub, rest, asJson) { switch (sub) { case 'friction': { @@ -458,7 +487,18 @@ function cmdScore(cwd, sub, rest, asJson) { } case 'aperture': { const result = scoring.scoreAperture(readPayload(rest[0])); - return out(asJson ? JSON.stringify(result, null, 2) : md.aperture(result)); + // Serialize the fog on BOTH output modes — a mapRequired that lives only + // on stdout is the undrained-fog hole, and --json callers (the ones most + // likely to automate this read) must not silently bypass the write. The + // JSON result says whether the write happened (recordedFog). + const recordedFog = recordApertureFog(cwd, result); + if (asJson) return out(JSON.stringify({ ...result, recordedFog }, null, 2)); + return out( + md.aperture(result) + + (recordedFog + ? '\n_Fog recorded as an open loop — it drains confidence until `/ratchet:map` lands the unknown-map artifact._' + : '') + ); } default: throw new Error(`unknown score subcommand: ${sub} (friction | confidence | aperture)`); diff --git a/src/coldStart.js b/src/coldStart.js index d639cf5..a20eeef 100644 --- a/src/coldStart.js +++ b/src/coldStart.js @@ -151,14 +151,48 @@ function scan(cwd = process.cwd()) { if (nextAction) add('next action is set', 'ok'); else add('next action is set', 'warn', 'no next action — a cold session has no first move'); + // A retracted probe is a COMPLETED build-for-learn — its code died on purpose + // and its findings live elsewhere in state. Only non-probe retractions are + // dead steering; the live probe is the anomaly (checked below). const lastArtifact = artifacts[artifacts.length - 1]; - if (lastArtifact && (lastArtifact.status === 'retracted' || lastArtifact.status === 'superseded')) { + if ( + lastArtifact && + (lastArtifact.status === 'retracted' || lastArtifact.status === 'superseded') && + lastArtifact.kind !== 'probe' + ) { add('steering artifact is live', 'fail', `the most recent artifact "${lastArtifact.title}" is ${lastArtifact.status} — it will steer the next session toward dead work`); } else { add('steering artifact is live', 'ok'); } + // Fog gate: probes are build-for-learn — the code must die (retract) or be + // explicitly promoted. A probe still live at cold start is residue the next + // session could mistake for kept work. + const liveProbes = artifacts.filter( + (a) => a.kind === 'probe' && a.status !== 'retracted' && a.status !== 'superseded' + ); + if (liveProbes.length) { + add('probe code is disposed or promoted', 'warn', + `${liveProbes.length} live probe artifact(s) (${liveProbes.map((a) => a.id).join(', ')}) — probe code is not kept work; retract it (disposed/promoted) before it ships by inertia`); + } else { + add('probe code is disposed or promoted', 'ok'); + } + + // Fog the dial recorded but nothing mapped: steering that says "build" while a + // fog loop is open is exactly the confident-build-into-fog the aperture refused. + const openFog = (s.openLoops || []).filter( + (l) => l.status !== 'closed' && /^fog:/i.test(String(l.text || '')) + ); + // \bbuild\b, not /build/: "rebuild trust" is not build steering. + const steering = `${s.nextCommand || ''} ${s.nextAction || ''}`; + if (openFog.length && /\bbuild\b/i.test(steering)) { + add('build steering has no unmapped fog', 'fail', + `steering says build while ${openFog.length} fog loop(s) are open — run /ratchet:map (or close the fog with evidence) first`); + } else { + add('build steering has no unmapped fog', 'ok'); + } + const steeredToDead = retracted.filter( (a) => nextAction && (nextAction.includes(a.id) || (a.title && nextAction.includes(a.title))) ); diff --git a/src/markdown.js b/src/markdown.js index 62fe795..d358674 100644 --- a/src/markdown.js +++ b/src/markdown.js @@ -388,6 +388,17 @@ function receipt(r) { o.push('- Open defects: none'); } o.push(`- Untested assumptions: ${r.state.untested} · open loops: ${r.state.openLoops}`); + const fog = r.state.fog || { maps: [], fogLoops: 0, probes: { live: 0, disposed: 0 } }; + if (fog.maps.length || fog.fogLoops || fog.probes.live || fog.probes.disposed) { + const mapBit = fog.maps.length + ? `${fog.maps.length} unknown-map (${fog.maps.reduce((n, m) => n + m.openItems, 0)} OPEN item(s))` + : 'no unknown-map'; + const probeBit = `probes: ${fog.probes.live} live / ${fog.probes.disposed} disposed`; + const residue = fog.probes.live ? ' — ⚠ live probe code must be disposed or promoted before ship' : ''; + o.push(`- Fog: ${mapBit} · unmapped fog loops: ${fog.fogLoops} · ${probeBit}${residue}`); + } else { + o.push('- Fog: none recorded'); + } 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}`); diff --git a/src/receipt.js b/src/receipt.js index 630c2d9..f4969c7 100644 --- a/src/receipt.js +++ b/src/receipt.js @@ -96,6 +96,21 @@ function assemble(cwd = process.cwd()) { const openLoops = (s.openLoops || []).filter((l) => l.status !== 'closed'); const stale = Boolean(s.dirty && (!s.lastCompileAt || s.lastCompileAt < s.updatedAt)); + // FOG — build-for-learn state, cold-start-safe: live unknown-maps with their + // OPEN item counts (their holes), fog loops the aperture recorded but no map + // answered, and probes split live (residue) / disposed (completed). + const probeArts = artifactsArr.filter((a) => a.kind === 'probe'); + const fog = { + maps: liveArtifacts + .filter((a) => a.kind === 'unknown-map') + .map((a) => ({ id: a.id, title: a.title, openItems: Array.isArray(a.holes) ? a.holes.length : 0 })), + fogLoops: openLoops.filter((l) => /^fog:/i.test(String(l.text || ''))).length, + probes: { + live: probeArts.filter((a) => a.status !== 'retracted' && a.status !== 'superseded').length, + disposed: probeArts.filter((a) => a.status === 'retracted' || a.status === 'superseded').length, + }, + }; + // 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. @@ -232,6 +247,7 @@ function assemble(cwd = process.cwd()) { openDefects: openDefects.map((d) => ({ severity: d.severity, summary: d.summary })), untested: untested.length, openLoops: openLoops.length, + fog, dirty: Boolean(s.dirty), stale, git: diff --git a/src/schemas.js b/src/schemas.js index 29692aa..646d058 100644 --- a/src/schemas.js +++ b/src/schemas.js @@ -82,6 +82,12 @@ const LEDGER_COLLECTIONS = { }; const SEVERITIES = ['critical', 'high', 'medium', 'low', 'info']; + +// The fog loop's text prefix. One constant, two ends of the lifecycle: the CLI +// opens a loop with this prefix when `score aperture` raises mapRequired, and +// artifacts.addArtifact closes loops with this prefix when the unknown-map +// lands. Shared so the open and close can never drift apart. +const FOG_LOOP_PREFIX = 'fog: pre-build map required'; const PHASES = ['idle', 'lock', 'auction', 'cut', 'build', 'attack', 'patch', 'compile', 'loop']; // Defect lifecycle. `open`/`patched`/`reopened` are still-live pressure and @@ -100,6 +106,7 @@ module.exports = { STATE_SCALARS, LEDGER_COLLECTIONS, SEVERITIES, + FOG_LOOP_PREFIX, PHASES, DEFECT_STATUSES, DEFECT_TERMINAL_STATUSES, diff --git a/src/scoring.js b/src/scoring.js index 08aea84..6d03397 100644 --- a/src/scoring.js +++ b/src/scoring.js @@ -139,7 +139,7 @@ function scoreConfidence(state) { // 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', + 'recorded loop pressure, not correctness — 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; unrecorded fog is invisible to it', }; } diff --git a/templates/probe-card.md b/templates/probe-card.md new file mode 100644 index 0000000..51b4866 --- /dev/null +++ b/templates/probe-card.md @@ -0,0 +1,43 @@ +# Probe: + +- **Date:** +- **Against map:** .ratchet/unknowns-map.md (quadrant + item) +- **Mode:** build-for-learn — the proof-of-done is knowledge, not code + +## Card + +- **Unknown:** +- **Hypothesis:** +- **Smallest reversible touch:** +- **Allowed surfaces:** +- **Proof of learning:** +- **Disposal rule:** code dies (revert/delete); findings live +- **Durable output:** +- **Promotion rule:** no probe code ships by inertia — promotion = a fresh `/ratchet:build` + under the full proof/seam gates, then retract the probe `--superseded-by ` +- **Stop condition:** + +## Serialize + +Open (before touching anything): + +``` +ratchet artifact add '{"kind":"probe","title":"probe: ","status":"v0","holes":["disposal: pending"]}' +``` + +The `disposal: pending` hole drains confidence until the probe is disposed or promoted — +residue cannot read clean. + +Close (findings first, then dispose): + +``` +ratchet state append decisions '{"choice":"","rejected":"","tripwire":""}' +ratchet retract --reason "disposed: code reverted; finding recorded as " +``` + +Promote (rare, explicit — never the default): + +``` +ratchet retract --reason "promoted: rebuilt for keep under proof/seam gates" --superseded-by +``` diff --git a/templates/unknowns-map.md b/templates/unknowns-map.md index 5b9fdfd..3475ba8 100644 --- a/templates/unknowns-map.md +++ b/templates/unknowns-map.md @@ -12,7 +12,11 @@ ## Known unknowns — decision ledger | Question | Recommended | Closed by | Unblocks | | --- | --- | --- | --- | -| | | user \| territory \| OPEN | | +| | | user \| territory \| probe \| parked(owner) \| OPEN | | + + ## Unknown knowns — extracted tacit context - — reshaped the target: diff --git a/test/cli.test.js b/test/cli.test.js index 0f67b68..613e4fa 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -716,5 +716,199 @@ ok('score confidence leaves no write footprint for a propose-only agent', () => } }); +// --- probe + undrained fog (the fog gate's remaining holes) ------------------ + +ok('score aperture serializes mapRequired fog as an open loop (not just stdout)', () => { + state.initProject(cwd, { force: true }); + cli.run(['node', 'ratchet', 'score', 'aperture', '{"ambiguity":2,"terrain":2,"taste":2,"blastRadius":1,"reversibility":1}']); + const fogLoops = state.loadState(cwd).openLoops.filter((l) => /^fog: pre-build map required/.test(l.text)); + assert.strictEqual(fogLoops.length, 1, 'the dial leaves the fog on the record, not only on stdout'); + assert.strictEqual(fogLoops[0].status, 'open'); + // a re-score does not stack a second drain + cli.run(['node', 'ratchet', 'score', 'aperture', '{"ambiguity":2,"terrain":2,"taste":2,"blastRadius":1,"reversibility":1}']); + assert.strictEqual( + state.loadState(cwd).openLoops.filter((l) => /^fog:/.test(l.text)).length, 1, 'fog loop is deduped' + ); + // and recorded fog drains confidence like any open loop + assert.ok( + scoring.scoreConfidence(state.loadState(cwd)).penalties.some((p) => /open loop/.test(p.reason)), + 'recorded fog drains confidence' + ); +}); + +ok('a low-uncertainty aperture read leaves no fog footprint', () => { + state.initProject(cwd, { force: true }); + cli.run(['node', 'ratchet', 'score', 'aperture', '{"ambiguity":0,"terrain":0,"taste":0,"blastRadius":1,"reversibility":0}']); + assert.strictEqual(state.loadState(cwd).openLoops.length, 0, 'no mapRequired → no fog loop'); +}); + +ok('score aperture leaves no fog footprint for a propose-only agent', () => { + state.initProject(cwd, { force: true }); + process.env.RATCHET_AGENT = 'ratchet-auditor'; + try { + cli.run(['node', 'ratchet', 'score', 'aperture', '{"ambiguity":2,"terrain":2,"taste":2,"blastRadius":2,"reversibility":2}']); + } finally { + delete process.env.RATCHET_AGENT; + } + assert.strictEqual(state.loadState(cwd).openLoops.length, 0, 'a propose-only read leaves no write behind'); +}); + +ok('the unknown-map artifact landing closes the fog loop the dial opened', () => { + state.initProject(cwd, { force: true }); + cli.run(['node', 'ratchet', 'score', 'aperture', '{"ambiguity":2,"terrain":2,"taste":2,"blastRadius":1,"reversibility":1}']); + artifacts.addArtifact(cwd, { + kind: 'unknown-map', title: 'unknowns map: fixture', path: '.ratchet/unknowns-map.md', + status: 'handoff', holes: ['Q3 OPEN — route: probe'], + }); + const fogLoops = state.loadState(cwd).openLoops.filter((l) => /^fog:/.test(l.text)); + assert.ok(fogLoops.length >= 1, 'the fog loop is still on the record for provenance'); + assert.ok(fogLoops.every((l) => l.status === 'closed'), 'the map landing closes the fog the dial recorded'); +}); + +ok('probe lifecycle: the disposal hole drains until the gated retract clears it', () => { + state.initProject(cwd, { force: true }); + const probe = artifacts.addArtifact(cwd, { + kind: 'probe', title: 'probe: does the seam double-fire?', holes: ['disposal: pending'], + }); + assert.ok( + scoring.scoreConfidence(state.loadState(cwd)).penalties.some((p) => /holes/.test(p.reason)), + 'a live probe drains confidence via its disposal hole' + ); + // disposal reuses the gated verb — no silent disposal + assert.throws(() => cli.run(['node', 'ratchet', 'retract', probe.id]), /reason/); + cli.run(['node', 'ratchet', 'retract', probe.id, '--reason', 'disposed: code reverted; finding recorded as decision']); + const after = state.loadState(cwd); + assert.strictEqual(after.artifacts.find((a) => a.id === probe.id).status, 'retracted'); + assert.ok( + !scoring.scoreConfidence(after).penalties.some((p) => /holes/.test(p.reason)), + 'disposal stops the drain' + ); +}); + +ok('cold-start reads probes correctly: disposed is healthy, live is residue, fog+build steering fails', () => { + const proj = path.join(tmp, 'fog-cold'); + fs.mkdirSync(proj, { recursive: true }); + state.initProject(proj, { force: true }); + let st = state.loadState(proj); + st.objective = 'ship the probe gate'; + st.nextAction = 'verify the fog checks'; + st.nextCommand = '/ratchet:verify'; + // a disposed probe as the most recent artifact is a COMPLETED build-for-learn + st.artifacts = [ + { id: 'p1', title: 'probe: seam', kind: 'probe', status: 'retracted', retracted: { reason: 'disposed: finding recorded' } }, + ]; + state.saveState(proj, st); + let scan = coldStart.scan(proj); + const lvl = (frag) => (scan.checks.find((c) => c.name.includes(frag)) || {}).level; + assert.strictEqual(lvl('steering artifact is live'), 'ok', 'a disposed probe is not dead steering'); + assert.strictEqual(lvl('probe code is disposed'), 'ok'); + assert.strictEqual(lvl('build steering has no unmapped fog'), 'ok'); + assert.strictEqual(scan.ok, true, 'a completed probe leaves a healthy cold start'); + + // "rebuild trust" is not build steering — fog + a non-build move stays ok + st = state.loadState(proj); + st.nextAction = 'rebuild the demo narrative'; + st.openLoops = [{ id: 'l0', text: 'fog: pre-build map required (aperture A3, score 7/10)', status: 'open' }]; + state.saveState(proj, st); + scan = coldStart.scan(proj); + assert.strictEqual(lvl('build steering has no unmapped fog'), 'ok', '"rebuild" must not read as build steering'); + + // now: undisposed residue + recorded fog + steering that says build anyway + st = state.loadState(proj); + st.nextCommand = '/ratchet:build'; + st.artifacts.push({ id: 'p2', title: 'probe: taste', kind: 'probe', status: 'v0', holes: ['disposal: pending'] }); + st.openLoops = [{ id: 'l1', text: 'fog: pre-build map required (aperture A3, score 7/10)', status: 'open' }]; + state.saveState(proj, st); + scan = coldStart.scan(proj); + assert.strictEqual(lvl('probe code is disposed'), 'warn', 'live probe code is residue a cold session must not inherit'); + assert.strictEqual(lvl('build steering has no unmapped fog'), 'fail', 'steering says build while fog is open'); + assert.strictEqual(scan.ok, false); +}); + +ok('receipt carries the fog card — emptiness stated, residue warned', () => { + state.initProject(cwd, { force: true }); + let r = receipt.assemble(cwd); + assert.ok(r.state.fog, 'fog card is always present'); + assert.strictEqual(r.state.fog.probes.live, 0); + assert.ok(/Fog: none recorded/.test(md.receipt(r)), 'empty fog is stated, not omitted'); + + artifacts.addArtifact(cwd, { kind: 'unknown-map', title: 'unknowns map: fixture', holes: ['Q1 OPEN — route: user', 'Q2 OPEN — route: probe'] }); + artifacts.addArtifact(cwd, { kind: 'probe', title: 'probe: q2', holes: ['disposal: pending'] }); + r = receipt.assemble(cwd); + assert.strictEqual(r.state.fog.maps.length, 1); + assert.strictEqual(r.state.fog.maps[0].openItems, 2, 'map holes count as OPEN items'); + assert.strictEqual(r.state.fog.probes.live, 1); + const rendered = md.receipt(r); + assert.ok(/Fog: 1 unknown-map \(2 OPEN item\(s\)\)/.test(rendered), 'fog renders in STATE'); + assert.ok(/disposed or promoted/.test(rendered), 'a live probe carries its warning in the receipt'); +}); + +ok('score aperture --json serializes fog too (no read-mode bypass) and reports it', () => { + state.initProject(cwd, { force: true }); + const chunks = []; + const orig = process.stdout.write; + process.stdout.write = (str) => { chunks.push(String(str)); return true; }; + try { + cli.run(['node', 'ratchet', 'score', 'aperture', '{"ambiguity":2,"terrain":2,"taste":2,"blastRadius":2,"reversibility":2}', '--json']); + } finally { + process.stdout.write = orig; + } + const parsed = JSON.parse(chunks.join('')); + assert.strictEqual(parsed.recordedFog, true, 'the JSON result says the fog write happened'); + assert.strictEqual( + state.loadState(cwd).openLoops.filter((l) => /^fog:/.test(l.text)).length, 1, + 'a --json consumer cannot bypass fog serialization' + ); + // and the JSON path stays footprint-free for propose-only agents + state.initProject(cwd, { force: true }); + process.env.RATCHET_AGENT = 'ratchet-auditor'; + try { + const silent = []; + process.stdout.write = (str) => { silent.push(String(str)); return true; }; + try { + cli.run(['node', 'ratchet', 'score', 'aperture', '{"ambiguity":2,"terrain":2,"taste":2,"blastRadius":2,"reversibility":2}', '--json']); + } finally { + process.stdout.write = orig; + } + assert.strictEqual(JSON.parse(silent.join('')).recordedFog, false, 'a propose-only read reports no write'); + } finally { + delete process.env.RATCHET_AGENT; + } + assert.strictEqual(state.loadState(cwd).openLoops.length, 0, 'propose-only --json leaves no footprint'); +}); + +ok('probe artifacts always receive the disposal hole (invariant, not convention)', () => { + state.initProject(cwd, { force: true }); + const probe = artifacts.addArtifact(cwd, { kind: 'probe', title: 'probe: forgot the hole' }); + assert.ok(probe.holes.some((h) => /disposal:\s*pending/i.test(h)), 'the boundary injects disposal: pending'); + assert.ok( + scoring.scoreConfidence(state.loadState(cwd)).penalties.some((p) => /holes/.test(p.reason)), + 'a hole-less probe still drains confidence' + ); +}); + +ok('probe retraction must state its outcome: disposed or promoted (+ superseded-by)', () => { + state.initProject(cwd, { force: true }); + const probe = artifacts.addArtifact(cwd, { kind: 'probe', title: 'probe: outcome gate' }); + assert.throws(() => cli.run(['node', 'ratchet', 'retract', probe.id, '--reason', 'done']), /disposed|promoted/); + assert.strictEqual( + state.loadState(cwd).artifacts.find((a) => a.id === probe.id).status, 'v0', + 'a vague reason does not dispose the probe' + ); + assert.throws( + () => cli.run(['node', 'ratchet', 'retract', probe.id, '--reason', 'promoted: kept the fixture']), + /superseded-by/ + ); + cli.run(['node', 'ratchet', 'retract', probe.id, '--reason', 'promoted: rebuilt under proof gates', '--superseded-by', 'art-keep-1']); + const after = state.loadState(cwd).artifacts.find((a) => a.id === probe.id); + assert.strictEqual(after.status, 'retracted'); + assert.strictEqual(after.retracted.supersededBy, 'art-keep-1'); +}); + +ok('session confidence names recorded pressure, not correctness', () => { + const conf = scoring.scoreConfidence(state.loadState(cwd)); + assert.ok(/recorded loop pressure, not correctness/.test(conf.scope), 'the scope says what the number is not'); +}); + fs.rmSync(tmp, { recursive: true, force: true }); process.stdout.write(`\n${passed} passed\n`); diff --git a/test/plugin-shape.test.js b/test/plugin-shape.test.js index d357f8b..fc3dd16 100644 --- a/test/plugin-shape.test.js +++ b/test/plugin-shape.test.js @@ -155,4 +155,19 @@ ok('the living unknowns-map ships as templates and threads through build → han assert.ok(/deviation/i.test(read('skills/handoff/SKILL.md')), 'handoff surfaces map deviations'); }); +ok('the probe primitive threads map → build → handoff with a disposal rule', () => { + // A probe is a build whose proof-of-done is knowledge, not code: the map can + // close an unknown by probe, build runs it as build-for-learn, handoff reports + // whether its code died or was explicitly promoted. + assert.ok(exists('templates/probe-card.md'), 'templates/probe-card.md exists'); + assert.ok(/disposal/i.test(read('templates/probe-card.md')), 'the probe card carries a disposal rule'); + const mapSkill = read('skills/map/SKILL.md'); + assert.ok(/\bprobe\b/i.test(mapSkill), 'map can close an unknown by probe'); + assert.ok(/park/i.test(mapSkill), 'map OPEN items can be parked with an owner'); + assert.ok(/build-for-learn/i.test(read('skills/build/SKILL.md')), 'build distinguishes build-for-learn from build-for-keep'); + assert.ok(/probe/i.test(read('skills/handoff/SKILL.md')), 'handoff surfaces probe outcomes'); + assert.ok(/probe/i.test(read('reference/PROMPTS.md')), 'the prompt source of truth knows the probe closure'); + assert.ok(/probe/i.test(read('templates/unknowns-map.md')), 'the map template offers probe as a closure'); +}); + process.stdout.write(`\n${passed} passed\n`); From 1bfebda076ede60a58c5f27a818c3c6437852c6f Mon Sep 17 00:00:00 2001 From: Danny Gillespie Date: Mon, 6 Jul 2026 15:01:55 +0100 Subject: [PATCH 2/2] chore(release): v0.7.0 probe gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.2 gated proof; 0.3 gated the seam; 0.4 metered the depth; 0.5 gave it a cockpit; 0.6 gated the fog. 0.7 gates the touch: build-for-learn is distinct from build-for-keep — a probe is a build whose proof-of-done is knowledge, not code. Probe code dies; probe findings live. - Bumps package.json, both plugin manifests, and the marketplace manifest to 0.7.0 (both CLIs derive their version from package.json). - Promotes the [Unreleased] entries into [0.7.0] — Probe Gate: probe closure + CLI-enforced invariants, the undrained-fog fix (fog serialized on both output modes), cold-start fog checks, receipt fog card, map convergence rule, and prompt-level surprise tripwires. - Adds the 0.7.0 compare link. npm test green (cli 60 · evolve 19 · plugin-shape 16); versions aligned across every surface. Author-model: claude-fable-5 Co-Authored-By: Claude Fable 5 --- .claude-plugin/marketplace.json | 4 ++-- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- CHANGELOG.md | 5 ++++- package.json | 2 +- 5 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 95871e8..f764a40 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.6.0" + "version": "0.7.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.6.0", + "version": "0.7.0", "author": { "name": "Danny Gillespie" }, diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index bec91a0..11e81c8 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "ratchet", "displayName": "Torque Loop", - "version": "0.6.0", + "version": "0.7.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 530d2fb..7b02599 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "torque-loop", - "version": "0.6.0", + "version": "0.7.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 f9036fd..eebd997 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.7.0] - 2026-07-06 — Probe Gate + 0.6 gated the fog: *no map → no confident build*. It left two holes: fog the dial named could live only on stdout (skipped maps leave no trace, so unrecorded uncertainty never drains confidence), and some fog cannot be mapped by reading or asking at all — it only @@ -304,7 +306,8 @@ Initial public release. - Single-plugin marketplace manifest so the repo installs directly as a Claude Code plugin. - Zero-dependency smoke test suites for the state engine and the evolution helpers. -[Unreleased]: https://github.com/TheLucidTech/torque-loop/compare/v0.6.0...HEAD +[Unreleased]: https://github.com/TheLucidTech/torque-loop/compare/v0.7.0...HEAD +[0.7.0]: https://github.com/TheLucidTech/torque-loop/compare/v0.6.0...v0.7.0 [0.6.0]: https://github.com/TheLucidTech/torque-loop/compare/v0.5.0...v0.6.0 [0.5.0]: https://github.com/TheLucidTech/torque-loop/compare/v0.4.0...v0.5.0 [0.4.0]: https://github.com/TheLucidTech/torque-loop/compare/v0.3.0...v0.4.0 diff --git a/package.json b/package.json index 5b489bb..49e002c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@torque-loop/plugin", - "version": "0.6.0", + "version": "0.7.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",