diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index db053f2..07b4b47 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.2.0" + "version": "0.3.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.2.0", + "version": "0.3.0", "author": { "name": "Danny Gillespie" }, diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 92f138a..becf134 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "ratchet", "displayName": "Torque Loop", - "version": "0.2.0", + "version": "0.3.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 a549789..1fe8e28 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "torque-loop", - "version": "0.2.0", + "version": "0.3.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 1532000..4bcffa9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,20 +7,74 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.0] - 2026-07-03 — Seam Gate + +Where 0.2 made the loop **require proof**, 0.3 asks whether the proof is about the +thing you are actually shipping. A proxy evaluation can produce the right-looking +number and still point at the wrong decision, so a production `KEEP` now needs +evidence from the exact ship seam — and remediated defects can finally be cleared, +waived, or superseded through the CLI instead of blocking confidence forever. + +**No proof → no keep** (0.2). **Wrong proof → no ship** (0.3). + ### Added +- **Defect lifecycle** — `ratchet defect resolve | reopen | waive | supersede`, + plus `list` and `get`. `resolve` requires `--evidence`; `waive` requires + `--owner` and `--reason`; `supersede` links the replacement with `--by`. Each + transition is logged per-defect and mirrored into the QA ledger. This is the + mutation 0.2 lacked: a defect could be born but never cleared, so remediated + work stayed confidence-blocking forever. +- **Seam-fidelity metadata on evolution events** — a verification can now record + `evidenceType`, `method`, `independentFromBuilderMethod`, `testedSeam`, + `shipSeam`, `seamMatch`, and `proxyWarning`. +- **Seam gate** — a production-code (`mode: code`) `KEEP` is rejected unless the + evidence seam is an exact match for the ship seam, or a named human waiver is + supplied. Verification that repeats the builder's own method is rejected as not + independent. +- **`ratchet retract `** — retract an artifact whose claim became false or + obsolete (`--reason`, `--superseded-by`). Provenance is preserved + (`keptForProvenance`), and a retracted artifact's holes stop draining confidence. +- **`ratchet git status-refs`** — base-qualified git status: every ahead/behind + count names the ref it was measured against — never a bare "ahead of main". +- **`ratchet doctor cold-start`** — scans for stale steering that would start the + next session in the wrong world. Generic ratchet-state checks always run; + project operator surfaces (goal files, decision sheets) are an opt-in adapter + via `.ratchet/cold-start.json`. No workspace path is hardcoded, and a + declared-but-unimplemented check warns rather than silently passing. See + `templates/cold-start.example.json`. +- **`REVERTED_AND_LEARNED`** — a first-class successful evolve outcome for a + mutation reverted after verification that still left a reusable lesson. Evolve + status counts it distinctly: corrected knowledge, no bad code kept. - Codex install metadata: `.codex-plugin/plugin.json` plus a repo-local `.agents/plugins/marketplace.json`, so the plugin can be registered and installed by Codex CLI and surfaced in the Codex app. ### Changed +- Confidence scoring now treats `waived` and `superseded` defects as terminal + (like `resolved` / `closed`) — a change proven necessary by a failing test + before it was made. `resolved` handling was already correct and was left + untouched. +- The terminal-defect predicate is centralized in `scoring.isDefectOpen` and + consumed by the scorer, the state summary, and the QA ledger, so a cleared + defect can never read as open on one surface while draining on another. +- The `ratchet` CLI gained a real `--key value` flag parser for subcommands that + carry values (the router previously treated every `--flag` as boolean). - Package metadata, `ratchet doctor`, and plugin-shape tests now cover both Claude Code and Codex manifests. - npm packaging includes the Codex marketplace file explicitly as `.agents/plugins/marketplace.json`, without packaging the whole `.agents` tree. - Repo snapshots now surface `.agents` and `.codex-plugin` alongside the existing plugin-critical dot directories. +- Bumped package, both plugin manifests, and the marketplace manifest to `0.3.0`. + +### Fixed + +- Remediated defects could never be cleared through the CLI, so they blocked + confidence permanently. They can now be resolved, waived, or superseded. +- A production `KEEP` could be justified by a proxy evaluation that measured a + different seam than the one it ships on. The seam gate now blocks that. ## [0.2.0] - 2026-07-03 — Proof Gate @@ -103,6 +157,7 @@ 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.2.0...HEAD +[Unreleased]: https://github.com/TheLucidTech/torque-loop/compare/v0.3.0...HEAD +[0.3.0]: https://github.com/TheLucidTech/torque-loop/compare/v0.2.0...v0.3.0 [0.2.0]: https://github.com/TheLucidTech/torque-loop/compare/v0.1.0...v0.2.0 [0.1.0]: https://github.com/TheLucidTech/torque-loop/releases/tag/v0.1.0 diff --git a/README.md b/README.md index fe83261..1d336c5 100644 --- a/README.md +++ b/README.md @@ -255,6 +255,41 @@ refused at write time — the loop cannot record progress it did not prove. --- +## Why seam fidelity matters + +Torque Loop does not only ask *"was this tested?"* It asks *"was this tested at the seam +you are about to ship?"* A proxy evaluation can produce the right-looking number and still +point at the wrong decision. + +In one real session, a proposed replay-only recall-router gate looked like a **+21.4%** +improvement in a fixture-shortlist eval (`apply_strategy` over a force-included lexical +shortlist). A live-seam eval against the actual ship path (`rerank_candidates` over cosine +recall, with no forced gold) showed the opposite: the gate was a **regression**. The flag +was reverted, **no code shipped**, and the router stayed as-is. + +That is a successful loop — the outcome Torque Loop now records as `REVERTED_AND_LEARNED`. + +> **No proof → no keep.** (v0.2 — the proof gate) +> **Wrong proof → no ship.** (v0.3 — the seam gate) + +The seam gate is why a production-code `KEEP` in `/ratchet:evolve` must declare an exact +ship-seam match (or a named human waiver), and why verification that merely repeats the +builder's own search method is rejected as not independent. + +### v0.3 state & quality verbs (`ratchet` CLI) + +| Command | Purpose | +| --- | --- | +| `ratchet defect resolve --evidence ""` | Clear a defect — proof required. | +| `ratchet defect waive --owner --reason ""` | Accept the risk; stop the confidence drain. | +| `ratchet defect supersede --by ` | Replace a defect with newer work. | +| `ratchet defect reopen --reason ""` | A resolved defect regressed. | +| `ratchet retract --reason "" [--superseded-by ]` | Retract a false/obsolete artifact (provenance kept). | +| `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`). | + +--- + ## How it works ``` diff --git a/package.json b/package.json index 110cbaa..ca5d68f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@torque-loop/plugin", - "version": "0.2.0", + "version": "0.3.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/src/artifacts.js b/src/artifacts.js index 50e5114..f778ed0 100644 --- a/src/artifacts.js +++ b/src/artifacts.js @@ -52,8 +52,88 @@ function addDefect(cwd, item, { alsoLedger = true } = {}) { status: record.status, foundAt: now, }).item; + // Link the state defect to its ledger mirror so lifecycle transitions can + // keep both surfaces honest instead of letting the ledger silently drift. + record.ledgerId = ledgerRecord.id; + state.saveState(cwd, s); } return { state: record, ledger: ledgerRecord }; } -module.exports = { addArtifact, addDefect }; +// Move a defect through its lifecycle: open/patched/reopened → resolved | waived +// | superseded, or resolved → reopened. This is the mutation the CLI lacked in +// 0.2: a defect could be born but never cleared, so remediated work stayed +// confidence-blocking forever. The scorer already honors terminal statuses +// (scoring.isDefectOpen); this is what finally lets a defect *reach* one. +function transitionDefect(cwd, id, toStatus, meta = {}) { + if (!schemas.DEFECT_STATUSES.includes(toStatus)) { + throw new Error(`unknown defect status "${toStatus}". valid: ${schemas.DEFECT_STATUSES.join(', ')}`); + } + const s = state.loadState(cwd); + const d = (s.defects || []).find((x) => x.id === id); + if (!d) throw new Error(`no defect with id "${id}"`); + const now = schemas.nowIso(); + const from = d.status || 'open'; + + d.status = toStatus; + d.log = Array.isArray(d.log) ? d.log : []; + d.log.push({ at: now, from, to: toStatus, note: meta.note || '' }); + + // Stamp the fields each transition owns; clear stale ones on reopen. + if (toStatus === 'resolved') { + d.resolvedAt = now; + if (meta.evidence) d.evidence = meta.evidence; + } + if (toStatus === 'reopened') { + d.resolvedAt = null; + d.reopenReason = meta.reason || ''; + } + if (toStatus === 'waived') { + d.waivedBy = meta.owner || ''; + d.waiveReason = meta.reason || ''; + } + if (toStatus === 'superseded') { + d.supersededBy = meta.by || ''; + } + + s.dirty = true; + s.history.push({ id: state.makeId('hist'), at: now, event: `defect.${toStatus}`, note: `${id}: ${from} → ${toStatus}` }); + state.saveState(cwd, s); + + // Keep the QA ledger mirror in step. Best-effort: a defect added before the + // link existed has no mirror to sync, and a ledger hiccup must never strand a + // state transition that already succeeded. + if (d.ledgerId) { + try { + require('./ledger').upsert(cwd, 'defects', { id: d.ledgerId, status: toStatus }); + } catch (_e) { + /* ledger sync is best-effort */ + } + } + return d; +} + +// Retract an artifact whose claim turned out false or obsolete. Provenance is +// preserved (keptForProvenance) — the record stays in history, but its status +// flips to `retracted` so it stops steering cold sessions and its holes stop +// draining confidence. This is the move the T2.3 re-scope doc needed when its +// central premise ("no endpoint exists") was disproven by the live seam. +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}"`); + const now = schemas.nowIso(); + a.status = 'retracted'; + a.retracted = { at: now, reason, supersededBy, keptForProvenance: true }; + s.dirty = true; + s.history.push({ + id: state.makeId('hist'), + at: now, + event: 'artifact.retracted', + note: `${id}: ${reason}${supersededBy ? ` → superseded by ${supersededBy}` : ''}`, + }); + state.saveState(cwd, s); + return a; +} + +module.exports = { addArtifact, addDefect, transitionDefect, retractArtifact }; diff --git a/src/cli.js b/src/cli.js index 395115d..3501aa6 100644 --- a/src/cli.js +++ b/src/cli.js @@ -8,6 +8,8 @@ const ledger = require('./ledger'); const scoring = require('./scoring'); const artifacts = require('./artifacts'); const repo = require('./repoSnapshot'); +const gitRefs = require('./gitRefs'); +const coldStart = require('./coldStart'); const md = require('./markdown'); const schemas = require('./schemas'); @@ -49,6 +51,43 @@ function coerceScalar(key, value) { return value; } +// Minimal `--key value` parser for subcommands that carry real values (defect +// lifecycle). The top-level router treats every `--flag` as boolean, which is +// fine for switches like --json but drops values like --evidence "". +// Declared booleans never swallow the following positional. +function parseArgv(argv, { booleans = [] } = {}) { + const boolSet = new Set(booleans); + const positionals = []; + const opts = {}; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (typeof a === 'string' && a.startsWith('--')) { + const eq = a.indexOf('='); + if (eq !== -1) { + opts[a.slice(2, eq)] = a.slice(eq + 1); + continue; + } + const key = a.slice(2); + const next = argv[i + 1]; + if (!boolSet.has(key) && next != null && !String(next).startsWith('--')) { + opts[key] = next; + i++; + } else { + opts[key] = true; + } + } else { + positionals.push(a); + } + } + return { positionals, opts }; +} + +// A flag value is only usable if it is a non-empty string; a bare `--evidence` +// (no value) parses as `true` and must be rejected, not stringified to "true". +function strOpt(v) { + return v == null || v === true ? '' : String(v).trim(); +} + // --------------------------------------------------------------------------- // Command table. // --------------------------------------------------------------------------- @@ -95,11 +134,11 @@ function run(argv) { return out(`artifact ${rec.id} added: ${rec.title} (${rec.status})`); } - case 'defect': { - if (sub !== 'add') throw new Error('usage: ratchet defect add '); - const rec = artifacts.addDefect(cwd, readPayload(rest[0])); - return out(`defect ${rec.state.id} added: [${rec.state.severity}] ${rec.state.summary}`); - } + case 'defect': + return cmdDefect(cwd, args, asJson); + + case 'retract': + return cmdRetract(cwd, args); case 'score': return cmdScore(cwd, sub, rest, asJson); @@ -110,6 +149,14 @@ function run(argv) { return out(asJson ? JSON.stringify(snap, null, 2) : md.repoSnapshot(snap)); } + case 'git': { + if (sub !== 'status-refs' && sub !== 'status') { + throw new Error('usage: ratchet git status-refs [--json]'); + } + const refs = gitRefs.statusRefs(cwd); + return out(asJson ? JSON.stringify(refs, null, 2) : md.gitStatusRefs(refs)); + } + case 'export': return cmdExport(cwd, sub); @@ -124,6 +171,7 @@ function run(argv) { } case 'doctor': + if (sub === 'cold-start') return cmdColdStart(cwd, asJson); return cmdDoctor(cwd, asJson); case 'touch': { @@ -219,6 +267,95 @@ function cmdLedger(cwd, sub, rest, asJson) { } } +// Defect lifecycle — the mutation the CLI lacked in 0.2. `add` keeps its JSON +// contract; every other verb transitions an existing defect by id and (for the +// clearing verbs) demands the proof/reason that makes the clear honest. +function cmdDefect(cwd, argv, asJson) { + // argv still includes the leading "defect" group token → positionals[0]. + const { positionals, opts } = parseArgv(argv, { booleans: ['json'] }); + const sub = positionals[1]; + const id = positionals[2]; + const json = asJson || opts.json === true; + + const need = (val, msg) => { + if (!val) throw new Error(msg); + return val; + }; + + switch (sub) { + case 'add': { + const rec = artifacts.addDefect(cwd, readPayload(positionals[2])); + return out(`defect ${rec.state.id} added: [${rec.state.severity}] ${rec.state.summary}`); + } + case 'list': { + const s = state.loadState(cwd); + return out(json ? JSON.stringify(s.defects || [], null, 2) : md.defectList(s.defects || [])); + } + case 'get': { + need(id, 'usage: ratchet defect get [--json]'); + const s = state.loadState(cwd); + const d = (s.defects || []).find((x) => x.id === id); + if (!d) throw new Error(`no defect with id "${id}"`); + return out(json ? JSON.stringify(d, null, 2) : md.defectOne(d)); + } + case 'resolve': { + need(id, 'usage: ratchet defect resolve --evidence ""'); + const evidence = strOpt(opts.evidence); + // Proof gate, same spirit as the evolve KEEP gate: a defect cannot be + // marked fixed without stating the proof that it is actually fixed. + need(evidence, 'defect resolve requires --evidence "" — no proof, no resolve'); + artifacts.transitionDefect(cwd, id, 'resolved', { evidence, note: `resolved: ${evidence}` }); + return out(`defect ${id} → resolved`); + } + case 'reopen': { + need(id, 'usage: ratchet defect reopen --reason ""'); + const reason = strOpt(opts.reason); + need(reason, 'defect reopen requires --reason ""'); + artifacts.transitionDefect(cwd, id, 'reopened', { reason, note: `reopened: ${reason}` }); + return out(`defect ${id} → reopened`); + } + case 'waive': { + need(id, 'usage: ratchet defect waive --owner "" --reason ""'); + const owner = strOpt(opts.owner); + const reason = strOpt(opts.reason); + need(owner, 'defect waive requires --owner ""'); + need(reason, 'defect waive requires --reason ""'); + artifacts.transitionDefect(cwd, id, 'waived', { owner, reason, note: `waived by ${owner}: ${reason}` }); + return out(`defect ${id} → waived (owner: ${owner})`); + } + case 'supersede': { + need(id, 'usage: ratchet defect supersede --by '); + const by = strOpt(opts.by); + need(by, 'defect supersede requires --by '); + const reason = strOpt(opts.reason); + artifacts.transitionDefect(cwd, id, 'superseded', { + by, + reason, + note: `superseded by ${by}${reason ? `: ${reason}` : ''}`, + }); + return out(`defect ${id} → superseded (by: ${by})`); + } + default: + throw new Error( + 'usage: ratchet defect ...' + (sub ? ` (got "${sub}")` : '') + ); + } +} + +// Retract an artifact whose central claim became false or obsolete. Requires a +// reason (proof-gate spirit: never retract silently); --superseded-by links the +// replacement so provenance survives. +function cmdRetract(cwd, argv) { + const { positionals, opts } = parseArgv(argv, {}); + const id = positionals[1]; + if (!id) throw new Error('usage: ratchet retract --reason "" [--superseded-by ]'); + const reason = strOpt(opts.reason); + if (!reason) throw new Error("retract requires --reason \"\""); + const supersededBy = strOpt(opts['superseded-by']); + artifacts.retractArtifact(cwd, id, { reason, supersededBy }); + return out(`artifact ${id} retracted${supersededBy ? ` (superseded by ${supersededBy})` : ''}`); +} + function cmdScore(cwd, sub, rest, asJson) { switch (sub) { case 'friction': { @@ -437,6 +574,31 @@ function cmdDoctor(cwd, asJson) { if (failed.length) process.exitCode = 1; } +// Cold-start poison scan: does the current state / operator surfaces steer the +// next session into the wrong world? Generic ratchet-state checks always run; +// project surfaces are opt-in via .ratchet/cold-start.json. FAIL = contradiction. +function cmdColdStart(cwd, asJson) { + const result = coldStart.scan(cwd); + if (asJson) { + out(JSON.stringify(result, null, 2)); + } else { + out('ratchet doctor cold-start'); + out(''); + for (const c of result.checks) { + const tag = c.level === 'ok' ? 'ok ' : c.level === 'warn' ? 'warn' : 'FAIL'; + out(` ${tag} ${c.name}${c.detail ? ` — ${c.detail}` : ''}`); + } + out(''); + const fails = result.checks.filter((c) => c.level === 'fail').length; + const warns = result.checks.filter((c) => c.level === 'warn').length; + if (fails) out(`${fails} contradiction(s), ${warns} warning(s) — cold-start surfaces may steer the next session wrong.`); + else if (warns) out(`no contradictions, ${warns} warning(s).`); + else out('clean — no stale steering detected.'); + if (!result.configured) out('(generic checks only — add .ratchet/cold-start.json to scan project surfaces)'); + } + if (!result.ok) process.exitCode = 1; +} + function help() { out( [ @@ -454,6 +616,13 @@ function help() { 'ARTIFACTS & DEFECTS', ' ratchet artifact add record an artifact ({title,kind,status,path,holes})', ' ratchet defect add record a defect ({severity,summary,feature}) — also hits ledger', + ' ratchet defect list [--json] list defects (○ draining / ● terminal)', + ' ratchet defect get [--json] show one defect + its lifecycle log', + ' ratchet defect resolve --evidence "" mark fixed (proof required)', + ' ratchet defect reopen --reason "" a resolved defect regressed', + ' ratchet defect waive --owner "" --reason "" accept the risk, stop the drain', + ' ratchet defect supersede --by replaced by newer work', + ' ratchet retract --reason "" [--superseded-by ] retract a false/obsolete artifact', '', 'LEDGER (QA canonical record)', ' ratchet ledger create ensure ledger exists', @@ -466,11 +635,13 @@ function help() { '', 'CONTEXT', ' ratchet snapshot repo [path] cheap repo read (files, dirs, git)', + ' ratchet git status-refs [--json] ahead/behind vs every base ref, each named', ' ratchet export markdown full session compile', ' ratchet export json raw state', '', 'PLUGIN HEALTH', ' ratchet doctor [--json] check plugin shape, version alignment, state dir', + ' 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.', ].join('\n') diff --git a/src/coldStart.js b/src/coldStart.js new file mode 100644 index 0000000..d639cf5 --- /dev/null +++ b/src/coldStart.js @@ -0,0 +1,198 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const state = require('./state'); +const scoring = require('./scoring'); + +// Cold-start poison: stale steering that makes the NEXT session start in the +// wrong world. This scanner knows what stale steering looks like — it does NOT +// know what any particular workspace is. Generic ratchet-state checks always +// run; project-specific operator surfaces (goal files, decision sheets) are an +// opt-in adapter declared in .ratchet/cold-start.json. No workspace path is +// ever hardcoded here. + +function loadConfig(cwd) { + const p = path.join(cwd, '.ratchet', 'cold-start.json'); + try { + return JSON.parse(fs.readFileSync(p, 'utf8')); + } catch (_e) { + return null; // no config → generic checks only + } +} + +// --- opt-in surface checks -------------------------------------------------- +// A small registry so declared-but-unimplemented checks warn (transparent) +// rather than silently pass. Each returns { level: 'ok'|'warn'|'fail', detail }. + +function toPosix(p) { + return String(p).split(path.sep).join('/'); +} + +const SURFACE_CHECKS = { + 'valid-as-of': ({ text }) => { + const has = /valid[-\s]?as[-\s]?of|as of\s+\d{4}-\d{2}-\d{2}|updated:?\s*\d{4}-\d{2}-\d{2}/i.test(text); + return has ? { level: 'ok' } : { level: 'warn', detail: 'no valid-as-of / date stamp — a reader cannot tell if it is current' }; + }, + 'base-qualified-git': ({ text }) => { + const offenders = []; + for (const line of text.split('\n')) { + const mentionsCount = /\b\d+\s+(ahead|behind)\b/i.test(line) || /\bahead of\b/i.test(line); + if (!mentionsCount) continue; + const named = /origin\/|upstream|@\{u\}|[\w.-]+\/[\w.-]+/.test(line); // a ref with a slash, or a known remote + if (!named) offenders.push(line.trim()); + } + return offenders.length + ? { level: 'fail', detail: `unqualified git count: ${offenders.slice(0, 2).map((l) => `"${l}"`).join(', ')}` } + : { level: 'ok' }; + }, + 'no-retracted-claims': ({ text, retracted }) => { + const hit = retracted.filter((a) => a.title && text.includes(a.title) && !/\[RETRACTED\]/i.test(text)); + return hit.length + ? { level: 'fail', detail: `repeats retracted claim "${hit[0].title}" with no [RETRACTED] marker` } + : { level: 'ok' }; + }, + 'retracted-title': ({ text, file, cwd, retracted }) => { + const rel = toPosix(path.relative(cwd, file)); + const match = retracted.find((a) => a.path && toPosix(a.path) === rel); + if (!match) return { level: 'ok', detail: 'not a retracted artifact file' }; + const firstLine = text.split('\n').find((l) => l.trim()) || ''; + return /\[RETRACTED\]/i.test(firstLine) + ? { level: 'ok' } + : { level: 'fail', detail: 'retracted artifact file does not start with [RETRACTED]' }; + }, + 'supersession-link': ({ text, file, cwd, retracted }) => { + const rel = toPosix(path.relative(cwd, file)); + const match = retracted.find((a) => a.path && toPosix(a.path) === rel); + const by = match && match.retracted && match.retracted.supersededBy; + if (!by) return { level: 'ok' }; + return text.includes(by) ? { level: 'ok' } : { level: 'warn', detail: `does not link its superseder ${by}` }; + }, +}; + +function globToRegex(glob) { + let re = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&'); + re = re.replace(/\\\*\\\*\//g, '(?:.*/)?').replace(/\\\*\\\*/g, '.*').replace(/\\\*/g, '[^/]*').replace(/\\\?/g, '[^/]'); + return new RegExp('^' + re + '$'); +} + +function globFiles(cwd, glob) { + const re = globToRegex(glob); + const SKIP = new Set(['node_modules', '.git', '.ratchet', '.hg', 'dist', 'build', 'coverage']); + const out = []; + const stack = ['.']; + let count = 0; + while (stack.length && count < 5000) { + const rel = stack.pop(); + let entries; + try { + entries = fs.readdirSync(path.join(cwd, rel), { withFileTypes: true }); + } catch (_e) { + continue; + } + for (const e of entries) { + const childRel = rel === '.' ? e.name : `${rel}/${e.name}`; + if (e.isDirectory()) { + if (!SKIP.has(e.name)) stack.push(childRel); + } else if (e.isFile()) { + count++; + if (re.test(toPosix(childRel))) out.push(path.join(cwd, childRel)); + } + } + } + return out; +} + +function resolveSurface(cwd, surface) { + if (surface.path) { + const p = path.resolve(cwd, surface.path); + return fs.existsSync(p) && fs.statSync(p).isFile() ? [p] : []; + } + if (surface.glob) return globFiles(cwd, surface.glob); + return []; +} + +function runSurfaceChecks(cwd, file, surface, retracted, add) { + let text = ''; + try { + text = fs.readFileSync(file, 'utf8'); + } catch (_e) { + add(`${path.relative(cwd, file)}`, 'warn', 'surface unreadable'); + return; + } + const rel = path.relative(cwd, file); + const ctx = { text, file, cwd, retracted }; + for (const checkName of surface.checks || []) { + const fn = SURFACE_CHECKS[checkName]; + if (!fn) { + add(`${rel} · ${checkName}`, 'warn', 'check declared but not implemented in this version (not silently skipped)'); + continue; + } + const res = fn(ctx); + add(`${rel} · ${checkName}`, res.level, res.detail); + } +} + +// --- scanner ---------------------------------------------------------------- + +function scan(cwd = process.cwd()) { + const checks = []; + const add = (name, level, detail) => checks.push({ name, level, detail: detail || '' }); + const s = state.loadState(cwd); + const artifacts = s.artifacts || []; + const retracted = artifacts.filter((a) => a.status === 'retracted' || a.status === 'superseded'); + + // Generic ratchet-state checks (always run) -------------------------------- + if (s.objective && String(s.objective).trim()) add('objective is set', 'ok'); + else add('objective is set', 'warn', 'no locked objective — a cold session starts without a target'); + + const nextAction = String(s.nextAction || '').trim(); + 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'); + + const lastArtifact = artifacts[artifacts.length - 1]; + if (lastArtifact && (lastArtifact.status === 'retracted' || lastArtifact.status === 'superseded')) { + 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'); + } + + const steeredToDead = retracted.filter( + (a) => nextAction && (nextAction.includes(a.id) || (a.title && nextAction.includes(a.title))) + ); + if (steeredToDead.length) { + add('next action avoids retracted work', 'fail', + `next action references ${steeredToDead.map((a) => a.id).join(', ')} which is retracted/superseded`); + } else { + add('next action avoids retracted work', 'ok'); + } + + const retractedIds = new Set(retracted.map((a) => a.id)); + const staleDefects = (s.defects || []).filter((d) => scoring.isDefectOpen(d) && d.artifact && retractedIds.has(d.artifact)); + if (staleDefects.length) { + add('active defects have live premises', 'fail', + `${staleDefects.length} open defect(s) attach to a retracted/superseded artifact — their premise may be gone`); + } else { + add('active defects have live premises', 'ok'); + } + + // Opt-in project-surface checks (adapter) ---------------------------------- + const config = loadConfig(cwd); + if (config && Array.isArray(config.surfaces)) { + for (const surface of config.surfaces) { + const files = resolveSurface(cwd, surface); + if (!files.length) { + add(`surface ${surface.path || surface.glob}`, 'warn', 'configured but no matching file found'); + continue; + } + for (const file of files) runSurfaceChecks(cwd, file, surface, retracted, add); + } + } + + const ok = !checks.some((c) => c.level === 'fail'); + return { ok, configured: Boolean(config), checks }; +} + +module.exports = { scan, SURFACE_CHECKS, loadConfig }; diff --git a/src/evolve/index.js b/src/evolve/index.js index c5cdfe4..65c5bbd 100644 --- a/src/evolve/index.js +++ b/src/evolve/index.js @@ -105,11 +105,17 @@ function renderStatus(s) { o.push(`- Events: ${s.events}`); o.push(`- Kept: ${s.kept}`); o.push(`- Reverted: ${s.reverted}`); + if (s.revertedAndLearned) { + o.push(`- Reverted & learned: ${s.revertedAndLearned} (successful loop — corrected knowledge, no bad code kept)`); + } o.push(`- Asks: ${s.asks}`); o.push('- Active targets:'); for (const t of s.targets) o.push(` - ${t}`); if (s.last) { o.push(`- Last verdict: ${s.last.verdict} (${s.last.id})`); + if (s.last.seam && s.last.seam.seamMatch) { + o.push(`- Last seam match: ${s.last.seam.seamMatch}${s.last.seam.shipSeam ? ` (ship seam: ${s.last.seam.shipSeam})` : ''}`); + } if (s.last.nextEdge) o.push(`- Next edge: ${s.last.nextEdge}`); } return o.join('\n'); @@ -216,6 +222,7 @@ function help() { ' ratchet-evolve score mutation rank candidates, pick one (--json)', ' ratchet-evolve verify [--test "cmd"] [--mode m] gather verification evidence (--json)', ' ratchet-evolve log append write an evolution event (KEEP needs proof)', + ' code KEEP also needs seam:{seamMatch:"exact",...} or seam.waiver:{by,reason} — wrong proof → no ship', ' ratchet-evolve log | status read the evolve log (--json)', ' ratchet-evolve next show the last recorded next edge (--json)', ' ratchet-evolve pressure [mode] suggested pressure vector for a mode', diff --git a/src/evolve/journal.js b/src/evolve/journal.js index 7b2e1a6..c53d945 100644 --- a/src/evolve/journal.js +++ b/src/evolve/journal.js @@ -60,13 +60,16 @@ function appendEvent(cwd, fields) { function status(cwd = process.cwd()) { const events = readEvents(cwd); - if (!events.length) return { events: 0, kept: 0, reverted: 0, asks: 0, targets: [], last: null }; + if (!events.length) { + return { events: 0, kept: 0, reverted: 0, revertedAndLearned: 0, asks: 0, targets: [], last: null }; + } const targets = [...new Set(events.map((e) => e.target))]; const last = events[events.length - 1]; const kept = events.filter((e) => e.verdict === 'KEEP').length; const reverted = events.filter((e) => e.verdict === 'REVERT').length; + const revertedAndLearned = events.filter((e) => e.verdict === 'REVERTED_AND_LEARNED').length; const asks = events.filter((e) => e.verdict === 'ASK').length; - return { events: events.length, kept, reverted, asks, targets, last }; + return { events: events.length, kept, reverted, revertedAndLearned, asks, targets, last }; } module.exports = { logPath, readEvents, appendEvent, nextId, status }; diff --git a/src/evolve/schema.js b/src/evolve/schema.js index b07c0cc..d39fb27 100644 --- a/src/evolve/schema.js +++ b/src/evolve/schema.js @@ -18,7 +18,23 @@ const PRESSURES = [ ]; const MODES = ['code', 'prompt', 'docs', 'workflow', 'auto']; -const VERDICTS = ['KEEP', 'REVERT', 'ASK']; +// REVERTED_AND_LEARNED is a *successful* outcome: a mutation was reverted after +// verification and a reusable lesson/test remained. Like REVERT it never claims +// a kept improvement, so it bypasses the KEEP proof gate. +const VERDICTS = ['KEEP', 'REVERT', 'ASK', 'REVERTED_AND_LEARNED']; + +// Seam fidelity (0.3). How close the tested seam is to the seam the artifact +// actually ships on, and what kind of evidence produced the claim. +const SEAM_MATCHES = ['exact', 'close-proxy', 'weak-proxy', 'mismatch']; +const EVIDENCE_TYPES = ['test', 'manual', 'code-read', 'live-call', 'static-analysis', 'eval', 're-run']; + +// A human waiver is only valid if it names who accepts the risk and why. +function normalizeWaiver(w) { + if (!w || typeof w !== 'object') return null; + const by = w.by || w.owner || ''; + const reason = w.reason || ''; + return by && reason ? { by, reason } : null; +} function nowIso() { if (process.env.RATCHET_NOW) return process.env.RATCHET_NOW; @@ -32,6 +48,7 @@ function newEvent(f = {}) { target: f.target || '', goal: f.goal || '', iteration: f.iteration != null ? f.iteration : 1, + mode: MODES.includes(f.mode) ? f.mode : '', pressure: { primary: f.pressure?.primary || (f.primary || ''), secondary: f.pressure?.secondary || (f.secondary || ''), @@ -43,6 +60,18 @@ function newEvent(f = {}) { manualChecks: f.verification?.manualChecks || [], result: f.verification?.result || 'manual', // pass | fail | manual }, + // Seam-fidelity metadata: what was tested, how, and whether that seam is the + // one the artifact actually ships on. The KEEP gate reads this for code. + seam: { + evidenceType: f.seam?.evidenceType || f.evidenceType || '', + method: f.seam?.method || f.method || '', + independentFromBuilderMethod: f.seam?.independentFromBuilderMethod ?? f.independentFromBuilderMethod ?? null, + testedSeam: f.seam?.testedSeam || f.testedSeam || '', + shipSeam: f.seam?.shipSeam || f.shipSeam || '', + seamMatch: SEAM_MATCHES.includes(f.seam?.seamMatch || f.seamMatch) ? f.seam?.seamMatch || f.seamMatch : '', + proxyWarning: f.seam?.proxyWarning ?? f.proxyWarning ?? null, + waiver: normalizeWaiver(f.seam?.waiver || f.waiver), + }, verdict: VERDICTS.includes(f.verdict) ? f.verdict : 'ASK', remainingRisks: Array.isArray(f.remainingRisks) ? f.remainingRisks : [], nextEdge: f.nextEdge || '', @@ -70,7 +99,31 @@ function validateKeepGate(event) { if (result === 'manual' && !manualChecks.length) { throw new Error('cannot KEEP: manual verification requires explicit checks'); } + + // Seam gate (0.3 — "wrong proof → no ship"). Production code cannot be KEPT on + // evidence from a different seam than the one it ships on: a proxy eval can + // show the right-looking number and still point at the wrong decision. Require + // an exact ship-seam match (or an explicit, named human waiver). Non-code + // artifacts (docs/prompt/workflow) keep the lighter manual-evidence contract. + if (event.mode === 'code') { + const seam = event.seam || {}; + const waived = Boolean(seam.waiver && seam.waiver.by && seam.waiver.reason); + if (!waived) { + if (seam.seamMatch !== 'exact') { + throw new Error( + `cannot KEEP production code: evidence seam is "${seam.seamMatch || 'unspecified'}", not "exact" — ` + + 'test the ship seam, or waive with a named owner + reason (wrong proof → no ship)' + ); + } + if (seam.independentFromBuilderMethod === false) { + throw new Error( + "cannot KEEP production code: verification repeated the builder's own method, so it is not " + + 'independent — vary the method, or waive with a named owner + reason' + ); + } + } + } return event; } -module.exports = { PRESSURES, MODES, VERDICTS, nowIso, newEvent, validateKeepGate }; +module.exports = { PRESSURES, MODES, VERDICTS, SEAM_MATCHES, EVIDENCE_TYPES, nowIso, newEvent, validateKeepGate }; diff --git a/src/gitRefs.js b/src/gitRefs.js new file mode 100644 index 0000000..7f51a1a --- /dev/null +++ b/src/gitRefs.js @@ -0,0 +1,81 @@ +'use strict'; + +const { execFileSync } = require('child_process'); + +// Base-qualified git status. The session that inspired 0.3 reported "43 ahead of +// main" — true only against a stale local `main`; the useful truth was "82 ahead +// of origin/main, 10 ahead of origin/wip". So this helper NEVER emits an +// ahead/behind number without naming the ref it was measured against. + +function safeGit(args, cwd) { + try { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 5000, + }).trim(); + } catch (_e) { + return null; + } +} + +function refExists(ref, cwd) { + return safeGit(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], cwd) != null; +} + +// `git rev-list --left-right --count base...HEAD` → " ": +// left = reachable from base but not HEAD → HEAD is *behind* by that many +// right = reachable from HEAD but not base → HEAD is *ahead* by that many +function aheadBehind(base, cwd) { + if (!refExists(base, cwd)) return null; + const out = safeGit(['rev-list', '--left-right', '--count', `${base}...HEAD`], cwd); + if (!out) return null; + const [behind, ahead] = out.split(/\s+/).map((n) => parseInt(n, 10)); + if (Number.isNaN(behind) || Number.isNaN(ahead)) return null; + return { base, ahead, behind }; +} + +function statusRefs(cwd = process.cwd()) { + if (safeGit(['rev-parse', '--is-inside-work-tree'], cwd) !== 'true') { + return { isRepo: false }; + } + + const branch = safeGit(['rev-parse', '--abbrev-ref', 'HEAD'], cwd); + const head = safeGit(['rev-parse', '--short', 'HEAD'], cwd); + const upstream = safeGit(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], cwd); // null if none + const dirty = (safeGit(['status', '--porcelain'], cwd) || '').length > 0; + + // Compare against every base that actually exists — the tracking ref first, + // then the usual mains. Each result carries its own base name. + const bases = []; + if (upstream) bases.push(upstream); + for (const b of ['main', 'origin/main', 'master', 'origin/master']) { + if (!bases.includes(b)) bases.push(b); + } + const comparisons = bases.map((b) => aheadBehind(b, cwd)).filter(Boolean); + + // Unpushed = commits on HEAD not on the upstream tracking ref. + let unpushed = null; + if (upstream) { + const c = safeGit(['rev-list', '--count', `${upstream}..HEAD`], cwd); + unpushed = c == null ? null : parseInt(c, 10); + } + + // Does any remote-tracking branch already contain HEAD? (Is this pushed?) + const remoteContains = safeGit(['branch', '-r', '--contains', 'HEAD'], cwd); + const remoteContainsHead = Boolean(remoteContains && remoteContains.trim()); + + return { + isRepo: true, + branch, + head, + upstream: upstream || null, + dirty, + comparisons, + unpushed, + remoteContainsHead, + }; +} + +module.exports = { statusRefs, aheadBehind, safeGit }; diff --git a/src/ledger.js b/src/ledger.js index 25a52e6..d7197e0 100644 --- a/src/ledger.js +++ b/src/ledger.js @@ -2,6 +2,7 @@ const state = require('./state'); const schemas = require('./schemas'); +const scoring = require('./scoring'); // The QA ledger is the canonical feature/test/defect record for a repo. // It is separate from session state: state is "this session"; the ledger is @@ -37,7 +38,7 @@ function upsert(cwd, collection, item) { } function summary(ledger) { - const openDefects = (ledger.defects || []).filter((d) => d.status !== 'resolved' && d.status !== 'closed'); + const openDefects = (ledger.defects || []).filter(scoring.isDefectOpen); const failingTests = (ledger.tests || []).filter((t) => t.status === 'fail'); return { features: (ledger.features || []).length, diff --git a/src/markdown.js b/src/markdown.js index d92e7f8..1ae83ff 100644 --- a/src/markdown.js +++ b/src/markdown.js @@ -17,7 +17,7 @@ function bullets(items, fmt) { function stateSummary(state) { const conf = scoring.scoreConfidence(state); - const openDefects = (state.defects || []).filter((d) => d.status !== 'resolved' && d.status !== 'closed'); + const openDefects = (state.defects || []).filter(scoring.isDefectOpen); const openLoops = (state.openLoops || []).filter((l) => l.status !== 'closed'); const untested = (state.assumptions || []).filter((a) => a.status !== 'tested' && a.status !== 'killed'); const lastArtifact = (state.artifacts || [])[state.artifacts.length - 1]; @@ -90,6 +90,56 @@ function confidence(state) { return out.join('\n'); } +// `○` = still draining confidence, `●` = terminal (resolved/waived/superseded). +function defectList(defects) { + if (!defects || !defects.length) return '_no defects recorded_'; + const lines = ['### Defects']; + for (const d of defects) { + const open = scoring.isDefectOpen(d); + lines.push(`- ${open ? '○' : '●'} \`${d.id}\` [${d.severity}] ${d.summary} — **${d.status}**`); + } + const draining = defects.filter(scoring.isDefectOpen).length; + lines.push(''); + lines.push(`_${draining} draining · ${defects.length - draining} terminal · ${defects.length} total_`); + return lines.join('\n'); +} + +function defectOne(d) { + const lines = []; + lines.push(`### Defect \`${d.id}\``); + lines.push(`- **Severity:** ${d.severity}`); + lines.push(`- **Status:** ${d.status}${scoring.isDefectOpen(d) ? ' (draining confidence)' : ' (terminal — not draining)'}`); + lines.push(`- **Summary:** ${d.summary}`); + if (d.evidence) lines.push(`- **Resolution evidence:** ${d.evidence}`); + if (d.waivedBy) lines.push(`- **Waived by:** ${d.waivedBy}${d.waiveReason ? ` — ${d.waiveReason}` : ''}`); + if (d.supersededBy) lines.push(`- **Superseded by:** ${d.supersededBy}`); + if (Array.isArray(d.log) && d.log.length) { + lines.push(`- **History:**`); + for (const e of d.log) lines.push(` - ${dash(e.at)}: ${dash(e.from)} → ${e.to}${e.note ? ` — ${e.note}` : ''}`); + } + return lines.join('\n'); +} + +function gitStatusRefs(refs) { + if (!refs || !refs.isRepo) return '### Git status\n_not a git repository_'; + const o = []; + o.push('### Git status (base-qualified)'); + o.push(`- **Branch:** ${dash(refs.branch)} @ ${dash(refs.head)} — ${refs.dirty ? 'dirty' : 'clean'}`); + o.push(`- **Upstream:** ${refs.upstream || '— none set'}`); + if (refs.comparisons && refs.comparisons.length) { + for (const c of refs.comparisons) { + o.push(`- **vs \`${c.base}\`:** ${c.ahead} ahead, ${c.behind} behind`); + } + } else { + o.push('- _no comparison bases found (main/origin-main/upstream all absent)_'); + } + o.push(`- **Unpushed (vs upstream):** ${refs.unpushed == null ? '— no upstream set' : refs.unpushed}`); + o.push(`- **A remote branch contains HEAD:** ${refs.remoteContainsHead ? 'yes' : 'no'}`); + o.push(''); + o.push('_Every count names its base ref — never "ahead of main" without saying which main._'); + return o.join('\n'); +} + function repoSnapshot(snap) { const out = []; out.push(`### Repo snapshot — ${snap.root}`); @@ -155,4 +205,4 @@ function fullExport(state, ledger) { return out.join('\n'); } -module.exports = { stateSummary, friction, confidence, repoSnapshot, fullExport, dash, bullets }; +module.exports = { stateSummary, friction, confidence, defectList, defectOne, gitStatusRefs, repoSnapshot, fullExport, dash, bullets }; diff --git a/src/schemas.js b/src/schemas.js index a416f86..29692aa 100644 --- a/src/schemas.js +++ b/src/schemas.js @@ -84,6 +84,12 @@ const LEDGER_COLLECTIONS = { const SEVERITIES = ['critical', 'high', 'medium', 'low', 'info']; const PHASES = ['idle', 'lock', 'auction', 'cut', 'build', 'attack', 'patch', 'compile', 'loop']; +// Defect lifecycle. `open`/`patched`/`reopened` are still-live pressure and +// drain confidence; the TERMINAL set does not. `closed` is the pre-0.3 alias +// for `resolved`. A defect only leaves the terminal set by being reopened. +const DEFECT_STATUSES = ['open', 'patched', 'reopened', 'resolved', 'waived', 'superseded', 'closed']; +const DEFECT_TERMINAL_STATUSES = ['resolved', 'closed', 'waived', 'superseded']; + module.exports = { STATE_VERSION, LEDGER_VERSION, @@ -95,4 +101,6 @@ module.exports = { LEDGER_COLLECTIONS, SEVERITIES, PHASES, + DEFECT_STATUSES, + DEFECT_TERMINAL_STATUSES, }; diff --git a/src/scoring.js b/src/scoring.js index 5269c1e..70f9c00 100644 --- a/src/scoring.js +++ b/src/scoring.js @@ -3,6 +3,19 @@ // Scoring is deliberately transparent: the model can reproduce it by hand. // No hidden weighting, no ML — just multiplicative pressure functions. +const schemas = require('./schemas'); + +// One predicate, one source of truth. Confidence, the state summary, and the +// QA ledger all decide "is this defect still draining?" the same way — so a +// resolved/waived/superseded defect can never lie on one surface while it drains +// on another. Terminal statuses are read from schemas so the lifecycle verbs +// and the scorer can never disagree about what "done" means. +const TERMINAL_DEFECT_STATUSES = new Set(schemas.DEFECT_TERMINAL_STATUSES.map((s) => s.toLowerCase())); +function isDefectOpen(defect) { + const status = String((defect && defect.status) || 'open').toLowerCase(); + return !TERMINAL_DEFECT_STATUSES.has(status); +} + function clamp(n, lo, hi) { n = Number(n); if (Number.isNaN(n)) return lo; @@ -59,7 +72,7 @@ function scoreFriction(obstacles) { function scoreConfidence(state) { const penalties = []; - const openDefects = (state.defects || []).filter((d) => d.status !== 'resolved' && d.status !== 'closed'); + const openDefects = (state.defects || []).filter(isDefectOpen); const bySev = (sev) => openDefects.filter((d) => (d.severity || 'medium').toLowerCase() === sev); const critical = bySev('critical'); @@ -75,7 +88,9 @@ function scoreConfidence(state) { const untested = (state.assumptions || []).filter((a) => a.status !== 'tested' && a.status !== 'killed'); if (untested.length) penalties.push({ reason: `${untested.length} untested assumption(s)`, cost: 10 * untested.length }); - const holey = (state.artifacts || []).filter((a) => Array.isArray(a.holes) && a.holes.length > 0); + const holey = (state.artifacts || []).filter( + (a) => a.status !== 'retracted' && Array.isArray(a.holes) && a.holes.length > 0 + ); if (holey.length) penalties.push({ reason: `${holey.length} artifact(s) with known holes`, cost: 6 * holey.length }); const openLoops = (state.openLoops || []).filter((l) => l.status !== 'closed'); @@ -108,4 +123,4 @@ function scoreConfidence(state) { return { score, band, loopClear, penalties, openDefects: openDefects.length }; } -module.exports = { scoreFriction, scoreConfidence, clamp }; +module.exports = { scoreFriction, scoreConfidence, clamp, isDefectOpen, TERMINAL_DEFECT_STATUSES }; diff --git a/templates/cold-start.example.json b/templates/cold-start.example.json new file mode 100644 index 0000000..af54ffd --- /dev/null +++ b/templates/cold-start.example.json @@ -0,0 +1,20 @@ +{ + "_comment": "Copy to .ratchet/cold-start.json to add project-specific cold-start surface checks. The generic ratchet-state checks always run without this file; this only adds an OPT-IN adapter for your own operator surfaces. Torque Loop ships no built-in knowledge of any specific workspace — the .lucid/goal entry below is an EXAMPLE, not default behavior.", + "surfaces": [ + { + "path": ".lucid/goal", + "kind": "goal", + "checks": ["base-qualified-git", "valid-as-of", "no-retracted-claims"] + }, + { + "path": "../local-loop/DECISION_SHEET.md", + "kind": "decision-sheet", + "checks": ["valid-as-of", "base-qualified-git", "no-retracted-claims"] + }, + { + "glob": "reports/**/*.md", + "kind": "artifact", + "checks": ["retracted-title", "supersession-link"] + } + ] +} diff --git a/test/cli.test.js b/test/cli.test.js index 0ca6d5f..1ede53c 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -18,6 +18,8 @@ const artifacts = require('../src/artifacts'); const ledger = require('../src/ledger'); const md = require('../src/markdown'); const repo = require('../src/repoSnapshot'); +const gitRefs = require('../src/gitRefs'); +const coldStart = require('../src/coldStart'); const cli = require('../src/cli'); let passed = 0; @@ -55,6 +57,96 @@ ok('defect add hits both state and ledger', () => { assert.strictEqual(ledger.summary(state.loadLedger(cwd)).openDefects, 1); }); +// --- defect lifecycle (0.3 Seam Gate) --------------------------------------- + +ok('defect resolve requires evidence (proof gate)', () => { + const { state: d } = artifacts.addDefect(cwd, { severity: 'high', summary: 'needs proof' }); + assert.throws(() => cli.run(['node', 'ratchet', 'defect', 'resolve', d.id]), /evidence/); + assert.strictEqual(state.loadState(cwd).defects.find((x) => x.id === d.id).status, 'open', 'stays open without proof'); +}); + +ok('defect resolve with evidence clears the confidence drain', () => { + const { state: d } = artifacts.addDefect(cwd, { severity: 'critical', summary: 'blocker' }); + const before = scoring.scoreConfidence(state.loadState(cwd)).score; + cli.run(['node', 'ratchet', 'defect', 'resolve', d.id, '--evidence', 'ran the repro, now green']); + const after = state.loadState(cwd).defects.find((x) => x.id === d.id); + assert.strictEqual(after.status, 'resolved'); + assert.ok(/green/.test(after.evidence), 'resolution evidence is recorded'); + assert.ok(scoring.scoreConfidence(state.loadState(cwd)).score > before, 'resolving raises confidence'); +}); + +ok('defect waive stops the drain (the case the 0.2 scorer was blind to)', () => { + const { state: d } = artifacts.addDefect(cwd, { severity: 'critical', summary: 'accepted risk' }); + const before = scoring.scoreConfidence(state.loadState(cwd)).score; + cli.run(['node', 'ratchet', 'defect', 'waive', d.id, '--owner', 'danny', '--reason', 'out of scope this release']); + const after = state.loadState(cwd).defects.find((x) => x.id === d.id); + assert.strictEqual(after.status, 'waived'); + assert.strictEqual(after.waivedBy, 'danny'); + assert.ok(scoring.scoreConfidence(state.loadState(cwd)).score > before, 'waiving stops the drain'); +}); + +ok('defect waive requires both owner and reason', () => { + const { state: d } = artifacts.addDefect(cwd, { severity: 'low', summary: 'nit' }); + assert.throws(() => cli.run(['node', 'ratchet', 'defect', 'waive', d.id, '--reason', 'x']), /owner/); + assert.throws(() => cli.run(['node', 'ratchet', 'defect', 'waive', d.id, '--owner', 'danny']), /reason/); +}); + +ok('defect supersede stops the drain and records the replacement', () => { + const { state: d } = artifacts.addDefect(cwd, { severity: 'high', summary: 'old premise' }); + const before = scoring.scoreConfidence(state.loadState(cwd)).score; + cli.run(['node', 'ratchet', 'defect', 'supersede', d.id, '--by', 'art_live_seam_eval']); + const after = state.loadState(cwd).defects.find((x) => x.id === d.id); + assert.strictEqual(after.status, 'superseded'); + assert.strictEqual(after.supersededBy, 'art_live_seam_eval'); + assert.ok(scoring.scoreConfidence(state.loadState(cwd)).score > before); +}); + +ok('defect reopen re-drains a resolved defect', () => { + const { state: d } = artifacts.addDefect(cwd, { severity: 'high', summary: 'flaky' }); + cli.run(['node', 'ratchet', 'defect', 'resolve', d.id, '--evidence', 'passed 100x']); + const mid = scoring.scoreConfidence(state.loadState(cwd)).score; + cli.run(['node', 'ratchet', 'defect', 'reopen', d.id, '--reason', 'regressed on CI']); + assert.strictEqual(state.loadState(cwd).defects.find((x) => x.id === d.id).status, 'reopened'); + assert.ok(scoring.scoreConfidence(state.loadState(cwd)).score < mid, 'reopen re-drains confidence'); +}); + +ok('resolving a defect syncs its ledger mirror', () => { + const { state: d } = artifacts.addDefect(cwd, { severity: 'high', summary: 'mirror me', feature: 'router' }); + assert.ok(d.ledgerId, 'state defect links to its ledger mirror'); + cli.run(['node', 'ratchet', 'defect', 'resolve', d.id, '--evidence', 'fixed + verified live']); + const mirror = state.loadLedger(cwd).defects.find((x) => x.id === d.ledgerId); + assert.strictEqual(mirror.status, 'resolved', 'ledger mirror follows the state transition'); +}); + +ok('defect list + get render without throwing', () => { + const listOut = md.defectList(state.loadState(cwd).defects); + assert.ok(/Defects/.test(listOut)); + const one = state.loadState(cwd).defects[0]; + assert.ok(md.defectOne(one).includes(one.id)); +}); + +// --- artifact retraction (0.3 Seam Gate) ------------------------------------ + +ok('retract flips status, keeps provenance, and stops holes draining confidence', () => { + const a = artifacts.addArtifact(cwd, { title: 'T2.3 re-scope', kind: 'docs', holes: ['premise unverified'] }); + const before = scoring.scoreConfidence(state.loadState(cwd)).score; + cli.run([ + 'node', 'ratchet', 'retract', a.id, + '--reason', 'central premise false: endpoint exists and returns live vectors', + '--superseded-by', 'art_live_seam_eval', + ]); + const after = state.loadState(cwd).artifacts.find((x) => x.id === a.id); + assert.strictEqual(after.status, 'retracted'); + assert.strictEqual(after.retracted.keptForProvenance, true); + assert.strictEqual(after.retracted.supersededBy, 'art_live_seam_eval'); + assert.ok(scoring.scoreConfidence(state.loadState(cwd)).score >= before, 'a retracted holey artifact stops draining'); +}); + +ok('retract requires a reason (no silent retraction)', () => { + const a = artifacts.addArtifact(cwd, { title: 'x', kind: 'docs' }); + assert.throws(() => cli.run(['node', 'ratchet', 'retract', a.id]), /reason/); +}); + ok('friction uses 1-10 scale and ranks', () => { const r = scoring.scoreFriction([ { name: 'a', leverage: 10, certainty: 10, speed: 10, risk: 10 }, @@ -152,5 +244,71 @@ ok('repo snapshot sees allowlisted dot dirs, skips .git / node_modules', () => { assert.ok(!snap.dirs.includes('node_modules'), 'node_modules is skipped'); }); +ok('git status-refs is base-qualified and never emits a bare count', () => { + const refs = gitRefs.statusRefs(process.cwd()); + assert.strictEqual(typeof refs.isRepo, 'boolean'); + if (refs.isRepo) { + assert.ok(Array.isArray(refs.comparisons), 'comparisons is an array'); + for (const c of refs.comparisons) { + assert.ok(c.base, 'every comparison names its base ref'); + assert.strictEqual(typeof c.ahead, 'number'); + assert.strictEqual(typeof c.behind, 'number'); + } + } + const rendered = md.gitStatusRefs(refs); + assert.ok(/Git status/.test(rendered)); + // A non-repo path renders cleanly, not a crash. + assert.ok(/not a git repository/.test(md.gitStatusRefs({ isRepo: false }))); +}); + +ok('cold-start scanner flags retracted steering + unqualified git counts', () => { + const proj = path.join(tmp, 'cold-fixture'); + fs.mkdirSync(path.join(proj, '.ratchet'), { recursive: true }); + state.initProject(proj, { force: true }); + const st = state.loadState(proj); + st.objective = 'ship seam gate'; + st.nextAction = 'finish art-dead re-scope'; + st.artifacts = [ + { id: 'art-dead', title: 'T2.3 re-scope', status: 'retracted', path: 'reports/rescope.md', retracted: { supersededBy: 'art-eval', keptForProvenance: true } }, + ]; + state.saveState(proj, st); + // a goal surface: unqualified git count, no valid-as-of stamp, repeats the retracted claim + fs.writeFileSync(path.join(proj, 'goal.md'), '# Goal\nWe are 43 ahead of main.\nT2.3 re-scope is still the plan.\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 r = coldStart.scan(proj); + assert.strictEqual(r.ok, false, 'contradictions make the scan not-ok'); + const lvl = (frag) => (r.checks.find((c) => c.name.includes(frag)) || {}).level; + assert.strictEqual(lvl('steering artifact is live'), 'fail'); + assert.strictEqual(lvl('next action avoids retracted'), 'fail'); + assert.strictEqual(lvl('base-qualified-git'), 'fail'); + assert.strictEqual(lvl('valid-as-of'), 'warn'); + assert.strictEqual(lvl('no-retracted-claims'), 'fail'); +}); + +ok('cold-start scanner is clean on healthy state and flags unimplemented checks transparently', () => { + const proj = path.join(tmp, 'cold-clean'); + fs.mkdirSync(path.join(proj, '.ratchet'), { recursive: true }); + state.initProject(proj, { force: true }); + const st = state.loadState(proj); + st.objective = 'x'; + st.nextAction = 'do y'; + st.artifacts = [{ id: 'a1', title: 'live spec', status: 'v1', holes: [] }]; + state.saveState(proj, st); + fs.writeFileSync(path.join(proj, 'sheet.md'), '# Sheet\nvalid-as-of 2026-07-03\n82 ahead of origin/main.\n'); + fs.writeFileSync( + path.join(proj, '.ratchet', 'cold-start.json'), + JSON.stringify({ surfaces: [{ path: 'sheet.md', kind: 'decision-sheet', checks: ['valid-as-of', 'base-qualified-git', 'no-closed-work-as-next'] }] }) + ); + const r = coldStart.scan(proj); + assert.strictEqual(r.ok, true, 'healthy state + qualified counts pass'); + assert.strictEqual((r.checks.find((c) => c.name.includes('valid-as-of')) || {}).level, 'ok'); + assert.strictEqual((r.checks.find((c) => c.name.includes('base-qualified-git')) || {}).level, 'ok'); + // a declared-but-unimplemented check must warn, not silently pass + assert.strictEqual((r.checks.find((c) => c.name.includes('no-closed-work-as-next')) || {}).level, 'warn'); +}); + fs.rmSync(tmp, { recursive: true, force: true }); process.stdout.write(`\n${passed} passed\n`); diff --git a/test/evolve.test.js b/test/evolve.test.js index 25e2579..075944e 100644 --- a/test/evolve.test.js +++ b/test/evolve.test.js @@ -127,6 +127,77 @@ ok('proof gate: KEEP is rejected without proof', () => { ); }); +ok('seam gate: production KEEP needs an exact ship-seam match', () => { + const keepCode = (over) => + schema.newEvent({ + target: 'src/router.js', + mode: 'code', + verdict: 'KEEP', + verification: { commands: [{ pass: true }], result: 'pass' }, + ...over, + }); + // The T2.3 case: a proxy eval that looked good but measured the wrong seam. + assert.throws(() => schema.validateKeepGate(keepCode({ seam: { seamMatch: 'weak-proxy' } })), /seam/i); + // No seam declared at all → cannot ship. + assert.throws(() => schema.validateKeepGate(keepCode({})), /seam/i); + // Exact ship-seam match, independent method → allowed. + schema.validateKeepGate(keepCode({ seam: { seamMatch: 'exact', independentFromBuilderMethod: true } })); +}); + +ok('seam gate: verification that repeats the builder method is not independent', () => { + const ev = schema.newEvent({ + target: 'src/router.js', + mode: 'code', + verdict: 'KEEP', + verification: { commands: [{ pass: true }], result: 'pass' }, + seam: { seamMatch: 'exact', independentFromBuilderMethod: false }, + }); + assert.throws(() => schema.validateKeepGate(ev), /independent/i); +}); + +ok('seam gate: a named human waiver overrides a proxy seam', () => { + const ev = schema.newEvent({ + target: 'src/router.js', + mode: 'code', + verdict: 'KEEP', + verification: { commands: [{ pass: true }], result: 'pass' }, + seam: { seamMatch: 'mismatch', waiver: { by: 'danny', reason: 'hotfix; seam eval to follow' } }, + }); + schema.validateKeepGate(ev); // does not throw +}); + +ok('seam gate: docs/prompt KEEP stays exempt (manual evidence is enough)', () => { + schema.validateKeepGate( + schema.newEvent({ + target: 'README.md', + mode: 'docs', + verdict: 'KEEP', + verification: { manualChecks: ['first-use path is unambiguous'], result: 'manual' }, + }) + ); +}); + +ok('seam fields round-trip through newEvent', () => { + const e = schema.newEvent({ + target: 'src/x.js', + mode: 'code', + verdict: 'ASK', + seam: { + evidenceType: 'live-call', + method: 'recursive grep + live endpoint call', + independentFromBuilderMethod: true, + testedSeam: 'rerank_candidates', + shipSeam: 'rerank_candidates', + seamMatch: 'exact', + proxyWarning: null, + }, + }); + assert.strictEqual(e.seam.seamMatch, 'exact'); + assert.strictEqual(e.seam.testedSeam, 'rerank_candidates'); + assert.strictEqual(e.seam.evidenceType, 'live-call'); + assert.strictEqual(e.mode, 'code'); +}); + ok('proof gate: REVERT and ASK bypass the gate', () => { schema.validateKeepGate(schema.newEvent({ target: 't', verdict: 'REVERT', verification: { result: 'fail' } })); schema.validateKeepGate(schema.newEvent({ target: 't', verdict: 'ASK' })); @@ -138,6 +209,21 @@ ok('proof gate: appendEvent refuses to persist an unproven KEEP', () => { assert.ok(!s.targets.includes('b.md'), 'the rejected KEEP was never written'); }); +ok('REVERTED_AND_LEARNED is a successful outcome that bypasses the KEEP gate', () => { + // Reverting a mutation after verification (even a failing one) is valid and gate-exempt. + schema.validateKeepGate( + schema.newEvent({ target: 'src/router.js', mode: 'code', verdict: 'REVERTED_AND_LEARNED', verification: { result: 'fail' } }) + ); + journal.appendEvent(process.cwd(), { + target: 'src/router.js', + verdict: 'REVERTED_AND_LEARNED', + nextEdge: 'ship the live-seam eval harness, not the flag', + }); + const s = journal.status(process.cwd()); + assert.ok(s.revertedAndLearned >= 1, 'counted as a reverted-and-learned outcome'); + assert.strictEqual(s.last.verdict, 'REVERTED_AND_LEARNED'); +}); + ok('pressure suggests a vector and flags the rewrite trap', () => { const p = pressure.suggest('docs'); assert.ok(p.primaryCandidates.includes('clarity'));