@popovych.co/witness 0.10.0 → 0.11.1

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/README.md CHANGED
@@ -216,8 +216,11 @@ pnpm install
216
216
  pnpm test # vitest, in-process CLI against throwaway git repos
217
217
  pnpm run typecheck
218
218
  pnpm run build
219
+ pnpm run release patch # bump + stamp pins + commit + tag, in one act — see docs/RELEASING.md
219
220
  ```
220
221
 
222
+ Releasing is `node scripts/release.mjs <major|minor|patch|x.y.z>` followed by two pushes; the tag push is what publishes. The order is fixed because CI checks the tagged tree, not the intent — [docs/RELEASING.md](docs/RELEASING.md) explains why and what to do if a tag was pushed early.
223
+
221
224
  ## Run economics (operator notes)
222
225
 
223
226
  Non-load-bearing knobs for the machine that hosts long implement runs — the design
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,23 @@ 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 anchor resolves at this state — stale with no
100
+ // decision pending (reopened, or already revised), where every decide verb refuses with
101
+ // nothing-pending and the exits line's single re-gate act is the whole live set (D131).
102
+ function renderChoices(ctx, gate, target, entries, upstream, stale = false) {
103
+ const d = recommend({ gate, target, entries, upstream, stale });
104
+ if (d)
105
+ renderDecision(d).forEach((l) => ctx.out(l));
106
+ else
107
+ ctx.out(cmd('help', liveExits(gate, target, entries, stale, upstream)));
108
+ }
93
109
  // Row 79: the human approving a plan sees the run's shape before it starts.
94
110
  // Display only — no lens, no threshold; loads config/canon itself so the
95
111
  // entry-renderers stay pure formatters.
@@ -133,6 +149,9 @@ export async function runGate(ctx, gateName, target, flags) {
133
149
  return EXIT.REFUSED;
134
150
  }
135
151
  const input = inputR.value;
152
+ // Resolved once for every render this run performs — the entry renderers stay pure
153
+ // formatters (they take the id, they never look it up).
154
+ const upstreamId = spec.upstreamOf?.(root, canon, target);
136
155
  const batteryR = batteryFor(cfgR.value, spec.gate, input.class);
137
156
  if (!batteryR.ok) {
138
157
  renderRefusal(batteryR.violations).forEach((l) => ctx.err(l));
@@ -144,6 +163,11 @@ export async function runGate(ctx, gateName, target, flags) {
144
163
  const entries = readStream(root, target);
145
164
  const pins = policyPins(entries);
146
165
  const pinsText = pinsBlock(pins);
166
+ // D122. Open obligations join the prompt exactly as pins do, and join `prompts_sha` for
167
+ // the same reason: an obligation the battery did not see is an obligation nothing can
168
+ // discharge. Minting one therefore guarantees the next round re-invokes the battery,
169
+ // which is the intended cost of an override rather than an accident of the cache.
170
+ const deferralsText = deferralsBlock(openDeferrals(entries));
147
171
  const lenses = [];
148
172
  for (const name of active) {
149
173
  const lensR = resolvePrompt(name);
@@ -201,7 +225,8 @@ export async function runGate(ctx, gateName, target, flags) {
201
225
  const pin = chain[0];
202
226
  const key = {
203
227
  reviewed_sha: input.reviewedSha, gate: spec.gate,
204
- prompts_sha: promptsSha(lenses, pinsText === '' ? undefined : pinsText), pin, witness: version(),
228
+ prompts_sha: promptsSha(lenses, [pinsText, deferralsText].filter((t) => t !== '').join('') || undefined),
229
+ pin, witness: version(),
205
230
  harness: harness.name,
206
231
  };
207
232
  // D99: `gateSettled` reads only the last run, so any new run un-settles the gate.
@@ -241,21 +266,22 @@ export async function runGate(ctx, gateName, target, flags) {
241
266
  ctx.err(`warning: reviewer setup changed — this run discards the settled approve on ${spec.gate} ${target}`);
242
267
  }
243
268
  if (kind.kind === 'resume') {
244
- renderGateRun(ctx, kind.entry, 'resume', { entries });
269
+ renderGateRun(ctx, kind.entry, 'resume', { entries, upstream: upstreamId });
245
270
  printDispatchArithmetic(ctx, root, spec.gate, target);
246
271
  return kind.entry.outcome === 'passed' ? EXIT.OK : EXIT.FINDINGS;
247
272
  }
248
273
  if (kind.kind === 'changed-nothing') {
249
274
  ctx.out(kv('gate', spec.gate));
250
275
  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');
276
+ // The owed work is an EDIT, so the exits are the decisions that remain legal without
277
+ // one — the same set every other screen shows, rather than prose naming two of four.
278
+ renderChoices(ctx, spec.gate, target, entries, upstreamId);
252
279
  return EXIT.FINDINGS;
253
280
  }
254
281
  if (boundReached(entries, spec.gate)) {
255
282
  ctx.out(kv('gate', spec.gate));
256
283
  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}`);
284
+ renderChoices(ctx, spec.gate, target, entries, upstreamId);
259
285
  return EXIT.BLOCKED;
260
286
  }
261
287
  if (!flags.fresh) {
@@ -311,7 +337,7 @@ export async function runGate(ctx, gateName, target, flags) {
311
337
  const reviewed = ov?.reviewed ?? input.reviewed;
312
338
  const body = ov?.promptBody ?? input.promptBody;
313
339
  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`;
340
+ let prompt = `${lens.contents}\n\n${docsBlock(lens.docs ?? [])}${pinsText}${deferralsText}${menu ? `${menu}\n\n` : ''}## Reviewed content\n\n${body}\n`;
315
341
  for (let attempt = 0;; attempt++) {
316
342
  let answered;
317
343
  for (;;) {
@@ -389,8 +415,7 @@ export async function runGate(ctx, gateName, target, flags) {
389
415
  if (boundReached(entriesNow, spec.gate)) {
390
416
  ctx.out(kv('gate', spec.gate));
391
417
  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}`);
418
+ renderChoices(ctx, spec.gate, target, entriesNow, upstreamId);
394
419
  return EXIT.BLOCKED;
395
420
  }
396
421
  const entry = {
@@ -418,6 +443,16 @@ export async function runGate(ctx, gateName, target, flags) {
418
443
  ? [prepareStamp(doc, s.to, 'gate-approve', { run_id: entry.run_id })] : [];
419
444
  })
420
445
  : [];
446
+ // D122. The honest closure: a battery LOOKED and no longer found it. Only a run that
447
+ // actually judged this anchor's artifact can discharge — a malformed round judged nothing
448
+ // and must never close a debt, which is the same reason it does not spend the budget
449
+ // (row 67). Silence in a real verdict is the evidence; that is exactly what the injected
450
+ // block tells the reviewer, so the reviewer knows silence will be read that way.
451
+ const reported = new Set((entry.verdicts ?? []).flatMap((rv) => rv.findings
452
+ .map((f) => (typeof f.anchor === 'string' ? f.anchor : `omission:${f.anchor.scope}`))));
453
+ const discharged = entry.outcome === 'malformed' ? []
454
+ : openDeferrals(entriesNow).filter((d) => !reported.has(d.anchor))
455
+ .map((d) => ({ v: 1, t: 'deferral-discharged', id: d.id, artifact: target, by_run: entry.run_id }));
421
456
  const files = [
422
457
  journalRel(target), ...stamps.flatMap((s) => [s.rel, journalRel(s.stream)]),
423
458
  ...(input.repin ? [input.repin.rel] : []),
@@ -428,10 +463,13 @@ export async function runGate(ctx, gateName, target, flags) {
428
463
  journalMulti: [
429
464
  { stream: target, line: entryLine(entry) },
430
465
  ...stamps.map((s) => ({ stream: s.stream, line: s.line })),
466
+ ...discharged.map((d) => ({ stream: target, line: entryLine(d) })),
431
467
  ],
432
468
  };
433
469
  const txn = withTxn(root, marker, () => {
434
470
  appendEntry(root, target, entry);
471
+ for (const d of discharged)
472
+ appendEntry(root, target, d);
435
473
  for (const s of stamps)
436
474
  writeStamp(root, s);
437
475
  if (input.repin) {
@@ -444,8 +482,10 @@ export async function runGate(ctx, gateName, target, flags) {
444
482
  renderRefusal(txn.violations).forEach((l) => ctx.err(l));
445
483
  return EXIT.REFUSED;
446
484
  }
447
- renderGateRun(ctx, entry, 'ran', { entries: [...entriesNow, entry] });
485
+ renderGateRun(ctx, entry, 'ran', { entries: [...entriesNow, entry], upstream: upstreamId });
448
486
  printDispatchArithmetic(ctx, root, spec.gate, target);
487
+ for (const d of discharged)
488
+ ctx.out(kv('discharged', `${d.id} — no longer reported by this round`));
449
489
  if (input.repin)
450
490
  ctx.out(kv('re-pinned', `derives-from → ${input.repin.sha.slice(0, 7)} (witnessed by the drift lane)`));
451
491
  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,268 @@
1
+ import { anchorRecurrence, boundReached, ladderSpent, lastGateRun, notePrefill, pendingDecision, 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. Staleness blocks STAMPING, not judging: `--approve` asserts
103
+ // about current content and `decide` refuses it with `stale-verdict`, while a stop, a
104
+ // revise and an upstream judge the WORK and are all legal (probed 2026-08-12, all exit 0).
105
+ // Gated on a pending decision because that is what resolves an anchor: on the reopened
106
+ // and revised screens every decide verb refuses with `nothing-pending`, and there the
107
+ // caller's single re-gate act is the honest answer.
108
+ if (stale && !atBound) {
109
+ if (pendingDecision(entries, gate) === undefined)
110
+ return undefined;
111
+ const nextRound = spent + 1;
112
+ return {
113
+ key: 'decide', rule: 'stale-below-bound',
114
+ // No anchor: the findings describe bytes that no longer exist, and pinning a decision
115
+ // to one of them would file recurrence against a verdict nothing can still reproduce.
116
+ options: [
117
+ opt(`witness gate ${gate} ${target}`, 'root', {
118
+ why: `the verdict judged @${last.reviewed_sha.slice(0, 7)} and the content has moved since — no battery has read the current bytes, so every finding above describes a tree that no longer exists`,
119
+ tradeoff: `spends round ${nextRound} of ${budget}${nextRound >= budget ? ' — the last before the bound' : ''}`,
120
+ }),
121
+ opt(note, 'root', {
122
+ when: 'you already know the next edit and want no verdict on this state',
123
+ tradeoff: 'spends no round now — the round is spent by the re-gate that follows it',
124
+ }),
125
+ ...upAlt('the parent artifact is what is wrong, whatever the current bytes say', 'reopens the parent stage and resets this budget'),
126
+ stopOpt,
127
+ ],
128
+ };
129
+ }
130
+ const anchors = blockingAnchors(last);
131
+ const primary = anchors[0];
132
+ const recurrence = primary === undefined ? 0 : anchorRecurrence(entries, gate, primary);
133
+ const spentLadder = primary !== undefined && ladderSpent(entries, gate, primary);
134
+ // 3 — the ladder is spent for this anchor.
135
+ if (primary !== undefined && spentLadder && recurrence >= 1) {
136
+ return {
137
+ key: 'decide', rule: 'ladder-spent', anchor: primary,
138
+ options: [
139
+ { ...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 },
140
+ { ...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' },
141
+ ],
142
+ };
143
+ }
144
+ // 4 — at the bound with a recurring anchor: escalate.
145
+ if (atBound && primary !== undefined && recurrence >= 2) {
146
+ return {
147
+ key: 'decide', rule: 'bound-recurrence', anchor: primary,
148
+ options: [
149
+ 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` }),
150
+ overrideOpt,
151
+ ...(repairGranted(entries, gate) ? [] : [repairOpt]),
152
+ stopOpt,
153
+ abandonOpt,
154
+ ],
155
+ };
156
+ }
157
+ // 5 — at the bound otherwise.
158
+ if (atBound) {
159
+ return {
160
+ key: 'decide', rule: stale ? 'bound-stale' : 'bound', anchor: primary,
161
+ options: [
162
+ ...(stale ? [] : [opt(`${d} --approve --override`, 'deferral', {
163
+ why: `the round budget is spent (${spent} of ${budget}) and the gate will not run again; nothing below the bound remains`,
164
+ tradeoff: overrideOpt.tradeoff,
165
+ })]),
166
+ ...(stale
167
+ ? [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' })]
168
+ : [opt(up, 'root', { when: 'the parent artifact is what is wrong', tradeoff: 'reopens the parent stage and resets this budget' })]),
169
+ ...(repairGranted(entries, gate) ? [] : [repairOpt]),
170
+ stopOpt,
171
+ abandonOpt,
172
+ ],
173
+ };
174
+ }
175
+ // 6 — recurrence below the bound: escalate, patch-again becomes the alternative.
176
+ if (primary !== undefined && recurrence >= 2) {
177
+ return {
178
+ key: 'decide', rule: `anchor-recurrence-${recurrence}`, anchor: primary,
179
+ options: [
180
+ 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` }),
181
+ 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` }),
182
+ overrideOpt,
183
+ stopOpt,
184
+ ],
185
+ };
186
+ }
187
+ // 7 — a pin contradiction is a standing stop with its own handling.
188
+ const pinned = (last.verdicts ?? []).flatMap((rv) => rv.findings).find((f) => f.contradicts_pin !== undefined);
189
+ if (pinned !== undefined) {
190
+ return {
191
+ key: 'decide', rule: 'pin-contradiction',
192
+ options: [
193
+ 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` }),
194
+ 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' }),
195
+ ...upAlt('the pin itself is what should change, and it was set at the stage above', 'reopens the parent stage and resets this budget'),
196
+ stopOpt,
197
+ ],
198
+ };
199
+ }
200
+ // 8 / 9 — blocking findings, by where they anchor.
201
+ if (anchors.length > 0) {
202
+ const parented = upstream !== undefined && anchors.every((a) => a.startsWith(`${upstream} `) || a.startsWith(`${upstream}>`));
203
+ return parented
204
+ ? {
205
+ key: 'decide', rule: 'blocking-parent', anchor: primary,
206
+ options: [
207
+ 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` }),
208
+ 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' }),
209
+ overrideOpt,
210
+ stopOpt,
211
+ ],
212
+ }
213
+ : {
214
+ key: 'decide', rule: 'blocking-here', anchor: primary,
215
+ options: [
216
+ opt(note, 'root', { why: `${anchors.length} blocking finding${anchors.length === 1 ? '' : 's'} anchored inside this artifact (${anchors.slice(0, 2).join(', ')}); ${rounds}` }),
217
+ ...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'),
218
+ overrideOpt,
219
+ stopOpt,
220
+ ],
221
+ };
222
+ }
223
+ const checksFailed = last.checks.filter((c) => !c.ok);
224
+ const nonBlocking = (last.verdicts ?? []).flatMap((rv) => rv.findings).length;
225
+ // 10 — non-blocking findings only.
226
+ if (checksFailed.length === 0 && nonBlocking > 0) {
227
+ return {
228
+ key: 'decide', rule: 'non-blocking-only',
229
+ options: [
230
+ opt(`${d} --approve`, 'root', { why: `${nonBlocking} finding${nonBlocking === 1 ? '' : 's'}, none blocking; all ${last.checks.length} checks green` }),
231
+ 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` }),
232
+ ...upAlt('the finding points at something the stage above got wrong', 'reopens the parent stage and resets this budget'),
233
+ stopOpt,
234
+ ],
235
+ };
236
+ }
237
+ // 11 — a reserved stop with clean evidence.
238
+ if (checksFailed.length === 0 && last.standing !== undefined) {
239
+ return {
240
+ key: 'decide', rule: 'reserved-stop-clean',
241
+ options: [
242
+ opt(`${d} --approve`, 'root', {
243
+ why: `${last.checks.length} of ${last.checks.length} checks green, 0 findings — approve is what the evidence supports`,
244
+ judgeFirst: RESERVED.has(gate)
245
+ ? (gate === 'ship'
246
+ ? 'whether this change should exist. The lenses judged the code against the plan; nothing judged the plan against the product, and no lens can'
247
+ : 'the look itself — the critic judged canon-compliance and coverage, not whether this is the right design')
248
+ : 'whether this cut is how you would ship it. Coverage is checked; the shape of the cut is not',
249
+ }),
250
+ opt(note, 'root', { when: 'the evidence is right and the thing itself is wrong', tradeoff: `costs ${rounds}; the gate re-runs on your edit` }),
251
+ ...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'),
252
+ stopOpt,
253
+ ],
254
+ };
255
+ }
256
+ // 12 — stopped, clean, no standing stop: the --manual flag is the only reason.
257
+ return {
258
+ key: 'decide', rule: 'manual-stop',
259
+ options: [
260
+ opt(`${d} --approve`, 'root', {
261
+ 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`,
262
+ }),
263
+ 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` }),
264
+ ...upAlt('what you armed --manual to catch is a fault in the stage above', 'reopens the parent stage and resets this budget'),
265
+ stopOpt,
266
+ ],
267
+ };
268
+ }