@popovych.co/witness 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -14,6 +14,7 @@ const VERBS = {
14
14
  check: () => import('./verbs/check.js'),
15
15
  index: () => import('./verbs/index-verb.js'),
16
16
  satisfy: () => import('./verbs/satisfy.js'),
17
+ dismiss: () => import('./verbs/dismiss.js'),
17
18
  log: () => import('./verbs/log.js'),
18
19
  'test-evidence': () => import('./verbs/evidence.js'),
19
20
  'verify-red': () => import('./verbs/evidence.js').then((m) => ({ run: m.runVerifyRed })),
@@ -60,6 +61,7 @@ const VERB_USAGE = {
60
61
  recover: 'witness recover [--complete | --rollback]',
61
62
  rename: 'witness rename <old-id> <new-id>',
62
63
  satisfy: 'witness satisfy <doc-id> --need <text | index>',
64
+ dismiss: 'witness dismiss <artifact> --deferral <id|index> --cause <superseded|lens-retired|judged-wrong> --note "<why>"',
63
65
  ship: 'witness ship <plan-id>',
64
66
  start: 'witness start <plan-id>',
65
67
  status: 'witness status — flows · blocked docs · reconcile rows · pending gates (bare `witness` is the same screen)',
@@ -0,0 +1,43 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ export const DISMISS_CAUSES = ['superseded', 'lens-retired', 'judged-wrong'];
3
+ // D122. A minted id, never a derived ordinal: the debt is re-booked onto the parent spec
4
+ // when its flow completes, and a per-stream ordinal renumbers when it changes homes — so
5
+ // "how long has this been open" becomes unanswerable across the move, and age is the only
6
+ // thing separating a fresh deferral from a chronic one. Same shape as `newRunId`.
7
+ export const newDeferralId = () => `d-${randomBytes(4).toString('hex')}`;
8
+ // State is an append-only FOLD, never a mutation: `deferral` opens, `deferral-moved`
9
+ // closes it on THIS stream (it continues on another), `deferral-retyped` changes only its
10
+ // kind, and discharge/dismiss close it for good.
11
+ export function openDeferrals(entries) {
12
+ const open = new Map();
13
+ for (const e of entries) {
14
+ const id = typeof e.id === 'string' ? e.id : undefined;
15
+ if (id === undefined)
16
+ continue;
17
+ if (e.t === 'deferral')
18
+ open.set(id, e);
19
+ else if (e.t === 'deferral-moved' || e.t === 'deferral-discharged' || e.t === 'deferral-dismissed')
20
+ open.delete(id);
21
+ else if (e.t === 'deferral-retyped') {
22
+ const cur = open.get(id);
23
+ if (cur)
24
+ open.set(id, { ...cur, kind: e.kind });
25
+ }
26
+ }
27
+ return [...open.values()];
28
+ }
29
+ // Injected into every lens, exactly as `pinsBlock` is, and joined to `prompts_sha` so a new
30
+ // obligation invalidates the verdict cache and the next round cannot judge without it.
31
+ // Deliberately the INVERSE of a pin: a pin tells reviewers not to re-litigate, this tells
32
+ // them to report the thing if it is still there. Pins suppress findings; obligations
33
+ // solicit them — which is what makes the discharge automatic and evidence-shaped.
34
+ export function deferralsBlock(open) {
35
+ if (open.length === 0)
36
+ return '';
37
+ return '## Open deferrals (human overrides — report these if they still hold)\n\n' +
38
+ 'A human approved this artifact over the findings below rather than fixing them. They are ' +
39
+ 'NOT settled policy and they are NOT pins. If the defect is still present, report it as a ' +
40
+ 'finding and anchor it exactly as listed. If it is gone, say so in your coverage. Silence ' +
41
+ 'is read as "still present".\n\n' +
42
+ open.map((d, i) => `${i + 1}. ${d.anchor} — ${d.gate} round ${d.round} (${d.id})`).join('\n') + '\n\n';
43
+ }
package/dist/gate.js CHANGED
@@ -7,10 +7,11 @@ import { acquireLock } from './lock.js';
7
7
  import { appendEntry, entryLine, journalRel, policyPins, readStream } from './journal.js';
8
8
  import { primaryRoot, stateCommit } from './gitio.js';
9
9
  import { loadCanon, findById } from './scan.js';
10
+ import { deferralsBlock, openDeferrals } from './deferral.js';
10
11
  import { newRunId } from './drift.js';
11
12
  import { resolveJudge } from './harness.js';
12
13
  import { ok, refuse, renderRefusal, v } from './refusal.js';
13
- import { kv, rows } from './toon.js';
14
+ import { cmd, kv, rows } from './toon.js';
14
15
  import { loadMatrix, resolveModel, SESSION_DEFAULT } from './model.js';
15
16
  import { docKeysFor, docsBlock, invokeReviewer, loadLensDocs, parseVerdictText, pinsBlock, promptsSha, resolvePrompt } from './reviewer.js';
16
17
  import { anchorMenu, parseVerdict, verdictViolations } from './verdict.js';
@@ -18,6 +19,7 @@ import { ROUND_BOUND, appendKind, boundReached, fellBack, lastGateRun, liveExits
18
19
  export { liveExits } from './rounds.js';
19
20
  import { gateSettled } from './verbs/next.js';
20
21
  import { prepareStamp, writeStamp } from './stamp.js';
22
+ import { recommend, renderDecision } from './recommend.js';
21
23
  const GATES = new Map();
22
24
  export function registerGate(spec) { GATES.set(spec.gate, spec); }
23
25
  export function gateSpec(name) { return GATES.get(name); }
@@ -87,9 +89,22 @@ export function renderGateRun(ctx, entry, mode, opts = {}) {
87
89
  // Without `entries` this stays a pure formatter over the entry it was handed, and the
88
90
  // off-bound triple is the right answer; with them it tells the truth at the bound too.
89
91
  if (opts.help !== false)
90
- ctx.out(`help: ${liveExits(entry.gate, entry.artifact, entries, false)}`);
92
+ renderChoices(ctx, entry.gate, entry.artifact, entries, opts.upstream);
91
93
  }
92
94
  }
95
+ // D121: the exits line stated which commands would not refuse and never which to take, so
96
+ // --approve read as the default at stops with a live blocking finding. The block ranks them.
97
+ // One renderer for all four decision surfaces in this file — a per-site copy is how D119's
98
+ // nine hand-copied exits sets happened, and three of these four sites were among them.
99
+ // `undefined` from `recommend` means no decision exists at this state (stale below the
100
+ // bound, where the only live act is a re-gate); the exits line still answers that.
101
+ function renderChoices(ctx, gate, target, entries, upstream, stale = false) {
102
+ const d = recommend({ gate, target, entries, upstream, stale });
103
+ if (d)
104
+ renderDecision(d).forEach((l) => ctx.out(l));
105
+ else
106
+ ctx.out(cmd('help', liveExits(gate, target, entries, stale, upstream)));
107
+ }
93
108
  // Row 79: the human approving a plan sees the run's shape before it starts.
94
109
  // Display only — no lens, no threshold; loads config/canon itself so the
95
110
  // entry-renderers stay pure formatters.
@@ -133,6 +148,9 @@ export async function runGate(ctx, gateName, target, flags) {
133
148
  return EXIT.REFUSED;
134
149
  }
135
150
  const input = inputR.value;
151
+ // Resolved once for every render this run performs — the entry renderers stay pure
152
+ // formatters (they take the id, they never look it up).
153
+ const upstreamId = spec.upstreamOf?.(root, canon, target);
136
154
  const batteryR = batteryFor(cfgR.value, spec.gate, input.class);
137
155
  if (!batteryR.ok) {
138
156
  renderRefusal(batteryR.violations).forEach((l) => ctx.err(l));
@@ -144,6 +162,11 @@ export async function runGate(ctx, gateName, target, flags) {
144
162
  const entries = readStream(root, target);
145
163
  const pins = policyPins(entries);
146
164
  const pinsText = pinsBlock(pins);
165
+ // D122. Open obligations join the prompt exactly as pins do, and join `prompts_sha` for
166
+ // the same reason: an obligation the battery did not see is an obligation nothing can
167
+ // discharge. Minting one therefore guarantees the next round re-invokes the battery,
168
+ // which is the intended cost of an override rather than an accident of the cache.
169
+ const deferralsText = deferralsBlock(openDeferrals(entries));
147
170
  const lenses = [];
148
171
  for (const name of active) {
149
172
  const lensR = resolvePrompt(name);
@@ -201,7 +224,8 @@ export async function runGate(ctx, gateName, target, flags) {
201
224
  const pin = chain[0];
202
225
  const key = {
203
226
  reviewed_sha: input.reviewedSha, gate: spec.gate,
204
- prompts_sha: promptsSha(lenses, pinsText === '' ? undefined : pinsText), pin, witness: version(),
227
+ prompts_sha: promptsSha(lenses, [pinsText, deferralsText].filter((t) => t !== '').join('') || undefined),
228
+ pin, witness: version(),
205
229
  harness: harness.name,
206
230
  };
207
231
  // D99: `gateSettled` reads only the last run, so any new run un-settles the gate.
@@ -241,21 +265,22 @@ export async function runGate(ctx, gateName, target, flags) {
241
265
  ctx.err(`warning: reviewer setup changed — this run discards the settled approve on ${spec.gate} ${target}`);
242
266
  }
243
267
  if (kind.kind === 'resume') {
244
- renderGateRun(ctx, kind.entry, 'resume', { entries });
268
+ renderGateRun(ctx, kind.entry, 'resume', { entries, upstream: upstreamId });
245
269
  printDispatchArithmetic(ctx, root, spec.gate, target);
246
270
  return kind.entry.outcome === 'passed' ? EXIT.OK : EXIT.FINDINGS;
247
271
  }
248
272
  if (kind.kind === 'changed-nothing') {
249
273
  ctx.out(kv('gate', spec.gate));
250
274
  ctx.out(kv('outcome', 'revise changed nothing — reviewed content is identical to the last round'));
251
- ctx.out('help: edit the artifact (or code) before re-running, or decide --approve/--stop');
275
+ // The owed work is an EDIT, so the exits are the decisions that remain legal without
276
+ // one — the same set every other screen shows, rather than prose naming two of four.
277
+ renderChoices(ctx, spec.gate, target, entries, upstreamId);
252
278
  return EXIT.FINDINGS;
253
279
  }
254
280
  if (boundReached(entries, spec.gate)) {
255
281
  ctx.out(kv('gate', spec.gate));
256
282
  ctx.out(kv('outcome', `round bound reached (${roundsSinceApprove(entries, spec.gate)} rounds since last approve)`));
257
- ctx.out(`help: witness decide ${spec.gate} ${target} --approve --override | --revise --upstream <id> | --stop`);
258
- ctx.out(`help: or discard the plan: witness abandon ${target}`);
283
+ renderChoices(ctx, spec.gate, target, entries, upstreamId);
259
284
  return EXIT.BLOCKED;
260
285
  }
261
286
  if (!flags.fresh) {
@@ -311,7 +336,7 @@ export async function runGate(ctx, gateName, target, flags) {
311
336
  const reviewed = ov?.reviewed ?? input.reviewed;
312
337
  const body = ov?.promptBody ?? input.promptBody;
313
338
  const menu = anchorMenu(reviewed);
314
- let prompt = `${lens.contents}\n\n${docsBlock(lens.docs ?? [])}${pinsText}${menu ? `${menu}\n\n` : ''}## Reviewed content\n\n${body}\n`;
339
+ let prompt = `${lens.contents}\n\n${docsBlock(lens.docs ?? [])}${pinsText}${deferralsText}${menu ? `${menu}\n\n` : ''}## Reviewed content\n\n${body}\n`;
315
340
  for (let attempt = 0;; attempt++) {
316
341
  let answered;
317
342
  for (;;) {
@@ -389,8 +414,7 @@ export async function runGate(ctx, gateName, target, flags) {
389
414
  if (boundReached(entriesNow, spec.gate)) {
390
415
  ctx.out(kv('gate', spec.gate));
391
416
  ctx.out(kv('outcome', `round bound reached (${roundsSinceApprove(entriesNow, spec.gate)} rounds since last approve)`));
392
- ctx.out(`help: witness decide ${spec.gate} ${target} --approve --override | --revise --upstream <id> | --stop`);
393
- ctx.out(`help: or discard the plan: witness abandon ${target}`);
417
+ renderChoices(ctx, spec.gate, target, entriesNow, upstreamId);
394
418
  return EXIT.BLOCKED;
395
419
  }
396
420
  const entry = {
@@ -418,6 +442,16 @@ export async function runGate(ctx, gateName, target, flags) {
418
442
  ? [prepareStamp(doc, s.to, 'gate-approve', { run_id: entry.run_id })] : [];
419
443
  })
420
444
  : [];
445
+ // D122. The honest closure: a battery LOOKED and no longer found it. Only a run that
446
+ // actually judged this anchor's artifact can discharge — a malformed round judged nothing
447
+ // and must never close a debt, which is the same reason it does not spend the budget
448
+ // (row 67). Silence in a real verdict is the evidence; that is exactly what the injected
449
+ // block tells the reviewer, so the reviewer knows silence will be read that way.
450
+ const reported = new Set((entry.verdicts ?? []).flatMap((rv) => rv.findings
451
+ .map((f) => (typeof f.anchor === 'string' ? f.anchor : `omission:${f.anchor.scope}`))));
452
+ const discharged = entry.outcome === 'malformed' ? []
453
+ : openDeferrals(entriesNow).filter((d) => !reported.has(d.anchor))
454
+ .map((d) => ({ v: 1, t: 'deferral-discharged', id: d.id, artifact: target, by_run: entry.run_id }));
421
455
  const files = [
422
456
  journalRel(target), ...stamps.flatMap((s) => [s.rel, journalRel(s.stream)]),
423
457
  ...(input.repin ? [input.repin.rel] : []),
@@ -428,10 +462,13 @@ export async function runGate(ctx, gateName, target, flags) {
428
462
  journalMulti: [
429
463
  { stream: target, line: entryLine(entry) },
430
464
  ...stamps.map((s) => ({ stream: s.stream, line: s.line })),
465
+ ...discharged.map((d) => ({ stream: target, line: entryLine(d) })),
431
466
  ],
432
467
  };
433
468
  const txn = withTxn(root, marker, () => {
434
469
  appendEntry(root, target, entry);
470
+ for (const d of discharged)
471
+ appendEntry(root, target, d);
435
472
  for (const s of stamps)
436
473
  writeStamp(root, s);
437
474
  if (input.repin) {
@@ -444,8 +481,10 @@ export async function runGate(ctx, gateName, target, flags) {
444
481
  renderRefusal(txn.violations).forEach((l) => ctx.err(l));
445
482
  return EXIT.REFUSED;
446
483
  }
447
- renderGateRun(ctx, entry, 'ran', { entries: [...entriesNow, entry] });
484
+ renderGateRun(ctx, entry, 'ran', { entries: [...entriesNow, entry], upstream: upstreamId });
448
485
  printDispatchArithmetic(ctx, root, spec.gate, target);
486
+ for (const d of discharged)
487
+ ctx.out(kv('discharged', `${d.id} — no longer reported by this round`));
449
488
  if (input.repin)
450
489
  ctx.out(kv('re-pinned', `derives-from → ${input.repin.sha.slice(0, 7)} (witnessed by the drift lane)`));
451
490
  return outcome === 'passed' ? EXIT.OK : EXIT.FINDINGS;
@@ -84,6 +84,11 @@ registerGate({
84
84
  currentSha(root, canon, _cfg, effort) {
85
85
  return effortReviewedSha(root, canon, effort).sha;
86
86
  },
87
+ // Upstream from decompose is the scope itself — `decide` routes it to `witness recap
88
+ // --amend` rather than a second stream, so the id it names is the effort.
89
+ upstreamOf(root, _canon, effort) {
90
+ return streamExists(root, effort) ? effort : undefined;
91
+ },
87
92
  approveStamps(root, canon, effort) {
88
93
  return stampsFor(effortSpecs(root, canon, effort));
89
94
  },
@@ -67,6 +67,12 @@ registerGate({
67
67
  const abs = join(root, designRel(cfg.paths, specId));
68
68
  return reviewedShaOf(existsSync(abs) ? readFileSync(abs, 'utf8') : undefined, spec);
69
69
  },
70
+ // Upstream from the design gate is the spec's SLICING, which reopens the owning
71
+ // effort's decompose (Decision 52) — so the id is the effort, not the spec.
72
+ upstreamOf(root, canon, specId) {
73
+ const spec = findById(canon, specId);
74
+ return spec && spec.meta.type === 'spec' ? effortOf(root, specId) : undefined;
75
+ },
70
76
  approveMeta(root, canon, cfg, specId) {
71
77
  const spec = findById(canon, specId);
72
78
  if (!spec || spec.meta.type !== 'spec')
@@ -165,4 +165,10 @@ registerGate({
165
165
  // un-settle a gate or invert `decide --show`'s staleness line.
166
166
  return baseR.ok ? implementReviewedSha(wt, baseR.value, plan) : undefined;
167
167
  },
168
+ // The implement gate judges a plan's CODE, so upstream is the plan itself — reopening
169
+ // the plan stage, which is where an unimplementable step is answered.
170
+ upstreamOf(_root, canon, planId) {
171
+ const plan = findById(canon, planId);
172
+ return plan && plan.meta.type === 'plan' ? planId : undefined;
173
+ },
168
174
  });
@@ -79,6 +79,10 @@ registerGate({
79
79
  const parent = plan ? findById(canon, String(plan.meta.parent)) : undefined;
80
80
  return plan && parent ? planPairSha(plan, parent) : undefined;
81
81
  },
82
+ upstreamOf(_root, canon, planId) {
83
+ const plan = findById(canon, planId);
84
+ return plan && plan.meta.type === 'plan' ? String(plan.meta.parent) : undefined;
85
+ },
82
86
  approveStamps(_root, canon, planId) {
83
87
  const doc = findById(canon, planId);
84
88
  return doc && String(doc.meta.status) === 'draft' ? [{ artifact: planId, to: 'approved' }] : [];
@@ -105,4 +105,9 @@ registerGate({
105
105
  // undefined is "cannot compute", never "moved" — same doctrine as the implement gate.
106
106
  return baseR.ok ? diffReviewedSha(wt, baseR.value) : undefined;
107
107
  },
108
+ // Same as the implement gate: what ships is a plan's code, so upstream is the plan.
109
+ upstreamOf(_root, canon, planId) {
110
+ const plan = findById(canon, planId);
111
+ return plan && plan.meta.type === 'plan' ? planId : undefined;
112
+ },
108
113
  });
@@ -0,0 +1,243 @@
1
+ import { anchorRecurrence, boundReached, ladderSpent, lastGateRun, notePrefill, repairGranted, roundBudget, roundsSinceApprove, } from './rounds.js';
2
+ // Commands are emitted raw (D120): `esc` would quote the `--note "…"` argument and the
3
+ // line would paste into a shell as an empty note.
4
+ export function renderDecision(d) {
5
+ const n = d.options.length;
6
+ const out = [`${d.key}: ${n} option${n === 1 ? '' : 's'} · 1 is recommended`];
7
+ d.options.forEach((o, i) => {
8
+ const tags = [String(i + 1), ...(i === 0 ? ['recommended'] : []), o.depth,
9
+ ...(o.runnable ? [] : ['not runnable'])];
10
+ out.push(tags.join(' · '));
11
+ out.push(` ${o.command}`);
12
+ if (o.why)
13
+ out.push(` why: ${o.why}`);
14
+ if (o.judgeFirst)
15
+ out.push(` judge-first: ${o.judgeFirst}`);
16
+ if (o.when)
17
+ out.push(` when: ${o.when}`);
18
+ if (o.tradeoff)
19
+ out.push(` tradeoff: ${o.tradeoff}`);
20
+ if (o.note)
21
+ out.push(` note: ${o.note}`);
22
+ });
23
+ // No run: line when the recommendation cannot be pasted — a run: that needs editing is
24
+ // the promise this block exists to keep, broken.
25
+ if (d.options[0]?.runnable)
26
+ out.push(`run: ${d.options[0].command}`);
27
+ return out;
28
+ }
29
+ const RESERVED = new Set(['ship', 'design']);
30
+ const blockingAnchors = (r) => (r.verdicts ?? []).flatMap((rv) => rv.findings.filter((f) => f.blocking)
31
+ .map((f) => (typeof f.anchor === 'string' ? f.anchor : `omission:${f.anchor.scope}`)));
32
+ const opt = (command, depth, rest = {}) => ({ command, depth, runnable: !/<[^>]+>/.test(command), ...rest });
33
+ // The rule table is an ORDERED FIRST-MATCH list (D121). The id of the rule that matched is
34
+ // what the journal records, so a wrong recommendation is attributable to one line here
35
+ // rather than to a weighting — and each rule is testable from one journal state.
36
+ // Order: malformed · stale-below-bound · ladder-spent · bound+recurrence · bound ·
37
+ // recurrence · pin-contradiction · blocking-parent · blocking-here ·
38
+ // non-blocking-only · reserved-stop-clean · manual-stop
39
+ export function recommend(ctx) {
40
+ const { gate, target, entries, upstream, stale } = ctx;
41
+ const last = lastGateRun(entries, gate);
42
+ if (!last)
43
+ return undefined;
44
+ const d = `witness decide ${gate} ${target}`;
45
+ const up = upstream === undefined ? `${d} --revise --upstream <effort>` : `${d} --revise --upstream ${upstream}`;
46
+ const note = `${d} --revise --note "${notePrefill(entries, gate)}"`;
47
+ const atBound = boundReached(entries, gate);
48
+ const budget = roundBudget(entries, gate);
49
+ const spent = roundsSinceApprove(entries, gate);
50
+ const rounds = `round ${spent} of ${budget}`;
51
+ // The block REPLACED the exits line on every surface, so anything it omits becomes
52
+ // undiscoverable. `liveExits` below the bound offers approve, revise-note, revise-upstream
53
+ // and stop; a rule that lists a subset makes the two disagree, which is the D119 defect
54
+ // class in a new dress. Every rule therefore carries the whole live set and only the ORDER
55
+ // and the prose differ. Omitted rather than flagged when no upstream resolves — `decide`
56
+ // refuses that with `unknown-owner`, so it is an illegal act, not an unresolved one (D129).
57
+ const upAlt = (when, tradeoff) => upstream === undefined ? [] : [opt(up, 'root', { when, tradeoff })];
58
+ const stopOpt = opt(`${d} --stop`, 'terminal', {
59
+ when: 'this work should not continue as scoped',
60
+ tradeoff: 'parks the flow — next stops offering it and reopening is an explicit act',
61
+ });
62
+ // Offered BELOW the bound as well as at it, and this is the accounted spelling on purpose.
63
+ // Plain `--approve` over a live blocking finding is legal below the bound (`decide` only
64
+ // requires `--override` at the bound) and `liveExits` lists it — but it is the same act
65
+ // with the accounting switched off: it stamps the artifact over the finding and records
66
+ // nothing, which is the unaccounted band-aid D122 exists to prevent. Advertising it beside
67
+ // an accounted one would make the block the place a human learns to skip the ledger, so
68
+ // the block names one approve and it is the one that mints the obligation.
69
+ const overrideOpt = opt(`${d} --approve --override`, 'deferral', {
70
+ when: 'you have read the finding and judge it wrong',
71
+ tradeoff: 'stamps the artifact over a live blocking finding; mints an obligation that stays open in status until a later battery no longer reports it (the discharge)',
72
+ });
73
+ // The bound's only discarding act, and it must appear at EVERY bound branch. liveExits
74
+ // carries it unconditionally for a reason it states: nothing else offers it, and under D124
75
+ // `--stop` parks rather than discards, so a bound screen without abandon offers no way to
76
+ // end the work. The bound-recurrence branch omitted it and two existing tests caught that.
77
+ const abandonOpt = opt(`witness abandon ${target}`, 'terminal', {
78
+ when: 'this work should be discarded, not parked',
79
+ tradeoff: 'irreversible — unlike --stop, nothing reopens it',
80
+ });
81
+ const repairOpt = opt(`${d} --revise --repair`, 'deferral', {
82
+ when: 'the edit you just made is the fix and you want it verified rather than assumed',
83
+ tradeoff: 'buys exactly one round; the discharge is that round passing, and the grant does not refresh until an approve, a revise-upstream or a passed run',
84
+ });
85
+ // 1 — malformed. D126 removed it from pendingDecision, so reaching here means a caller
86
+ // asked anyway; answer with the acts that actually help rather than four dispositions.
87
+ if (last.outcome === 'malformed') {
88
+ return {
89
+ key: 'next', rule: 'malformed-rerun',
90
+ options: [
91
+ opt(`witness gate ${gate} ${target}`, 'root', {
92
+ why: `the battery emitted ${last.malformed?.length ?? 0} schema violation(s) and no verdict — this round judged nothing, and malformed rounds do not spend the budget, so re-running is free`,
93
+ note: 'a second malformed round on the same pin and prompts trips malformed-streak, which names the config remedy',
94
+ }),
95
+ opt(`witness calibrate ${last.model} --only ${gate}`, 'root', {
96
+ when: 'the battery has malformed more than once — the lens or the model is at fault, not the artifact',
97
+ tradeoff: 'spends a calibration run; nothing about this artifact changes',
98
+ }),
99
+ ],
100
+ };
101
+ }
102
+ // 2 — stale below the bound: no decision exists, the caller renders the re-gate act.
103
+ if (stale && !atBound)
104
+ return undefined;
105
+ const anchors = blockingAnchors(last);
106
+ const primary = anchors[0];
107
+ const recurrence = primary === undefined ? 0 : anchorRecurrence(entries, gate, primary);
108
+ const spentLadder = primary !== undefined && ladderSpent(entries, gate, primary);
109
+ // 3 — the ladder is spent for this anchor.
110
+ if (primary !== undefined && spentLadder && recurrence >= 1) {
111
+ return {
112
+ key: 'decide', rule: 'ladder-spent', anchor: primary,
113
+ options: [
114
+ { ...stopOpt, why: `${primary} has recurred across budget windows and the upstream reset already happened for it — the stage above was re-authored and the finding survived, so the depth ladder is spent`, when: undefined, tradeoff: undefined },
115
+ { ...overrideOpt, when: 'the recurring finding comes from one lens while the content genuinely changed each round — that pattern is a lens problem, files a lens suspicion rather than a debt against the artifact' },
116
+ ],
117
+ };
118
+ }
119
+ // 4 — at the bound with a recurring anchor: escalate.
120
+ if (atBound && primary !== undefined && recurrence >= 2) {
121
+ return {
122
+ key: 'decide', rule: 'bound-recurrence', anchor: primary,
123
+ options: [
124
+ opt(up, 'root', { why: `${primary} survived ${recurrence} rounds across distinct reviewed shas — patching here has failed every time, and upstream is unspent for this anchor` }),
125
+ overrideOpt,
126
+ ...(repairGranted(entries, gate) ? [] : [repairOpt]),
127
+ stopOpt,
128
+ abandonOpt,
129
+ ],
130
+ };
131
+ }
132
+ // 5 — at the bound otherwise.
133
+ if (atBound) {
134
+ return {
135
+ key: 'decide', rule: stale ? 'bound-stale' : 'bound', anchor: primary,
136
+ options: [
137
+ ...(stale ? [] : [opt(`${d} --approve --override`, 'deferral', {
138
+ why: `the round budget is spent (${spent} of ${budget}) and the gate will not run again; nothing below the bound remains`,
139
+ tradeoff: overrideOpt.tradeoff,
140
+ })]),
141
+ ...(stale
142
+ ? [opt(up, 'root', { why: 'verdict and content disagree, so --approve is not offered — no battery read the current bytes — and the gate will not re-run' })]
143
+ : [opt(up, 'root', { when: 'the parent artifact is what is wrong', tradeoff: 'reopens the parent stage and resets this budget' })]),
144
+ ...(repairGranted(entries, gate) ? [] : [repairOpt]),
145
+ stopOpt,
146
+ abandonOpt,
147
+ ],
148
+ };
149
+ }
150
+ // 6 — recurrence below the bound: escalate, patch-again becomes the alternative.
151
+ if (primary !== undefined && recurrence >= 2) {
152
+ return {
153
+ key: 'decide', rule: `anchor-recurrence-${recurrence}`, anchor: primary,
154
+ options: [
155
+ opt(up, 'root', { why: `${primary} was found in ${recurrence} rounds across distinct reviewed shas — one honest fix already failed at this seam, so the likelier fault is above it` }),
156
+ opt(note, 'root', { when: 'the previous fix was the wrong fix and you now know the right one', tradeoff: `spends ${rounds}; recurring again leaves only the endgame set` }),
157
+ overrideOpt,
158
+ stopOpt,
159
+ ],
160
+ };
161
+ }
162
+ // 7 — a pin contradiction is a standing stop with its own handling.
163
+ const pinned = (last.verdicts ?? []).flatMap((rv) => rv.findings).find((f) => f.contradicts_pin !== undefined);
164
+ if (pinned !== undefined) {
165
+ return {
166
+ key: 'decide', rule: 'pin-contradiction',
167
+ options: [
168
+ opt(note, 'root', { why: `a finding contradicts policy pin #${pinned.contradicts_pin} — the gate escalated the conflict rather than burning a round on it, and only you can settle which side holds` }),
169
+ opt(`${d} --approve`, 'root', { when: 'the pin still holds and the finding is the thing that is wrong', tradeoff: 'the lens will raise it again on the next round unless the pin is restated' }),
170
+ ...upAlt('the pin itself is what should change, and it was set at the stage above', 'reopens the parent stage and resets this budget'),
171
+ stopOpt,
172
+ ],
173
+ };
174
+ }
175
+ // 8 / 9 — blocking findings, by where they anchor.
176
+ if (anchors.length > 0) {
177
+ const parented = upstream !== undefined && anchors.every((a) => a.startsWith(`${upstream} `) || a.startsWith(`${upstream}>`));
178
+ return parented
179
+ ? {
180
+ key: 'decide', rule: 'blocking-parent', anchor: primary,
181
+ options: [
182
+ opt(up, 'root', { why: `${anchors.length} of ${anchors.length} blocking findings anchor to ${upstream}, not to this artifact — it is faithful to a parent that is wrong` }),
183
+ opt(note, 'root', { when: 'this artifact can route around the parent gap without the parent changing', tradeoff: 'leaves the parent wrong for everything else that derives from it' }),
184
+ overrideOpt,
185
+ stopOpt,
186
+ ],
187
+ }
188
+ : {
189
+ key: 'decide', rule: 'blocking-here', anchor: primary,
190
+ options: [
191
+ opt(note, 'root', { why: `${anchors.length} blocking finding${anchors.length === 1 ? '' : 's'} anchored inside this artifact (${anchors.slice(0, 2).join(', ')}); ${rounds}` }),
192
+ ...upAlt('the finding is only true because the parent asks for something unbuildable here', 'reopens the parent stage and resets this budget; a wrong upstream spends a whole stage cycle'),
193
+ overrideOpt,
194
+ stopOpt,
195
+ ],
196
+ };
197
+ }
198
+ const checksFailed = last.checks.filter((c) => !c.ok);
199
+ const nonBlocking = (last.verdicts ?? []).flatMap((rv) => rv.findings).length;
200
+ // 10 — non-blocking findings only.
201
+ if (checksFailed.length === 0 && nonBlocking > 0) {
202
+ return {
203
+ key: 'decide', rule: 'non-blocking-only',
204
+ options: [
205
+ opt(`${d} --approve`, 'root', { why: `${nonBlocking} finding${nonBlocking === 1 ? '' : 's'}, none blocking; all ${last.checks.length} checks green` }),
206
+ opt(note, 'root', { when: 'a non-blocking finding is one you want fixed before it becomes load-bearing', tradeoff: `spends ${rounds} on findings the battery already judged non-blocking` }),
207
+ ...upAlt('the finding points at something the stage above got wrong', 'reopens the parent stage and resets this budget'),
208
+ stopOpt,
209
+ ],
210
+ };
211
+ }
212
+ // 11 — a reserved stop with clean evidence.
213
+ if (checksFailed.length === 0 && last.standing !== undefined) {
214
+ return {
215
+ key: 'decide', rule: 'reserved-stop-clean',
216
+ options: [
217
+ opt(`${d} --approve`, 'root', {
218
+ why: `${last.checks.length} of ${last.checks.length} checks green, 0 findings — approve is what the evidence supports`,
219
+ judgeFirst: RESERVED.has(gate)
220
+ ? (gate === 'ship'
221
+ ? 'whether this change should exist. The lenses judged the code against the plan; nothing judged the plan against the product, and no lens can'
222
+ : 'the look itself — the critic judged canon-compliance and coverage, not whether this is the right design')
223
+ : 'whether this cut is how you would ship it. Coverage is checked; the shape of the cut is not',
224
+ }),
225
+ opt(note, 'root', { when: 'the evidence is right and the thing itself is wrong', tradeoff: `costs ${rounds}; the gate re-runs on your edit` }),
226
+ ...upAlt('what is wrong is what this stage was ASKED to build, not how it was built', 'reopens the parent stage and resets this budget; the work here waits on that cycle'),
227
+ stopOpt,
228
+ ],
229
+ };
230
+ }
231
+ // 12 — stopped, clean, no standing stop: the --manual flag is the only reason.
232
+ return {
233
+ key: 'decide', rule: 'manual-stop',
234
+ options: [
235
+ opt(`${d} --approve`, 'root', {
236
+ why: `${last.checks.length - checksFailed.length} of ${last.checks.length} checks green, 0 blocking findings — nothing in the evidence stopped this round; the stop is the --manual flag armed for this run`,
237
+ }),
238
+ opt(note, 'root', { when: 'you armed --manual because you expect the battery to miss something and you can name it', tradeoff: `spends ${rounds} on a round the evidence passed` }),
239
+ ...upAlt('what you armed --manual to catch is a fault in the stage above', 'reopens the parent stage and resets this budget'),
240
+ stopOpt,
241
+ ],
242
+ };
243
+ }