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