@popovych.co/witness 0.14.8 → 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.
@@ -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/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;
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/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();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@popovych.co/witness",
3
- "version": "0.14.8",
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.8",
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.8}"
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.8} 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.8}"
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.8}"
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.8}"
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.8}"
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.8}"
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.8}"
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.