@popovych.co/witness 0.14.7 → 0.15.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/abandon.js CHANGED
@@ -10,7 +10,7 @@ import { crashPoint, withTxn } from './txn.js';
10
10
  import { appendEntry, effortAbandoned, effortStreams, entryLine, journalRel, readStream } from './journal.js';
11
11
  import { findById, plansOn } from './scan.js';
12
12
  import { effortOf, effortWrites } from './reviewed.js';
13
- import { ok, refuse, v } from './refusal.js';
13
+ import { humanCall, ok, refuse, v } from './refusal.js';
14
14
  import { prepareStamp, writeStamp } from './stamp.js';
15
15
  import { kv } from './toon.js';
16
16
  import { removeWorktree, worktreePath } from './worktree.js';
@@ -37,6 +37,21 @@ export function pinCommit(root, rel, pin, from) {
37
37
  return found;
38
38
  }
39
39
  export const keptLine = (k) => `${String(k.doc.meta.id)} — not unwound · still standing on it: ${k.standing.join(' ')}`;
40
+ // D167. The abandon says everything it did to canon, on both paths, as `kept:` already did: what
41
+ // it reverted, what came back with it, and what the walk took back on the way.
42
+ export function abandonLines(items, kept) {
43
+ return [
44
+ ...items.flatMap((item) => [
45
+ ...(item.revert ? [kv('reverted', `${String(item.revert.doc.meta.id)} (${item.revert.action}) — journal untouched`)] : []),
46
+ ...(item.revert?.returns ?? []).flatMap((r) => [
47
+ kv('returned', `${r.entry.artifact} — back in canon as ${r.entry.to} · the re-slice into ${r.by} is taken back`),
48
+ ...r.unwound.map((u) => kv('unwound', `${u.artifact} — stays out of canon · an abandoned effort created it`)),
49
+ ]),
50
+ ...(item.kept ? [kv('kept', keptLine(item.kept))] : []),
51
+ ]),
52
+ ...kept.map((k) => kv('kept', keptLine(k))),
53
+ ];
54
+ }
40
55
  // D166 (review). The writes the way back takes back besides this abandon's own targets: every
41
56
  // write of an effort already abandoned — whose abandon skipped a spec a later re-slice had
42
57
  // departed — and every write of the effort being abandoned now (`effort`, absent on the plan
@@ -117,6 +132,28 @@ export function specRevertFor(root, canon, spec, pin, abandonSet, effort) {
117
132
  const design = priorDesign !== undefined || existsSync(join(root, designR)) ? { rel: designR, prior: priorDesign } : undefined;
118
133
  return ok({ doc: spec, action: 'restore', content: prior.out, toStatus, design, returns });
119
134
  }
135
+ // D167. A return lands on the path its spec left from, and `rename` moves an id but leaves its
136
+ // file, so another doc may hold that path — or another spec this abandon brings back may have
137
+ // left from it too. Written over, either left canon with nothing to say so. Only a doc this same
138
+ // abandon departs may stand there; any other holder, or a second return, is a human's call.
139
+ export function returnCollisions(root, canon, items) {
140
+ const departing = new Set(items.flatMap((i) => (i.revert?.action === 'delete' ? [i.revert.doc.rel] : [])));
141
+ const returns = items.flatMap((i) => (i.revert?.returns ?? []).map((r) => ({ r, going: String(i.revert.doc.meta.id) })));
142
+ const landing = new Map();
143
+ for (const { r } of returns) {
144
+ const ids = landing.get(r.rel) ?? [];
145
+ if (!ids.includes(r.entry.artifact))
146
+ landing.set(r.rel, [...ids, r.entry.artifact]);
147
+ }
148
+ return [
149
+ ...returns.filter(({ r }) => !departing.has(r.rel) && existsSync(join(root, r.rel))).map(({ r, going }) => {
150
+ const held = canon.docs.find((d) => d.rel === r.rel);
151
+ const holder = held ? String(held.meta.id) : 'a doc canon cannot read';
152
+ return humanCall(v('spec', 'path-taken', `${r.rel} holds ${holder}`, `that path free first — ${r.entry.artifact} comes back to it when ${going} goes · what becomes of ${holder} is a human's call`));
153
+ }),
154
+ ...[...landing].filter(([, ids]) => ids.length > 1).map(([rel, ids]) => humanCall(v('spec', 'path-taken', `${rel} takes ${ids.join(' and ')}`, `one return per path — each left from it · which one comes back is a human's call`))),
155
+ ];
156
+ }
120
157
  export function executeAbandon(root, ctx, items, effortEntry, subject) {
121
158
  const files = new Set();
122
159
  const journalMulti = [];
@@ -187,10 +224,13 @@ export function executeAbandon(root, ctx, items, effortEntry, subject) {
187
224
  else if (d !== undefined && existsSync(join(root, d.rel)))
188
225
  unlinkSync(join(root, d.rel));
189
226
  }
190
- for (const r of item.revert.returns)
191
- r.restore();
192
227
  }
193
228
  }
229
+ // D167. Every departure lands before any return (P9b): restored per item, a later item's
230
+ // departure unlinked the spec an earlier item's return had just brought back to its path.
231
+ for (const item of items)
232
+ for (const r of item.revert?.returns ?? [])
233
+ r.restore();
194
234
  for (const s of stamps)
195
235
  writeStamp(root, s);
196
236
  for (const se of specEntries)
package/dist/adopt.js CHANGED
@@ -6,9 +6,11 @@ import { readStream } from './journal.js';
6
6
  import { canonicalSha } from './sha.js';
7
7
  const hasTrailer = (root, sha) => git(root, 'show', '-s', '--format=%B', sha).split('\n').some((l) => l.trim() === TRAILER);
8
8
  const commitsTouching = (root, rel) => git(root, 'log', '--format=%H', '--', rel).split('\n').filter(Boolean);
9
- export function lastWitnessed(root, rel) {
9
+ // `absolved`: commits an `adopt` entry took in (`adoptedCommits`), which count as witnessed too —
10
+ // a hand commit that `adopt` absolved is the doc's witnessed state from then on.
11
+ export function lastWitnessed(root, rel, absolved = new Set()) {
10
12
  for (const sha of commitsTouching(root, rel)) {
11
- if (!hasTrailer(root, sha))
13
+ if (!hasTrailer(root, sha) && !absolved.has(sha))
12
14
  continue;
13
15
  const raw = tryGit(root, 'show', `${sha}:${rel}`);
14
16
  if (!raw.ok)
@@ -16,7 +18,7 @@ export function lastWitnessed(root, rel) {
16
18
  const doc = splitDoc(raw.out);
17
19
  if (!doc.ok)
18
20
  continue;
19
- return { sha: canonicalSha(doc.value.meta, doc.value.body), criteria: doc.value.meta.criteria ?? null };
21
+ return { sha: canonicalSha(doc.value.meta, doc.value.body), criteria: doc.value.meta.criteria ?? null, id: String(doc.value.meta.id) };
20
22
  }
21
23
  return undefined;
22
24
  }
@@ -0,0 +1,68 @@
1
+ import { SESSION_DEFAULT } from './model.js';
2
+ import { ok, refuse } from './refusal.js';
3
+ import { invokeReviewerAsync, parseVerdictText } from './reviewer.js';
4
+ import { parseVerdict, verdictViolations } from './verdict.js';
5
+ // Row 169. Every lens walks the fallback chain on its own, from the pin. One lens that fell
6
+ // back makes the whole round a fallback round: `model` is the highest rung anyone answered
7
+ // on, so `fellBack` holds exactly when the sequential battery would have held it (106/107).
8
+ async function runLens(ctx, harness, chain, lens, opts) {
9
+ let rung = 0;
10
+ let prompt = lens.prompt;
11
+ let rerolled = false;
12
+ const failedOn = [];
13
+ for (let attempt = 0;; attempt++) {
14
+ let answered;
15
+ for (;;) {
16
+ const id = chain[rung];
17
+ const r = await invokeReviewerAsync(ctx, harness, {
18
+ ...opts, prompt, model: id === SESSION_DEFAULT ? undefined : id, lens: lens.name,
19
+ });
20
+ if (r.ok) {
21
+ answered = r.value.text;
22
+ break;
23
+ }
24
+ if (rung >= chain.length - 1)
25
+ return refuse(r.violations);
26
+ failedOn.push(rung);
27
+ rung += 1;
28
+ }
29
+ const rawR = parseVerdictText(answered);
30
+ const parsedR = rawR.ok ? parseVerdict(rawR.value) : rawR;
31
+ const violations = parsedR.ok ? verdictViolations(parsedR.value, lens.reviewed) : parsedR.violations;
32
+ if (parsedR.ok && violations.length === 0) {
33
+ return ok({
34
+ verdict: { reviewer: lens.name, coverage: parsedR.value.coverage, findings: parsedR.value.findings },
35
+ failedOn, rung, rerolled,
36
+ });
37
+ }
38
+ if (attempt === 0) {
39
+ // one self-repair reroll per reviewer — a single flaky verdict would
40
+ // otherwise poison the whole round as 'malformed'; the reroll is not
41
+ // part of the gate key, so cached rounds replay the settled result
42
+ rerolled = true;
43
+ prompt += `\n## Previous attempt rejected\n\n${violations.map((x) => `- ${x.field}: ${x.rule} — got ${x.got}; want ${x.want}`).join('\n')}\n\nRe-emit the complete corrected verdict JSON.\n`;
44
+ continue;
45
+ }
46
+ return ok({ malformed: violations, failedOn, rung, rerolled });
47
+ }
48
+ }
49
+ // Lenses launch together and are aggregated only once every one has settled, in battery
50
+ // order — the order a lens finishes in must never reach the journal. A lens that exhausts the
51
+ // chain refuses the whole gate, the first such lens in battery order speaking for it.
52
+ export async function runBattery(ctx, harness, chain, lenses, opts) {
53
+ const results = await Promise.all(lenses.map((l) => runLens(ctx, harness, chain, l, opts)));
54
+ const runs = [];
55
+ for (const r of results) {
56
+ if (!r.ok)
57
+ return refuse(r.violations);
58
+ runs.push(r.value);
59
+ }
60
+ const failed = new Set(runs.flatMap((r) => r.failedOn));
61
+ return ok({
62
+ verdicts: runs.flatMap((r) => (r.verdict ? [r.verdict] : [])),
63
+ malformed: runs.flatMap((r, i) => (r.malformed ? [{ reviewer: lenses[i].name, violations: r.malformed }] : [])),
64
+ fallback: [...new Set(chain.filter((_, i) => failed.has(i)))],
65
+ rerolled: lenses.filter((_, i) => runs[i].rerolled).map((l) => l.name),
66
+ model: chain[Math.max(0, ...runs.map((r) => r.rung))],
67
+ });
68
+ }
package/dist/deferral.js CHANGED
@@ -31,15 +31,28 @@ export function openDeferrals(entries) {
31
31
  // Deliberately the INVERSE of a pin: a pin tells reviewers not to re-litigate, this tells
32
32
  // them to report the thing if it is still there. Pins suppress findings; obligations
33
33
  // solicit them — which is what makes the discharge automatic and evidence-shaped.
34
+ //
35
+ // The block states the rule the gate applies, word for word: discharge (gate.ts) reads an
36
+ // obligation the round does not report as fixed. It once said the opposite — "silence is read
37
+ // as still present" — which let a reviewer who still saw a deferred defect stay quiet and
38
+ // close it (row 122, amended 0.15.0). An omission is stored as `omission:<scope>`, which is no
39
+ // anchor a verdict can carry, so it is listed with the anchor object a finding must emit.
34
40
  export function deferralsBlock(open) {
35
41
  if (open.length === 0)
36
42
  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
+ const line = (d, i) => {
44
+ const where = `${d.gate} round ${d.round} (${d.id})`;
45
+ if (!d.anchor.startsWith('omission:'))
46
+ return `${i + 1}. ${d.anchor} — ${where}`;
47
+ const scope = d.anchor.slice('omission:'.length);
48
+ return `${i + 1}. omission · scope ${scope} — ${where} — report it with "anchor": { "kind": "omission", "scope": ${JSON.stringify(scope)} }`;
49
+ };
50
+ return '## Open deferrals (approved over findings — report these if they still hold)\n\n' +
51
+ 'This artifact was approved over the findings below rather than fixed. They are NOT settled ' +
52
+ 'policy and they are NOT pins. If a defect is still present, report it as a finding anchored ' +
53
+ 'exactly as listed: that keeps its obligation open. Silence is read as fixed: an obligation ' +
54
+ 'this round does not report is discharged.\n\n' +
55
+ open.map(line).join('\n') + '\n\n';
43
56
  }
44
57
  // D122 + D166. A debt changes homes when the artifact holding it stops being the one the next
45
58
  // effort will meet: a plan's merge hands its open obligations to the parent spec (stamp.ts), a
package/dist/departure.js CHANGED
@@ -4,6 +4,7 @@ import { deferralMoves } from './deferral.js';
4
4
  import { designRel } from './design.js';
5
5
  import { FLOOR_STREAM } from './floor.js';
6
6
  import { splitDoc } from './fm.js';
7
+ import { adoptedCommits, lastWitnessed } from './adopt.js';
7
8
  import { gitBlob, tryGit } from './gitio.js';
8
9
  import { journalRel, readStream } from './journal.js';
9
10
  import { v } from './refusal.js';
@@ -121,6 +122,8 @@ function returnsThrough(root, paths, commit, chain, unwound, takenBack, seen) {
121
122
  // Each link of the chain hands home the debts that came to it from the next one down.
122
123
  moves: chain.flatMap((s, k) => deferralMoves(readStream(root, s), s, id, (d) => d.moved_from === (chain[k + 1] ?? id))),
123
124
  unwound,
125
+ rel,
126
+ by: successor,
124
127
  }];
125
128
  });
126
129
  }
@@ -145,3 +148,22 @@ export function idRetired(root, id, type) {
145
148
  return v('id', 'id-taken', id, `a new id — ${id} was a spec its abandon unwound`);
146
149
  return undefined;
147
150
  }
151
+ // D167. Whether a doc under this id may land on this path, which was last witnessed (by a
152
+ // trailer, or by an `adopt` that absolved the commit) under `prior`. A superseded spec comes back
153
+ // to the path it left from, so its departure holds that path as it holds the id. Any other id
154
+ // left a path only by hand, since `rename` keeps the file: the path is held only while the doc
155
+ // on it is what `prior` became — an in-place id change, which is `rename` done by hand. A
156
+ // removal, a `prior` living at another path, or a path left empty holds nothing: nothing lands back.
157
+ export function pathRetired(root, canon, rel, id) {
158
+ const prior = lastWitnessed(root, rel, adoptedCommits(root))?.id;
159
+ if (prior === undefined || prior === id)
160
+ return undefined;
161
+ const exit = canonExit(readStream(root, prior));
162
+ if (exit?.to === 'superseded') {
163
+ return v('id', 'path-taken', rel, `a new id — ${rel} is where ${prior} comes back if the re-slice that superseded it is taken back`);
164
+ }
165
+ const elsewhere = findById(canon, prior);
166
+ if (exit !== undefined || (elsewhere !== undefined && elsewhere.rel !== rel) || !existsSync(join(root, rel)))
167
+ return undefined;
168
+ return v('id', 'path-taken', rel, `the id it was witnessed under — ${rel} held ${prior} · witness rename is how an id changes`);
169
+ }
package/dist/gate.js CHANGED
@@ -13,9 +13,10 @@ import { newRunId } from './drift.js';
13
13
  import { resolveJudge } from './harness.js';
14
14
  import { ok, refuse, renderRefusal, v } from './refusal.js';
15
15
  import { cmd, kv, rows } from './toon.js';
16
- import { loadMatrix, resolveModel, SESSION_DEFAULT } from './model.js';
17
- import { docKeysFor, docsBlock, invokeReviewer, loadLensDocs, parseVerdictText, pinsBlock, promptsSha, resolvePrompt } from './reviewer.js';
18
- import { anchorMenu, parseVerdict, verdictViolations } from './verdict.js';
16
+ import { loadMatrix, resolveModel } from './model.js';
17
+ import { docKeysFor, docsBlock, loadLensDocs, pinsBlock, promptsSha, resolvePrompt } from './reviewer.js';
18
+ import { runBattery } from './battery.js';
19
+ import { anchorMenu } from './verdict.js';
19
20
  import { ROUND_BOUND, appendKind, boundReached, fellBack, lastGateRun, liveExits, roundBudget, roundsSinceApprove, runsSinceReset, } from './rounds.js';
20
21
  export { liveExits } from './rounds.js';
21
22
  import { gateSettled } from './verbs/next.js';
@@ -329,10 +330,9 @@ export async function runGate(ctx, gateName, target, flags) {
329
330
  // write in every other flow for its full duration, on a lock whose real job is a ~100ms
330
331
  // commit. The guarded window opens below, immediately before the journal append.
331
332
  let verdicts = [];
332
- const malformed = [];
333
- const fallback = [];
334
- const rerolled = [];
335
- let rung = 0;
333
+ let malformed = [];
334
+ let fallback = [];
335
+ let rerolled = [];
336
336
  let model = pin;
337
337
  let cached = false;
338
338
  if (kind.kind === 'cached') {
@@ -341,48 +341,24 @@ export async function runGate(ctx, gateName, target, flags) {
341
341
  cached = true;
342
342
  }
343
343
  else {
344
- for (const lens of lenses) {
344
+ // Row 169: every lens at once — a round's wall clock is its slowest lens, not their sum.
345
+ const composed = lenses.map((lens) => {
345
346
  const ov = input.lensOverrides?.[lens.name];
346
347
  const reviewed = ov?.reviewed ?? input.reviewed;
347
348
  const body = ov?.promptBody ?? input.promptBody;
348
349
  const menu = anchorMenu(reviewed);
349
- let prompt = `${lens.contents}\n\n${docsBlock(lens.docs ?? [])}${pinsText}${deferralsText}${menu ? `${menu}\n\n` : ''}## Reviewed content\n\n${body}\n`;
350
- for (let attempt = 0;; attempt++) {
351
- let answered;
352
- for (;;) {
353
- const id = chain[rung];
354
- const r = invokeReviewer(ctx, harness, { cwd: root, prompt, model: id === SESSION_DEFAULT ? undefined : id, ...extras });
355
- if (r.ok) {
356
- answered = r.value.text;
357
- model = id;
358
- break;
359
- }
360
- if (rung >= chain.length - 1) {
361
- renderRefusal(r.violations).forEach((l) => ctx.err(l));
362
- return EXIT.REFUSED;
363
- }
364
- fallback.push(id);
365
- rung += 1;
366
- }
367
- const rawR = parseVerdictText(answered);
368
- const parsedR = rawR.ok ? parseVerdict(rawR.value) : rawR;
369
- const violations = parsedR.ok ? verdictViolations(parsedR.value, reviewed) : parsedR.violations;
370
- if (parsedR.ok && violations.length === 0) {
371
- verdicts.push({ reviewer: lens.name, coverage: parsedR.value.coverage, findings: parsedR.value.findings });
372
- break;
373
- }
374
- if (attempt === 0) {
375
- // one self-repair reroll per reviewer — a single flaky verdict would
376
- // otherwise poison the whole round as 'malformed'; the reroll is not
377
- // part of the gate key, so cached rounds replay the settled result
378
- rerolled.push(lens.name);
379
- prompt += `\n## Previous attempt rejected\n\n${violations.map((x) => `- ${x.field}: ${x.rule} — got ${x.got}; want ${x.want}`).join('\n')}\n\nRe-emit the complete corrected verdict JSON.\n`;
380
- continue;
381
- }
382
- malformed.push({ reviewer: lens.name, violations });
383
- break;
384
- }
350
+ return {
351
+ name: lens.name, reviewed,
352
+ prompt: `${lens.contents}\n\n${docsBlock(lens.docs ?? [])}${pinsText}${deferralsText}${menu ? `${menu}\n\n` : ''}## Reviewed content\n\n${body}\n`,
353
+ };
354
+ });
355
+ const ranR = await runBattery(ctx, harness, chain, composed, { cwd: root, ...extras });
356
+ if (!ranR.ok) {
357
+ renderRefusal(ranR.violations).forEach((l) => ctx.err(l));
358
+ return EXIT.REFUSED;
385
359
  }
360
+ ;
361
+ ({ verdicts, malformed, fallback, rerolled, model } = ranR.value);
386
362
  }
387
363
  const blocking = verdicts.flatMap((x) => x.findings).filter((f) => f.blocking).length;
388
364
  const pinConflicts = verdicts.flatMap((x) => x.findings).filter((f) => f.contradicts_pin !== undefined).length;
@@ -470,10 +446,12 @@ export async function runGate(ctx, gateName, target, flags) {
470
446
  const marker = {
471
447
  op: `gate-${spec.gate}`,
472
448
  files: [...new Set(files)],
449
+ // D167. Per stream, the marker lists lines in the order the body appends them, because
450
+ // `landedJournal` reads a crash by that order — and the plan gate stamps its own target.
473
451
  journalMulti: [
474
452
  { stream: target, line: entryLine(entry) },
475
- ...stamps.map((s) => ({ stream: s.stream, line: s.line })),
476
453
  ...discharged.map((d) => ({ stream: target, line: entryLine(d) })),
454
+ ...stamps.map((s) => ({ stream: s.stream, line: s.line })),
477
455
  ],
478
456
  };
479
457
  const txn = withTxn(root, marker, () => {
@@ -8,6 +8,10 @@ import { effortOf, planPairSha } from '../reviewed.js';
8
8
  import { validateDoc } from '../schemas.js';
9
9
  import { registerGate } from '../gate.js';
10
10
  import { designPending, designStamp } from '../design.js';
11
+ // D167. One derivation for the gate's stamp and `decide`'s: a pass approves a draft and nothing
12
+ // else. Row 95 keeps an in-flight status through a re-author, and a passing re-gate must not
13
+ // take it back — nor stamp a finished plan approved again.
14
+ const draftApproval = (plan) => String(plan.meta.status) === 'draft' ? [{ artifact: String(plan.meta.id), to: 'approved' }] : [];
11
15
  registerGate({
12
16
  gate: 'plan',
13
17
  targetKind: 'plan',
@@ -69,7 +73,7 @@ registerGate({
69
73
  },
70
74
  promptBody,
71
75
  checks,
72
- stamps: [{ artifact: planId, to: 'approved' }],
76
+ stamps: draftApproval(plan),
73
77
  });
74
78
  },
75
79
  currentSha(_root, canon, _cfg, planId) {
@@ -83,6 +87,6 @@ registerGate({
83
87
  },
84
88
  approveStamps(_root, canon, planId) {
85
89
  const doc = findById(canon, planId);
86
- return doc && String(doc.meta.status) === 'draft' ? [{ artifact: planId, to: 'approved' }] : [];
90
+ return doc ? draftApproval(doc) : [];
87
91
  },
88
92
  });
package/dist/recommend.js CHANGED
@@ -35,7 +35,7 @@ const opt = (command, depth, rest = {}) => ({ command, depth, runnable: !/<[^>]+
35
35
  // rather than to a weighting — and each rule is testable from one journal state.
36
36
  // Order: malformed · stale-below-bound · ladder-spent · bound+recurrence · bound ·
37
37
  // recurrence · pin-contradiction · blocking-parent · blocking-here ·
38
- // non-blocking-only · reserved-stop-clean · manual-stop
38
+ // checks-failed · non-blocking-only · reserved-stop-clean · manual-stop
39
39
  // D154. Trust must never be the toll for approval. Where a plain `--approve` is offered
40
40
  // and the artifact carries untrusted `cmd:` criteria, the block renders BOTH forms — plain
41
41
  // first, so approving without granting stays the easy path — and names the commands
@@ -57,7 +57,9 @@ function withTrustVariants(d, untrusted) {
57
57
  tradeoff: [o.tradeoff, `the listed commands stay blocked at headless gates: ${listed}`]
58
58
  .filter(Boolean).join(' · '),
59
59
  };
60
- options.push(opt(`${o.command} --trust-cmds`, 'root', {
60
+ // The twin is the same stamp plus a grant, so it carries the stamp's depth: next to a
61
+ // deferral approve (checks-failed) it is a deferral too, never a root fix.
62
+ options.push(opt(`${o.command} --trust-cmds`, o.depth, {
61
63
  when: 'you have read these commands and accept them running unattended at every later gate',
62
64
  tradeoff: `grants them repo-wide in .witness/allow.json (machine-local): ${listed}`,
63
65
  note: 'a bare affirmation never grants trust — this form must be named (D143)',
@@ -265,6 +267,39 @@ function recommendCore(ctx) {
265
267
  }
266
268
  const checksFailed = last.checks.filter((c) => !c.ok);
267
269
  const nonBlocking = (last.verdicts ?? []).flatMap((rv) => rv.findings).length;
270
+ // 9b — a deterministic check failed and no finding blocks (row 170). Before this rule such a
271
+ // run fell to manual-stop, which recommended approve and blamed a --manual flag nobody set:
272
+ // 31 of 31 manual-stop recommendations in fewhats were exactly this. A failed check is not a
273
+ // reviewer's opinion, so the fix comes first and approving over it is the deferral.
274
+ if (checksFailed.length > 0) {
275
+ const names = checksFailed.map((c) => c.name);
276
+ // amendment-ack is the one check an approve clears: approving IS the acknowledgement it
277
+ // asks for (gates/decompose.ts). Alone, approve leads; beside any other failure, the fix
278
+ // still comes first and the ack rides the approve that follows it.
279
+ if (names.every((n) => n === 'amendment-ack')) {
280
+ return {
281
+ key: 'decide', rule: 'checks-failed',
282
+ options: [
283
+ opt(`${d} --approve`, 'root', { why: `the one failed check is amendment-ack — a spec was amended under in-progress plans · approving is the acknowledgement it asks for; ${rounds}` }),
284
+ opt(note, 'root', { when: 'the amendment should not land under the in-progress plans as it stands', tradeoff: `costs ${rounds}; the gate re-runs on your edit` }),
285
+ ...upAlt('what should change is the amendment the stage above made', 'reopens the parent stage and resets this budget'),
286
+ stopOpt,
287
+ ],
288
+ };
289
+ }
290
+ return {
291
+ key: 'decide', rule: 'checks-failed',
292
+ options: [
293
+ opt(note, 'root', { why: `${names.length} deterministic check${names.length === 1 ? '' : 's'} failed: ${names.join(' · ')} — no finding blocks · fix what the check names; ${rounds}` }),
294
+ ...upAlt('the check fails because of what the stage above asked for', 'reopens the parent stage and resets this budget'),
295
+ opt(`${d} --approve`, 'deferral', {
296
+ when: 'the failing check is wrong or cannot pass for a reason outside this artifact',
297
+ tradeoff: 'stamps the artifact over a failed deterministic check · no obligation is minted — the failure stays visible only until a later gate run passes the check',
298
+ }),
299
+ stopOpt,
300
+ ],
301
+ };
302
+ }
268
303
  // 10 — non-blocking findings only.
269
304
  if (checksFailed.length === 0 && nonBlocking > 0) {
270
305
  return {
package/dist/refusal.js CHANGED
@@ -11,13 +11,18 @@ export function v(field, rule, got, want, remedy) {
11
11
  export const humanCall = (x) => ({ ...x, human: true });
12
12
  export function renderRefusal(violations) {
13
13
  const runnable = [...new Set(violations.map((x) => x.remedy).filter((r) => r !== undefined && !/<[^>]+>/.test(r)))];
14
+ // D167. Row 108's principle cuts both ways: the stop is true for the human's rows and false for
15
+ // the reader's own, so the trailer names the human's rules and leaves the rest self-repairable.
16
+ const human = [...new Set(violations.filter((x) => x.human).map((x) => x.rule))];
17
+ const mixed = violations.some((x) => !x.human);
14
18
  return [
15
19
  // The destructure keeps `remedy` out of the row objects: the column list already
16
20
  // governs what prints, but a remedy is not a column and must not become one.
17
21
  ...rows('refused', ['field', 'rule', 'got', 'want'], violations.map(({ remedy: _remedy, ...rest }) => rest)),
18
22
  ...runnable.map((r) => `run: ${r}`),
19
- violations.some((x) => x.human)
20
- ? 'help: a row here is a human\'s decision — stop and surface the list; re-running cannot clear it'
23
+ human.length
24
+ ? `help: a human's decision: ${human.join(' · ')} — stop and surface the list; re-running cannot clear it`
25
+ + (mixed ? ' · the other rows are structured for self-repair once it is settled' : '')
21
26
  : 'help: fix each row and re-run — rows are structured for self-repair',
22
27
  ];
23
28
  }
package/dist/reviewer.js CHANGED
@@ -1,4 +1,4 @@
1
- import { spawnSync } from 'node:child_process';
1
+ import { spawn, spawnSync } from 'node:child_process';
2
2
  import { createHash } from 'node:crypto';
3
3
  import { existsSync, readFileSync } from 'node:fs';
4
4
  import { dirname, join } from 'node:path';
@@ -87,7 +87,15 @@ const REVIEWER_TIMEOUT_MS = 600_000;
87
87
  // invocations over hours, and treating a transient stall as fatal throws away every
88
88
  // sample already paid for. Retries are bounded so a genuinely wedged binary still ends.
89
89
  const TIMEOUT_RETRIES = 2;
90
- export function invokeReviewer(ctx, harness, opts) {
90
+ const MAX_OUTPUT = 64 * 1024 * 1024;
91
+ const TERM_GRACE_MS = 5_000;
92
+ // setTimeout clamps a delay above 2^31-1 ms to 1 ms, which would turn a "never time out"
93
+ // config into an instant timeout on every call. spawnSync has no such ceiling.
94
+ const MAX_TIMER_MS = 2 ** 31 - 1;
95
+ const EXIT_GRACE_MS = 2_000;
96
+ // The two invokers share everything but the spawn itself, so they cannot drift apart:
97
+ // the pin check, the spawn resolution, and below, the mapping from what happened to a row.
98
+ function prepareInvocation(ctx, harness, opts) {
91
99
  let pin;
92
100
  if (opts.model !== undefined) {
93
101
  const pinR = validatePin(harness, 'gates.model', opts.model);
@@ -100,36 +108,140 @@ export function invokeReviewer(ctx, harness, opts) {
100
108
  // prompt rides stdin, so the shim's second parse of the command line never sees it. A
101
109
  // reviewer that cannot be started is an invocation failure on every platform: the
102
110
  // resolution's own got/want, under the rule a missing binary has always reported.
103
- const run = resolveSpawn(cmd, args, { ...ctx.env, ...env });
111
+ const run = resolveSpawn(cmd, args, {
112
+ ...ctx.env, ...env, ...(opts.lens !== undefined ? { WITNESS_LENS: opts.lens } : {}),
113
+ });
104
114
  if (!run.ok)
105
115
  return refuse(run.violations.map((x) => ({ ...x, rule: 'reviewer-invocation' })));
106
- const timeout = opts.timeoutMs ?? REVIEWER_TIMEOUT_MS;
116
+ return ok({
117
+ cmd, file: run.value.file, args: run.value.args, env: run.value.env,
118
+ windowsVerbatimArguments: run.value.windowsVerbatimArguments,
119
+ timeout: opts.timeoutMs ?? REVIEWER_TIMEOUT_MS,
120
+ });
121
+ }
122
+ function classifyAttempt(harness, p, attempt, r) {
123
+ // A stall is NOT the missing-binary case. Reporting it as one sent the last
124
+ // reader hunting a PATH problem on a machine where the reviewer answered in 6s.
125
+ if (r.timedOut) {
126
+ if (attempt < TIMEOUT_RETRIES)
127
+ return 'retry';
128
+ return refuse([v(p.cmd, 'reviewer-timeout', `no response in ${p.timeout}ms after ${attempt + 1} attempts`, 'a reviewer that answers within the timeout — raise gates.reviewerTimeoutMs in witness.config.yaml if the model is simply slow')]);
129
+ }
130
+ if (r.error) {
131
+ return refuse([v(p.cmd, 'reviewer-invocation', String(r.error.message), `a runnable ${p.cmd} binary on PATH — gates invoke reviewers headlessly; witness check probes this`)]);
132
+ }
133
+ if (r.status !== 0) {
134
+ return refuse([v(p.cmd, 'reviewer-invocation', `exit ${String(r.status)}: ${r.stderr.slice(0, 200)}`, `${p.cmd} print mode exiting 0`)]);
135
+ }
136
+ return harness.reviewer.parseEnvelope(r.stdout);
137
+ }
138
+ export function invokeReviewer(ctx, harness, opts) {
139
+ const pR = prepareInvocation(ctx, harness, opts);
140
+ if (!pR.ok)
141
+ return pR;
142
+ const p = pR.value;
107
143
  for (let attempt = 0;; attempt += 1) {
108
- const r = spawnSync(run.value.file, run.value.args, {
144
+ const r = spawnSync(p.file, p.args, {
109
145
  cwd: opts.cwd,
110
- env: run.value.env,
146
+ env: p.env,
111
147
  input: opts.prompt,
112
148
  encoding: 'utf8',
113
- timeout,
114
- maxBuffer: 64 * 1024 * 1024,
115
- windowsVerbatimArguments: run.value.windowsVerbatimArguments,
149
+ timeout: p.timeout,
150
+ maxBuffer: MAX_OUTPUT,
151
+ windowsVerbatimArguments: p.windowsVerbatimArguments,
116
152
  });
117
- if (r.error) {
118
- // A stall is NOT the missing-binary case. Reporting it as one sent the last
119
- // reader hunting a PATH problem on a machine where the reviewer answered in 6s.
120
- if (r.error.code === 'ETIMEDOUT') {
121
- if (attempt < TIMEOUT_RETRIES)
122
- continue;
123
- return refuse([v(cmd, 'reviewer-timeout', `no response in ${timeout}ms after ${attempt + 1} attempts`, 'a reviewer that answers within the timeout — raise gates.reviewerTimeoutMs in witness.config.yaml if the model is simply slow')]);
124
- }
125
- return refuse([v(cmd, 'reviewer-invocation', String(r.error.message), `a runnable ${cmd} binary on PATH — gates invoke reviewers headlessly; witness check probes this`)]);
126
- }
127
- if (r.status !== 0) {
128
- return refuse([v(cmd, 'reviewer-invocation', `exit ${String(r.status)}: ${(r.stderr ?? '').slice(0, 200)}`, `${cmd} print mode exiting 0`)]);
129
- }
130
- return harness.reviewer.parseEnvelope(r.stdout);
153
+ const timedOut = r.error?.code === 'ETIMEDOUT';
154
+ const c = classifyAttempt(harness, p, attempt, {
155
+ timedOut, error: timedOut ? undefined : r.error, status: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '',
156
+ });
157
+ if (c !== 'retry')
158
+ return c;
131
159
  }
132
160
  }
161
+ // The battery's invoker: lenses run at once, so a reviewer call cannot block the process.
162
+ // Settles on `close`, bounded by `exit` plus a grace, for D145's reason: a reviewer CLI
163
+ // spawns its own children, they inherit stdout, and a killed parent whose grandchild
164
+ // survives would otherwise hold the pipe — and this call — open with no timeout above it.
165
+ export async function invokeReviewerAsync(ctx, harness, opts) {
166
+ const pR = prepareInvocation(ctx, harness, opts);
167
+ if (!pR.ok)
168
+ return pR;
169
+ const p = pR.value;
170
+ for (let attempt = 0;; attempt += 1) {
171
+ const r = await spawnAttempt(p, opts);
172
+ const c = classifyAttempt(harness, p, attempt, r);
173
+ if (c !== 'retry')
174
+ return c;
175
+ }
176
+ }
177
+ function spawnAttempt(p, opts) {
178
+ return new Promise((resolve) => {
179
+ const child = spawn(p.file, p.args, {
180
+ cwd: opts.cwd, env: p.env, stdio: ['pipe', 'pipe', 'pipe'],
181
+ windowsVerbatimArguments: p.windowsVerbatimArguments,
182
+ });
183
+ const out = [];
184
+ const err = [];
185
+ let outBytes = 0;
186
+ let epipe = false;
187
+ let timedOut = false;
188
+ let error;
189
+ let status = null;
190
+ let settled = false;
191
+ let killTimer;
192
+ let grace;
193
+ const settle = () => {
194
+ if (settled)
195
+ return;
196
+ settled = true;
197
+ clearTimeout(timer);
198
+ clearTimeout(killTimer);
199
+ clearTimeout(grace);
200
+ child.stdout.destroy();
201
+ child.stderr.destroy();
202
+ // Exit 0 after closing its stdin early means the reviewer answered without reading the
203
+ // whole prompt — a verdict on content it never saw, so it fails closed, as sync does.
204
+ if (epipe && status === 0 && !timedOut && error === undefined) {
205
+ error = new Error('exited 0 before reading the whole prompt (EPIPE)');
206
+ }
207
+ resolve({ timedOut, error, status, stdout: out.join(''), stderr: err.join('') });
208
+ };
209
+ const timer = setTimeout(() => {
210
+ timedOut = true;
211
+ child.kill('SIGTERM');
212
+ killTimer = setTimeout(() => child.kill('SIGKILL'), TERM_GRACE_MS);
213
+ }, Math.min(p.timeout, MAX_TIMER_MS));
214
+ child.stdout.setEncoding('utf8');
215
+ child.stderr.setEncoding('utf8');
216
+ child.stdout.on('data', (c) => {
217
+ outBytes += Buffer.byteLength(c);
218
+ if (outBytes > MAX_OUTPUT) {
219
+ if (error === undefined)
220
+ error = new Error('output exceeded 64 MiB');
221
+ child.kill('SIGKILL');
222
+ return;
223
+ }
224
+ out.push(c);
225
+ });
226
+ child.stderr.on('data', (c) => { err.push(c); });
227
+ // A reviewer that exits before reading its whole prompt closes the pipe under us: a
228
+ // nonzero exit is the answer, and exit 0 is refused at settle.
229
+ child.stdin.on('error', (e) => {
230
+ if (e.code === 'EPIPE')
231
+ epipe = true;
232
+ else if (error === undefined)
233
+ error = e;
234
+ });
235
+ child.stdin.end(opts.prompt);
236
+ child.on('error', (e) => { error = e; settle(); });
237
+ child.on('exit', (code) => {
238
+ status = code;
239
+ clearTimeout(killTimer);
240
+ grace = setTimeout(settle, EXIT_GRACE_MS);
241
+ });
242
+ child.on('close', (code) => { status = code; settle(); });
243
+ });
244
+ }
133
245
  export function parseVerdictText(text) {
134
246
  const fenced = /```(?:json)?\s*\n([\s\S]*?)\n\s*```/.exec(text);
135
247
  const body = (fenced ? fenced[1] ?? '' : text).trim();
@@ -7,7 +7,7 @@ import { effortWrites } from '../reviewed.js';
7
7
  import { canonicalSha } from '../sha.js';
8
8
  import { renderRefusal, v } from '../refusal.js';
9
9
  import { kv } from '../toon.js';
10
- import { executeAbandon, keptLine, planItems, specRevertFor } from '../abandon.js';
10
+ import { abandonLines, executeAbandon, planItems, returnCollisions, specRevertFor } from '../abandon.js';
11
11
  export async function run(ctx, argv) {
12
12
  const target = argv.find((a) => !a.startsWith('--'));
13
13
  if (!target) {
@@ -35,17 +35,18 @@ export async function run(ctx, argv) {
35
35
  renderRefusal(itemR.violations).forEach((l) => ctx.err(l));
36
36
  return EXIT.REFUSED;
37
37
  }
38
+ const taken = returnCollisions(root, canon, [itemR.value]);
39
+ if (taken.length > 0) {
40
+ renderRefusal(taken).forEach((l) => ctx.err(l));
41
+ return EXIT.REFUSED;
42
+ }
38
43
  const done = executeAbandon(root, ctx, [itemR.value], undefined, `abandon(${target})`);
39
44
  if (!done.ok) {
40
45
  renderRefusal(done.violations).forEach((l) => ctx.err(l));
41
46
  return EXIT.REFUSED;
42
47
  }
43
48
  ctx.out(kv('abandoned', target));
44
- if (itemR.value.revert) {
45
- ctx.out(kv('reverted', `${String(itemR.value.revert.doc.meta.id)} (${itemR.value.revert.action}) — journal untouched`));
46
- }
47
- if (itemR.value.kept)
48
- ctx.out(kv('kept', keptLine(itemR.value.kept)));
49
+ abandonLines([itemR.value], []).forEach((l) => ctx.out(l));
49
50
  return EXIT.OK;
50
51
  }
51
52
  if (streamExists(root, target) && latestRecap(root, target)) {
@@ -103,6 +104,7 @@ export async function run(ctx, argv) {
103
104
  else if (outcome)
104
105
  items.push({ revert: outcome });
105
106
  }
107
+ problems.push(...returnCollisions(root, canon, items));
106
108
  if (problems.length > 0) {
107
109
  renderRefusal(problems).forEach((l) => ctx.err(l));
108
110
  return EXIT.REFUSED;
@@ -112,9 +114,8 @@ export async function run(ctx, argv) {
112
114
  renderRefusal(done.violations).forEach((l) => ctx.err(l));
113
115
  return EXIT.REFUSED;
114
116
  }
115
- ctx.out(kv('abandoned', `effort ${target} — ${items.length} artifacts walked, journal remembers the attempt`));
116
- for (const k of kept)
117
- ctx.out(kv('kept', keptLine(k)));
117
+ ctx.out(kv('abandoned', `effort ${target} — ${items.length} artifacts walked · journal remembers the attempt`));
118
+ abandonLines(items, kept).forEach((l) => ctx.out(l));
118
119
  return EXIT.OK;
119
120
  }
120
121
  renderRefusal([v('target', 'unknown-target', target, 'a plan id or an effort slug')]).forEach((l) => ctx.err(l));
@@ -9,7 +9,7 @@ import { dirtyStatePaths, primaryRoot, stateCommit } from '../gitio.js';
9
9
  import { appendEntry, entryLine, journalRel } from '../journal.js';
10
10
  import { acquireLock } from '../lock.js';
11
11
  import { renderRefusal, v } from '../refusal.js';
12
- import { idRetired } from '../departure.js';
12
+ import { idRetired, pathRetired } from '../departure.js';
13
13
  import { findById, findCycle, loadCanon } from '../scan.js';
14
14
  import { validateDoc } from '../schemas.js';
15
15
  import { canonicalSha, short } from '../sha.js';
@@ -47,6 +47,11 @@ export async function run(ctx, argv) {
47
47
  const taken = idRetired(root, id, String(meta.type));
48
48
  if (taken)
49
49
  violations.push(taken);
50
+ // D167. Nor land on a path another id was last witnessed at — the same check `write` runs on a
51
+ // create. A doc witnessed where it sits passes on its own id, so any amendment stays adoptable.
52
+ const retired = pathRetired(root, canon, rel, id);
53
+ if (retired)
54
+ violations.push(retired);
50
55
  for (const dep of Array.isArray(meta.depends) ? meta.depends : []) {
51
56
  if (!findById(canon, dep))
52
57
  violations.push(v('depends', 'unknown-dep', dep, 'an existing doc id'));
@@ -4,7 +4,7 @@ import { parseArgs } from 'node:util';
4
4
  import { EXIT } from '../cli.js';
5
5
  import { loadConfig } from '../config.js';
6
6
  import { deferralMoves } from '../deferral.js';
7
- import { canonExit, idRetired, prepareDeparture } from '../departure.js';
7
+ import { canonExit, idRetired, pathRetired, prepareDeparture } from '../departure.js';
8
8
  import { designPending, designStamp } from '../design.js';
9
9
  import { ID_RE } from '../dsl.js';
10
10
  import { serializeDoc, splitDoc, writeDoc } from '../fm.js';
@@ -129,8 +129,9 @@ export async function run(ctx, argv) {
129
129
  renderRefusal(viol).forEach(ctx.err);
130
130
  return EXIT.REFUSED;
131
131
  };
132
- // D166. A create may not take an id that already names something (`idRetired` — the rule
133
- // `rename` applies to its target, with the one re-use of a spec its abandon unwound).
132
+ // D166. A create may not take an id that already names something (`idRetired`), with the one
133
+ // re-use of a spec its abandon unwound; a stray stream holds no id. `rename` is stricter on its
134
+ // target — no stream at all — because it moves its own stream onto that id.
134
135
  if (existing === undefined) {
135
136
  const taken = idRetired(root, id, String(manifest.type));
136
137
  if (taken)
@@ -213,6 +214,12 @@ export async function run(ctx, argv) {
213
214
  ? `a new id — ${rel} holds ${String(holder.meta.id)}`
214
215
  : `a new id — ${rel} holds a doc canon cannot read (fix or revert it first)`)]);
215
216
  }
217
+ // D167. Nor on the path a superseded spec comes back to (`pathRetired`).
218
+ if (existing === undefined) {
219
+ const retired = pathRetired(root, canon, rel, id);
220
+ if (retired)
221
+ return refuseWrite([retired]);
222
+ }
216
223
  const stream = journalRel(effort);
217
224
  const unrelated = dirtyStatePaths(root).filter((p) => p !== rel && p !== stream);
218
225
  if (unrelated.length) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@popovych.co/witness",
3
- "version": "0.14.7",
3
+ "version": "0.15.0",
4
4
  "description": "Evidence-gated spec-driven development pipeline for AI coding agents: brainstorm to PR in six stages, where specs are state, plans are motion, and no transition passes without evidence. Drives from Claude Code or Pi.",
5
5
  "keywords": [
6
6
  "spec-driven-development",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "witness",
3
3
  "description": "Spec-driven development with evidence gates: brainstorm → PR through six stages and five gates. Skills author, the pinned witness CLI writes state and witnesses evidence; gates stop where judgment is owed.",
4
- "version": "0.14.7",
4
+ "version": "0.15.0",
5
5
  "author": {
6
6
  "name": "Kostiantyn Popovych"
7
7
  },
@@ -10,7 +10,7 @@ The CLI decides, you act. If `$ARGUMENTS` contains `--manual`, append `--manual`
10
10
  Resolve the CLI once:
11
11
 
12
12
  ```bash
13
- WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.14.7}"
13
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.15.0}"
14
14
  ```
15
15
 
16
16
  If the repo has no `witness.config.yaml`, run `$WITNESS init` first (one bootstrap commit), then proceed. If init or any verb refuses on a dirty tree, the human commits or cleans it — **never `git commit` yourself, anywhere, at any stage**: the CLI makes its own state commits, and the ship phase makes the sole code commit.
@@ -3,4 +3,4 @@
3
3
  # context). Silent everywhere that isn't a witness repo; failures swallowed:
4
4
  # session start must never hang or die on this.
5
5
  [ -f witness.config.yaml ] || exit 0
6
- ${WITNESS_BIN:-npx -y @popovych.co/witness@0.14.7} 2>/dev/null || true
6
+ ${WITNESS_BIN:-npx -y @popovych.co/witness@0.15.0} 2>/dev/null || true
@@ -15,7 +15,7 @@ witness: ends in a confirmed scope recap, writes no document. See NOTICE.md. -->
15
15
  Resolve the CLI once per session:
16
16
 
17
17
  ```bash
18
- WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.14.7}"
18
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.15.0}"
19
19
  ```
20
20
 
21
21
  - **Render the CLI's decision output verbatim and in full — every line, unmodified.** Never print a command set you remember; never recompose, reformat, summarise or reorder what the CLI emitted. Which decisions are live, how they rank, and what each costs are the CLI's answers, and they change with the round, the bound, the repair grant and the content sha — a remembered set is wrong in more states than it is right.
@@ -10,7 +10,7 @@ description: Slice a witness effort's confirmed recap into vertical spec slices,
10
10
  Resolve the CLI once per session:
11
11
 
12
12
  ```bash
13
- WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.14.7}"
13
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.15.0}"
14
14
  ```
15
15
 
16
16
  - **Render the CLI's decision output verbatim and in full — every line, unmodified.** Never print a command set you remember; never recompose, reformat, summarise or reorder what the CLI emitted. Which decisions are live, how they rank, and what each costs are the CLI's answers, and they change with the round, the bound, the repair grant and the content sha — a remembered set is wrong in more states than it is right.
@@ -16,7 +16,7 @@ No text copied verbatim; the design-critic lens is new material. -->
16
16
  Resolve the CLI once per session:
17
17
 
18
18
  ```bash
19
- WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.14.7}"
19
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.15.0}"
20
20
  ```
21
21
 
22
22
  - **Render the CLI's decision output verbatim and in full — every line, unmodified.** Never print a command set you remember; never recompose, reformat, summarise or reorder what the CLI emitted. Which decisions are live, how they rank, and what each costs are the CLI's answers, and they change with the round, the bound, the repair grant and the content sha — a remembered set is wrong in more states than it is right.
@@ -15,7 +15,7 @@ tests carry the parent spec's tag in their NAME. See NOTICE.md. -->
15
15
  Resolve the CLI once per session:
16
16
 
17
17
  ```bash
18
- WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.14.7}"
18
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.15.0}"
19
19
  ```
20
20
 
21
21
  - **Render the CLI's decision output verbatim and in full — every line, unmodified.** Never print a command set you remember; never recompose, reformat, summarise or reorder what the CLI emitted. Which decisions are live, how they rank, and what each costs are the CLI's answers, and they change with the round, the bound, the repair grant and the content sha — a remembered set is wrong in more states than it is right.
@@ -15,7 +15,7 @@ parent's criteria. See NOTICE.md. -->
15
15
  Resolve the CLI once per session:
16
16
 
17
17
  ```bash
18
- WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.14.7}"
18
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.15.0}"
19
19
  ```
20
20
 
21
21
  - **Render the CLI's decision output verbatim and in full — every line, unmodified.** Never print a command set you remember; never recompose, reformat, summarise or reorder what the CLI emitted. Which decisions are live, how they rank, and what each costs are the CLI's answers, and they change with the round, the bound, the repair grant and the content sha — a remembered set is wrong in more states than it is right.
@@ -15,7 +15,7 @@ model can. See NOTICE.md. -->
15
15
  Resolve the CLI once per session:
16
16
 
17
17
  ```bash
18
- WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.14.7}"
18
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.15.0}"
19
19
  ```
20
20
 
21
21
  - **Render the CLI's decision output verbatim and in full — every line, unmodified.** Never print a command set you remember; never recompose, reformat, summarise or reorder what the CLI emitted. Which decisions are live, how they rank, and what each costs are the CLI's answers, and they change with the round, the bound, the repair grant and the content sha — a remembered set is wrong in more states than it is right.