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 b666a29..eebd997 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,63 @@ 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 +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 @@ -249,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/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/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", 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`);