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