@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.
- package/dist/battery.js +68 -0
- package/dist/deferral.js +19 -6
- package/dist/gate.js +20 -44
- package/dist/recommend.js +37 -2
- package/dist/reviewer.js +135 -23
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/commands/witness.md +1 -1
- package/plugin/hooks/session-dashboard.sh +1 -1
- package/plugin/skills/witness-brainstorm/SKILL.md +1 -1
- package/plugin/skills/witness-decompose/SKILL.md +1 -1
- package/plugin/skills/witness-design/SKILL.md +1 -1
- package/plugin/skills/witness-implement/SKILL.md +1 -1
- package/plugin/skills/witness-plan/SKILL.md +1 -1
- package/plugin/skills/witness-ship/SKILL.md +1 -1
package/dist/battery.js
ADDED
|
@@ -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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
17
|
-
import { docKeysFor, docsBlock,
|
|
18
|
-
import {
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, {
|
|
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
|
-
|
|
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(
|
|
144
|
+
const r = spawnSync(p.file, p.args, {
|
|
109
145
|
cwd: opts.cwd,
|
|
110
|
-
env:
|
|
146
|
+
env: p.env,
|
|
111
147
|
input: opts.prompt,
|
|
112
148
|
encoding: 'utf8',
|
|
113
|
-
timeout,
|
|
114
|
-
maxBuffer:
|
|
115
|
-
windowsVerbatimArguments:
|
|
149
|
+
timeout: p.timeout,
|
|
150
|
+
maxBuffer: MAX_OUTPUT,
|
|
151
|
+
windowsVerbatimArguments: p.windowsVerbatimArguments,
|
|
116
152
|
});
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|