@popovych.co/witness 0.8.0 → 0.9.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 CHANGED
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { configPath } from './config.js';
4
4
  import { primaryRoot } from './gitio.js';
5
+ import { renderRefusal, v } from './refusal.js';
5
6
  export const EXIT = { OK: 0, FINDINGS: 1, REFUSED: 2, BLOCKED: 3 };
6
7
  const VERBS = {
7
8
  recover: () => import('./verbs/recover.js'),
@@ -41,7 +42,7 @@ const VERB_USAGE = {
41
42
  calibrate: 'witness calibrate <exact-model-id> [--suite all|reviewers|skills] [--only <name>] [--samples <n>] [--publish]',
42
43
  check: 'witness check',
43
44
  clean: 'witness clean',
44
- decide: 'witness decide <gate> <target> --approve|--revise|--stop [--override] [--note <t>] [--upstream <artifact|effort>] [--pin <policy>]… [--show]',
45
+ decide: 'witness decide <gate> <target> --approve|--revise|--stop [--override] [--repair] [--note <t>] [--upstream <artifact|effort>] [--pin <policy>]… [--show]',
45
46
  design: 'witness design <spec-id> --file <html> | --reconfirm | --open',
46
47
  diff: 'witness diff <spec-id>',
47
48
  'dispatch-report': 'witness dispatch-report <plan-id> --steps-assigned <n> --steps-completed <n> [--tokens <n>] [--tool-uses <n>] [--duration-ms <n>]',
@@ -99,5 +100,21 @@ export async function main(ctx, argv) {
99
100
  ctx.err(usage());
100
101
  return EXIT.REFUSED;
101
102
  }
102
- return (await load()).run(ctx, rest);
103
+ try {
104
+ return await (await load()).run(ctx, rest);
105
+ }
106
+ catch (e) {
107
+ // Row 113. Eight verbs parse argv with node's strict `parseArgs`, which THROWS on a
108
+ // stray positional or a mistyped flag. bin.ts's crash net then rendered a typo as
109
+ // `unexpected-error … a bug — re-run with the same arguments and report this line`:
110
+ // the tool asked to be reported for a mistake it could answer with its own usage. This
111
+ // is the one throw class that is a user statement rather than a witness fault, and the
112
+ // usage line lives right here. Anything else still reaches bin.ts unchanged.
113
+ const code = e.code;
114
+ if (typeof code === 'string' && code.startsWith('ERR_PARSE_ARGS')) {
115
+ renderRefusal([v(verb, 'bad-arguments', String(e.message).split('.')[0].slice(0, 160), VERB_USAGE[verb] ?? `witness ${verb} --help`)]).forEach((l) => ctx.err(l));
116
+ return EXIT.REFUSED;
117
+ }
118
+ throw e;
119
+ }
103
120
  }
package/dist/gate.js CHANGED
@@ -14,7 +14,8 @@ import { kv, rows } from './toon.js';
14
14
  import { loadMatrix, resolveModel, SESSION_DEFAULT } from './model.js';
15
15
  import { docKeysFor, docsBlock, invokeReviewer, loadLensDocs, parseVerdictText, pinsBlock, promptsSha, resolvePrompt } from './reviewer.js';
16
16
  import { anchorMenu, parseVerdict, verdictViolations } from './verdict.js';
17
- import { ROUND_BOUND, appendKind, boundReached, fellBack, lastGateRun, roundsSinceApprove, runsSinceReset, } from './rounds.js';
17
+ import { ROUND_BOUND, appendKind, boundReached, fellBack, lastGateRun, liveExits, roundBudget, roundsSinceApprove, runsSinceReset, } from './rounds.js';
18
+ export { liveExits } from './rounds.js';
18
19
  import { gateSettled } from './verbs/next.js';
19
20
  import { prepareStamp, writeStamp } from './stamp.js';
20
21
  const GATES = new Map();
@@ -40,27 +41,16 @@ export function batteryFor(cfg, gate, cls) {
40
41
  }
41
42
  return ok(picked);
42
43
  }
43
- // Which decisions are legal RIGHT NOW is a pure function of journal state, so it belongs
44
- // where the state is. Skills used to recite a fixed triple, which is wrong at the bound
45
- // (D67's endgame set) — and now wrong in three more states.
46
- export function liveExits(gate, target, entries, stale) {
47
- // The bound outranks staleness: at the bound the gate short-circuits and will not run
48
- // again (:227), so "re-gate" is the D67 lie whatever the sha says. Stale content only
49
- // removes APPROVE from the endgame — a human cannot honestly stamp bytes no battery
50
- // read — which is the same set decide's stale-verdict refusal names.
51
- if (boundReached(entries, gate)) {
52
- return stale
53
- ? `witness decide ${gate} ${target} --revise --upstream <id> | --stop`
54
- : `witness decide ${gate} ${target} --approve --override | --revise --upstream <id> | --stop`;
55
- }
56
- if (stale)
57
- return `witness gate ${gate} ${target}`;
58
- return `witness decide ${gate} ${target} --approve | --revise --note "<why>" | --revise --upstream <id> | --stop`;
59
- }
60
- export function renderGateRun(ctx, entry, mode) {
44
+ // `entries` is the stream AS OF AFTER this entry: without it the renderer cannot tell the
45
+ // round that spends the budget from any other, and rows 109/110 are both about that round.
46
+ // `help: false` is for callers that render their own `exits:` line (`decide --show`).
47
+ export function renderGateRun(ctx, entry, mode, opts = {}) {
48
+ const entries = opts.entries ?? [];
49
+ const budget = opts.entries ? roundBudget(entries, entry.gate) : ROUND_BOUND;
50
+ const atBound = opts.entries !== undefined && boundReached(entries, entry.gate);
61
51
  ctx.out(kv('gate', entry.gate));
62
52
  ctx.out(kv('target', entry.artifact));
63
- ctx.out(kv('round', `${entry.round} of ${ROUND_BOUND}${mode === 'resume' ? ' (resume — content unchanged)' : ''}`));
53
+ ctx.out(kv('round', `${entry.round} of ${budget}${mode === 'resume' ? ' (resume — content unchanged)' : ''}`));
64
54
  ctx.out(kv('reviewed', entry.reviewed_sha.slice(0, 7)));
65
55
  ctx.out(kv('model', `${entry.model} · calibration: ${entry.calibration}${entry.cached ? ' · cached' : ''}`));
66
56
  if (entry.skipped?.length)
@@ -84,11 +74,20 @@ export function renderGateRun(ctx, entry, mode) {
84
74
  ctx.out(kv('standing-stop', entry.standing));
85
75
  ctx.out(kv('outcome', entry.outcome));
86
76
  if (entry.outcome !== 'passed') {
87
- // `[]` keeps this a pure formatter over the entry it was handed — callers that know
88
- // the stream (`--show`) render their own `exits:` line from the real entries. The
89
- // non-bound triple is correct for runGate's own post-run render: a run that reached
90
- // the bound short-circuits before rendering.
91
- ctx.out(`help: ${liveExits(entry.gate, entry.artifact, [], false)}`);
77
+ // Row 110. The round that SPENDS the budget is the one that has to say so. It rendered
78
+ // like any other, so the natural next act — fix the finding, re-gate — was advertised
79
+ // by the help line below and then refused twice: the gate short-circuits, and the edit
80
+ // that answered the finding forfeits `--approve` under D75. Nothing warned, and the
81
+ // finding is itself an instruction to edit. Printed above the `help:` suppression, not
82
+ // inside it: `decide --show` renders its own exits and is the surface a human reads
83
+ // while deciding — the one place this warning is most owed.
84
+ if (atBound) {
85
+ ctx.out(kv('last-round', 'the round budget is spent — the gate will not run again, and an edit from here forfeits --approve (the verdict would describe an older tree)'));
86
+ }
87
+ // Without `entries` this stays a pure formatter over the entry it was handed, and the
88
+ // off-bound triple is the right answer; with them it tells the truth at the bound too.
89
+ if (opts.help !== false)
90
+ ctx.out(`help: ${liveExits(entry.gate, entry.artifact, entries, false)}`);
92
91
  }
93
92
  }
94
93
  // Row 79: the human approving a plan sees the run's shape before it starts.
@@ -139,9 +138,9 @@ export async function runGate(ctx, gateName, target, flags) {
139
138
  renderRefusal(batteryR.violations).forEach((l) => ctx.err(l));
140
139
  return EXIT.REFUSED;
141
140
  }
142
- const skip = new Set(input.skipLenses ?? []);
141
+ const skip = new Map((input.skipLenses ?? []).map((s) => [s.lens, s.why]));
143
142
  const active = batteryR.value.filter((n) => !skip.has(n));
144
- const dropped = batteryR.value.filter((n) => skip.has(n));
143
+ const dropped = batteryR.value.filter((n) => skip.has(n)).map((n) => `${n} — ${skip.get(n)}`);
145
144
  const entries = readStream(root, target);
146
145
  const pins = policyPins(entries);
147
146
  const pinsText = pinsBlock(pins);
@@ -216,7 +215,24 @@ export async function runGate(ctx, gateName, target, flags) {
216
215
  .forEach((l) => ctx.err(l));
217
216
  return EXIT.REFUSED;
218
217
  }
219
- const kind = flags.fresh ? { kind: 'fresh' } : appendKind(entries, spec.gate, key);
218
+ let kind = flags.fresh ? { kind: 'fresh' } : appendKind(entries, spec.gate, key);
219
+ // Row 112. The cache key is the REVIEWED content digest, but the deterministic checks
220
+ // read inputs it cannot see — decompose's `goal-coverage`/`spec-coverage` read journaled
221
+ // `covers`, `amendment-ack` reads a sibling plan's pin, `graph` reads the whole canon. So
222
+ // correcting a goal mapping left the doc bytes untouched, the key unmoved, and `resume`
223
+ // served a blocking finding describing a state that no longer existed; `--fresh` was the
224
+ // only escape, and an operator who did not think of it spent a human decision on it.
225
+ // `resolve` has already recomputed the checks by this point — the fresh answer was in
226
+ // hand and thrown away. A flip is a new judgment, so it appends a real round rather than
227
+ // silently re-rendering; the verdicts are REPLAYED (`cached`), so no battery is spent.
228
+ // Compared on `ok` alone: a detail carries run output on some gates (timings, paths), and
229
+ // keying on it would append a round for every re-run of an unchanged tree.
230
+ const checkOk = (cs) => cs.map((c) => `${c.name}:${c.ok}`).sort().join('|');
231
+ if ((kind.kind === 'resume' || kind.kind === 'changed-nothing') &&
232
+ checkOk(kind.entry.checks) !== checkOk(input.checks) &&
233
+ kind.entry.outcome !== 'malformed' && (kind.entry.verdicts?.length ?? 0) > 0) {
234
+ kind = { kind: 'cached', from: kind.entry };
235
+ }
220
236
  // A key that moved for a NON-content reason — edited prompt, re-pinned model, new
221
237
  // witness version — un-settles just as quietly as --fresh did. Content moving explains
222
238
  // itself; this does not.
@@ -225,7 +241,7 @@ export async function runGate(ctx, gateName, target, flags) {
225
241
  ctx.err(`warning: reviewer setup changed — this run discards the settled approve on ${spec.gate} ${target}`);
226
242
  }
227
243
  if (kind.kind === 'resume') {
228
- renderGateRun(ctx, kind.entry, 'resume');
244
+ renderGateRun(ctx, kind.entry, 'resume', { entries });
229
245
  printDispatchArithmetic(ctx, root, spec.gate, target);
230
246
  return kind.entry.outcome === 'passed' ? EXIT.OK : EXIT.FINDINGS;
231
247
  }
@@ -428,7 +444,7 @@ export async function runGate(ctx, gateName, target, flags) {
428
444
  renderRefusal(txn.violations).forEach((l) => ctx.err(l));
429
445
  return EXIT.REFUSED;
430
446
  }
431
- renderGateRun(ctx, entry, 'ran');
447
+ renderGateRun(ctx, entry, 'ran', { entries: [...entriesNow, entry] });
432
448
  printDispatchArithmetic(ctx, root, spec.gate, target);
433
449
  if (input.repin)
434
450
  ctx.out(kv('re-pinned', `derives-from → ${input.repin.sha.slice(0, 7)} (witnessed by the drift lane)`));
@@ -104,7 +104,7 @@ registerGate({
104
104
  }
105
105
  else {
106
106
  // not UI-work: drop the lens if a battery names it (stages shrink, never skip)
107
- skipLenses.push('design-reviewer');
107
+ skipLenses.push({ lens: 'design-reviewer', why: 'no design-from pin: not UI work' });
108
108
  }
109
109
  const checks = [];
110
110
  checks.push({ name: 'diff', ok: files.length > 0,
package/dist/rounds.js CHANGED
@@ -66,8 +66,51 @@ export function roundsSinceApprove(entries, gate) {
66
66
  return runsSinceReset(entries, gate)
67
67
  .filter((r) => r.outcome !== 'malformed' && !fellBack(r)).length;
68
68
  }
69
+ // Row 109. The final round's blocking findings can only be VERIFIED by another round, so
70
+ // fixing one at the bound forfeits `--approve` (D75 puts staleness at consumption) and the
71
+ // only exits left are a full upstream re-cycle or a stop. A repair grant buys exactly one
72
+ // more round. It is read from the journal window rather than counted anywhere: the window
73
+ // closes on every reset `lastResetIndex` already knows about, so an approve, a
74
+ // revise-upstream or a passed run refreshes the grant for the next game and nothing else
75
+ // does. A second grant inside one window is therefore a no-op by construction, not by a
76
+ // rule someone has to remember to write.
77
+ export function repairGranted(entries, gate) {
78
+ return entries.slice(lastResetIndex(entries, gate) + 1).some((e) => isDecision(e, gate) && e.repair === true);
79
+ }
80
+ export function roundBudget(entries, gate) {
81
+ return ROUND_BOUND + (repairGranted(entries, gate) ? 1 : 0);
82
+ }
69
83
  export function boundReached(entries, gate) {
70
- return roundsSinceApprove(entries, gate) >= ROUND_BOUND;
84
+ return roundsSinceApprove(entries, gate) >= roundBudget(entries, gate);
85
+ }
86
+ // Which decisions are legal RIGHT NOW is a pure function of journal state, so it lives
87
+ // where the state is — it was in `gate.ts`, which `next.ts` cannot import back (gate.ts
88
+ // already imports next.ts), and that one-way edge is precisely why `next` grew three
89
+ // hand-copied bound triples that then went stale twice over: they miss the repair grant
90
+ // (109), and a fixed set is what this function was written to abolish. Skills used to
91
+ // recite the same triple, which is wrong at the bound (D67's endgame set) and now wrong
92
+ // in three more states.
93
+ // `upstream` is the id the caller already resolved (next knows the owning effort; the gate
94
+ // and decide surfaces do not) — the placeholder is a fallback, never a downgrade of a line
95
+ // that used to name the real target.
96
+ export function liveExits(gate, target, entries, stale, upstream = '<id>') {
97
+ // The bound outranks staleness: at the bound the gate short-circuits and will not run
98
+ // again (gate.ts), so "re-gate" is the D67 lie whatever the sha says. Stale content only
99
+ // removes APPROVE from the endgame — a human cannot honestly stamp bytes no battery
100
+ // read — which is the same set decide's stale-verdict refusal names.
101
+ if (boundReached(entries, gate)) {
102
+ // Row 109: the repair grant is an exit exactly while it is unspent, and it is the one
103
+ // the human standing at the bound with a fixed finding actually wants — naming it
104
+ // beside `--upstream` is what stops "fix it and re-gate" from being a move the tool
105
+ // advertises nowhere and refuses twice.
106
+ const repair = repairGranted(entries, gate) ? '' : ' | --revise --repair';
107
+ return stale
108
+ ? `witness decide ${gate} ${target} --revise --upstream ${upstream}${repair} | --stop`
109
+ : `witness decide ${gate} ${target} --approve --override | --revise --upstream ${upstream}${repair} | --stop`;
110
+ }
111
+ if (stale)
112
+ return `witness gate ${gate} ${target}`;
113
+ return `witness decide ${gate} ${target} --approve | --revise --note "<why>" | --revise --upstream ${upstream} | --stop`;
71
114
  }
72
115
  export function pendingDecision(entries, gate) {
73
116
  for (let i = entries.length - 1; i >= 0; i--) {
@@ -15,7 +15,7 @@ import { newRunId } from '../drift.js';
15
15
  import { renderRefusal, v } from '../refusal.js';
16
16
  import { short } from '../sha.js';
17
17
  import { kv, rows } from '../toon.js';
18
- import { boundReached, lastGateRun, openReopen, pendingDecision, roundsSinceApprove, } from '../rounds.js';
18
+ import { ROUND_BOUND, boundReached, lastGateRun, openReopen, pendingDecision, repairGranted, roundsSinceApprove, } from '../rounds.js';
19
19
  import { prepareStamp, writeStamp } from '../stamp.js';
20
20
  const asEntry = (e) => e;
21
21
  function flagValue(argv, flag) {
@@ -47,7 +47,7 @@ export async function run(ctx, argv) {
47
47
  const [gate, target] = positional;
48
48
  const spec = gate ? gateSpec(gate) : undefined;
49
49
  if (!gate || !target || !spec) {
50
- ctx.err('usage: witness decide <gate> <target> --approve|--revise|--stop [--override] [--note <t>] [--upstream <artifact|effort>] [--pin <policy>]… [--show]');
50
+ ctx.err('usage: witness decide <gate> <target> --approve|--revise|--stop [--override] [--repair] [--note <t>] [--upstream <artifact|effort>] [--pin <policy>]… [--show]');
51
51
  return EXIT.REFUSED;
52
52
  }
53
53
  const rootR = primaryRoot(ctx.cwd);
@@ -113,7 +113,7 @@ export async function run(ctx, argv) {
113
113
  return EXIT.OK;
114
114
  }
115
115
  // pending (no disposition) or revise (the author's input): the verdict is actionable
116
- renderGateRun(ctx, last, 'ran');
116
+ renderGateRun(ctx, last, 'ran', { entries, help: false });
117
117
  const shownPins = policyPins(entries);
118
118
  if (shownPins.length > 0)
119
119
  ctx.out(rows('pins', ['ordinal', 'text'], shownPins).join('\n'));
@@ -134,6 +134,7 @@ export async function run(ctx, argv) {
134
134
  const decision = picked[0].slice(2);
135
135
  const note = flagValue(argv, '--note');
136
136
  const override = argv.includes('--override');
137
+ const repair = argv.includes('--repair');
137
138
  const upstream = flagValue(argv, '--upstream');
138
139
  const pinTexts = flagValues(argv, '--pin');
139
140
  if (pinTexts.length > 0 && gate !== 'implement') {
@@ -146,11 +147,30 @@ export async function run(ctx, argv) {
146
147
  return EXIT.REFUSED;
147
148
  }
148
149
  const atBound = boundReached(entries, gate);
150
+ // Row 109. The grant is a REVISE — the human is sending the artifact back, they are just
151
+ // paying for the round that verifies the fix. Refusing the other spellings is not
152
+ // pedantry: `--approve --repair` reads as "approve and let it re-run", which is not a
153
+ // state this machine has, and silently ignoring the flag would journal an approve the
154
+ // human believed was something else.
155
+ if (repair && decision !== 'revise') {
156
+ renderRefusal([v('repair', 'repair-scope', `--${decision}`, 'witness decide … --revise --repair — a repair grant sends the artifact back for one verified round')])
157
+ .forEach((l) => ctx.err(l));
158
+ return EXIT.REFUSED;
159
+ }
160
+ if (repair && !atBound) {
161
+ renderRefusal([v('repair', 'repair-not-at-bound', `${roundsSinceApprove(entries, gate)} of ${ROUND_BOUND} rounds spent`, `the round bound — below it a plain revise already re-gates: witness decide ${gate} ${target} --revise --note "<why>"`)])
162
+ .forEach((l) => ctx.err(l));
163
+ return EXIT.REFUSED;
164
+ }
165
+ if (repair && repairGranted(entries, gate)) {
166
+ renderRefusal([v('repair', 'repair-spent', 'one repair round was already granted in this budget window', liveExits(gate, target, entries, false))]).forEach((l) => ctx.err(l));
167
+ return EXIT.REFUSED;
168
+ }
149
169
  // at the bound the gate refuses to run again, so no fresh pending decision can
150
170
  // ever exist — the endgame decisions must stay reachable anchored to the last
151
171
  // run, or the target livelocks (incident c2692b93)
152
172
  const boundEndgame = atBound && (decision === 'stop' || (decision === 'approve' && override) ||
153
- (decision === 'revise' && upstream !== undefined));
173
+ (decision === 'revise' && (upstream !== undefined || repair)));
154
174
  // D94: a revise is the author's INPUT, not a disposition — it leaves the run
155
175
  // undisposed, and with the content unchanged `gate` answers `changed-nothing` and
156
176
  // appends nothing, so a pending decision can never reappear. Every exit the human is
@@ -186,44 +206,50 @@ export async function run(ctx, argv) {
186
206
  const blockedCode = guardTxn(ctx, root);
187
207
  if (blockedCode !== undefined)
188
208
  return blockedCode;
189
- if (decision === 'revise' && atBound && upstream === undefined) {
190
- return renderBound(ctx, gate, target, entries, last !== undefined && nowSha !== undefined && nowSha !== last.reviewed_sha, 'upstream reopens the parent and resets the budget');
191
- }
192
- if (decision === 'approve' && atBound && !override) {
193
- renderRefusal([v('decision', 'override-required', 'approve at the round bound', 'witness decide … --approve --override')]).forEach((l) => ctx.err(l));
194
- return EXIT.REFUSED;
209
+ if (decision === 'revise' && atBound && upstream === undefined && !repair) {
210
+ return renderBound(ctx, gate, target, entries, last !== undefined && nowSha !== undefined && nowSha !== last.reviewed_sha, 'upstream reopens the parent and resets the budget; --repair buys one more round here');
195
211
  }
196
- // A standing stop is only human judgment if the human was shown the thing. The gate
197
- // refuses the same way, but the gate does not run at the round bound (gate.ts:176) —
198
- // and approve is the act that stamps, so the line has to hold here too. Sha-keyed:
199
- // re-authoring invalidates prior sight, as re-capturing invalidates a witnessed
200
- // screenshot (D71). Only approve — revise and stop need no sight to be honest.
201
- if (gate === 'design' && decision === 'approve') {
202
- const unseen = designUnseen(root, cfgR.value.paths, target);
203
- if (unseen !== undefined) {
204
- renderRefusal([v('design', 'design-unseen', `no sight witnessed for ${short(unseen)}`, `a human shown this artifact — run: witness design ${target} --open`)]).forEach((l) => ctx.err(l));
205
- return EXIT.REFUSED;
206
- }
207
- }
208
- // A `human-decision` entry is a RECORD — honest about the sha it judged whatever the
209
- // tree does afterward, which is why D76 declined a write-time staleness check. A stamp
210
- // is an ASSERTION about current content (`status: approved`, `design: {sha}`), and D75
211
- // puts staleness checks at consumption. approveMeta reads the artifact from disk and
212
- // never consults the run, so approving after a re-author blessed unreviewed bytes.
213
- // `currentSha` is undefined when it cannot be computed (no worktree, missing parent):
214
- // approve then proceeds, and the entry still honestly records what it judged — this
215
- // check must never convert an unrelated condition into a misleading refusal.
212
+ // Row 111. Every reason an approve cannot happen, in ONE refusal. These used to fire
213
+ // sequentially: `--approve` reported `override-required`, and only the re-run carrying
214
+ // `--override` reported the `stale-verdict` that was the actual blocker — the operator
215
+ // learned the real state one refusal later than the tool knew it, and at the bound that
216
+ // costs a round of thinking on an exit that was never available.
216
217
  if (decision === 'approve') {
218
+ const blockers = [];
219
+ if (atBound && !override) {
220
+ blockers.push(v('decision', 'override-required', 'approve at the round bound', 'witness decide … --approve --override'));
221
+ }
222
+ // A standing stop is only human judgment if the human was shown the thing. The gate
223
+ // refuses the same way, but the gate does not run at the round bound (gate.ts:176) —
224
+ // and approve is the act that stamps, so the line has to hold here too. Sha-keyed:
225
+ // re-authoring invalidates prior sight, as re-capturing invalidates a witnessed
226
+ // screenshot (D71). Only approve — revise and stop need no sight to be honest.
227
+ if (gate === 'design') {
228
+ const unseen = designUnseen(root, cfgR.value.paths, target);
229
+ if (unseen !== undefined) {
230
+ blockers.push(v('design', 'design-unseen', `no sight witnessed for ${short(unseen)}`, `a human shown this artifact — run: witness design ${target} --open`));
231
+ }
232
+ }
233
+ // A `human-decision` entry is a RECORD — honest about the sha it judged whatever the
234
+ // tree does afterward, which is why D76 declined a write-time staleness check. A stamp
235
+ // is an ASSERTION about current content (`status: approved`, `design: {sha}`), and D75
236
+ // puts staleness checks at consumption. approveMeta reads the artifact from disk and
237
+ // never consults the run, so approving after a re-author blessed unreviewed bytes.
238
+ // `currentSha` is undefined when it cannot be computed (no worktree, missing parent):
239
+ // approve then proceeds, and the entry still honestly records what it judged — this
240
+ // check must never convert an unrelated condition into a misleading refusal.
217
241
  const now = spec.currentSha?.(root, canon, cfgR.value, target);
218
242
  if (now !== undefined && now !== anchor.reviewed_sha) {
219
243
  // At the bound the gate will not re-run (gate.ts:176), so "go re-gate" would be
220
- // the lie D67 was written about. Name the exits that actually work: approve is
221
- // genuinely unavailable here — a human cannot honestly stamp bytes no gate read —
222
- // but --stop and --revise --upstream both remain, so nothing livelocks.
223
- renderRefusal([v('gate', 'stale-verdict', `verdict @${short(anchor.reviewed_sha)}, content @${short(now)}`, atBound
224
- ? `witness decide ${gate} ${target} --revise --upstream <id> | --stop (bound reached — the gate will not re-run)`
225
- : `a verdict describing current content — run: witness gate ${gate} ${target}`)])
226
- .forEach((l) => ctx.err(l));
244
+ // the lie D67 was written about. `liveExits` names the ones that actually work —
245
+ // including the repair grant while it is unspent, which is the exit an operator who
246
+ // just fixed the finding is looking for.
247
+ blockers.push(v('gate', 'stale-verdict', `verdict @${short(anchor.reviewed_sha)}, content @${short(now)}`, atBound
248
+ ? `${liveExits(gate, target, entries, true)} (bound reached — the gate will not re-run as it stands)`
249
+ : `a verdict describing current content — run: witness gate ${gate} ${target}`));
250
+ }
251
+ if (blockers.length > 0) {
252
+ renderRefusal(blockers).forEach((l) => ctx.err(l));
227
253
  return EXIT.REFUSED;
228
254
  }
229
255
  }
@@ -231,6 +257,7 @@ export async function run(ctx, argv) {
231
257
  v: 1, t: 'human-decision', gate, artifact: target, round: anchor.round,
232
258
  decision: decision === 'revise' && upstream ? 'revise-upstream' : decision,
233
259
  ...(override ? { override: true } : {}),
260
+ ...(repair ? { repair: true } : {}),
234
261
  ...(note ? { note } : {}),
235
262
  };
236
263
  const priorPins = entries.filter((e) => e.t === 'policy-pin').length;
@@ -364,9 +391,15 @@ export async function run(ctx, argv) {
364
391
  ctx.out(kv('decided', `${gate} ${target} → ${entry.decision}${override ? ' (override)' : ''}`));
365
392
  for (const p of pinEntries)
366
393
  ctx.out(kv('pinned', `#${p.ordinal} ${p.text}`));
394
+ if (repair) {
395
+ // What was bought, and that it does not come again — a grant the human forgets is
396
+ // spent is a second walk into the same wall.
397
+ ctx.out(kv('repair', `one extra round granted — round ${ROUND_BOUND + 1} of ${ROUND_BOUND + 1} is the last, and the grant does not refresh until an approve, a revise-upstream or a passed run`));
398
+ ctx.out(`help: fix the finding, then re-run: witness gate ${gate} ${target}`);
399
+ }
367
400
  if (entry.decision === 'revise' || entry.decision === 'revise-upstream') {
368
401
  ctx.out('revise-context: (reconstructed from the journal — survives session death)');
369
- renderGateRun(ctx, anchor, 'ran');
402
+ renderGateRun(ctx, anchor, 'ran', { entries, help: false });
370
403
  const pins = policyPins(readStream(root, target));
371
404
  if (pins.length > 0)
372
405
  ctx.out(rows('pins', ['ordinal', 'text'], pins).join('\n'));
@@ -15,7 +15,7 @@ import { worktreeFlow, worktreePath } from '../worktree.js';
15
15
  import { lazyStamp } from '../stamp.js';
16
16
  import { ok, refuse, renderRefusal, v } from '../refusal.js';
17
17
  import { kv, rows } from '../toon.js';
18
- import { ROUND_BOUND, boundReached, lastGateRun, openReopen, pendingDecision } from '../rounds.js';
18
+ import { ROUND_BOUND, boundReached, lastGateRun, liveExits, openReopen, pendingDecision } from '../rounds.js';
19
19
  // A gate is settled only if the verdict that settled it still describes the CURRENT
20
20
  // content. `reviewed_sha` is the tree we actually judged; when the caller can compute
21
21
  // what that sha is now and the two differ, the approval has lapsed and the gate re-arms.
@@ -341,7 +341,7 @@ export function computeNext(root, ctx, canon, cfg) {
341
341
  for (const e of efforts) {
342
342
  if (boundReached(e.entries, 'decompose') && !gateSettled(e.entries, 'decompose')) {
343
343
  return {
344
- line: `witness decide decompose ${e.slug} --approve --override | --revise --upstream ${e.slug} | --stop`,
344
+ line: liveExits('decompose', e.slug, e.entries, false, e.slug),
345
345
  target: e.slug, note: 'round bound reached — human decision required',
346
346
  };
347
347
  }
@@ -353,7 +353,7 @@ export function computeNext(root, ctx, canon, cfg) {
353
353
  if (boundReached(entries, gate) && !gateSettled(entries, gate)) {
354
354
  const up = gate === 'plan' ? String(plan.meta.parent) : id;
355
355
  return {
356
- line: `witness decide ${gate} ${id} --approve --override | --revise --upstream ${up} | --stop`,
356
+ line: liveExits(gate, id, entries, false, up),
357
357
  target: id, note: 'round bound reached — human decision required',
358
358
  };
359
359
  }
@@ -365,7 +365,7 @@ export function computeNext(root, ctx, canon, cfg) {
365
365
  if (boundReached(entries, 'design') && !gateSettled(entries, 'design')) {
366
366
  const eff = effortOf(root, id);
367
367
  return {
368
- line: `witness decide design ${id} --approve --override | --revise --upstream ${eff ?? '<effort>'} | --stop`,
368
+ line: liveExits('design', id, entries, false, eff ?? '<effort>'),
369
369
  target: id, note: 'round bound reached — human decision required',
370
370
  };
371
371
  }
@@ -14,6 +14,22 @@ export async function run(ctx, _argv) {
14
14
  const blocked = guardTxn(ctx, root);
15
15
  if (blocked !== undefined)
16
16
  return blocked;
17
+ // Row 114. `git pull --rebase` refuses on ANY unstaged TRACKED change, and every
18
+ // non-conflict failure below rendered as `rebase conflict — resolve manually`, which
19
+ // names neither the real condition nor a single file. A field report read that line as a
20
+ // structural deadlock ("ship owns the sole code commit, so the work is always unstaged")
21
+ // — sync runs in the primary root and never in a worktree, and the actual cause was one
22
+ // hand-edited tracked file. Untracked paths are deliberately not counted: git rebases
23
+ // around them, so refusing on them would invent a block that does not exist.
24
+ const dirty = tryGit(root, 'status', '--porcelain');
25
+ const tracked = dirty.ok
26
+ ? dirty.out.split('\n').filter((l) => l.trim() !== '' && !l.startsWith('??'))
27
+ : [];
28
+ if (tracked.length > 0) {
29
+ renderRefusal([v('worktree', 'worktree-dirty', tracked.map((l) => l.slice(3)).join(' · ').slice(0, 200), 'a clean tree — commit or stash the tracked edits, then witness sync (a rebase cannot run over them)')])
30
+ .forEach((l) => ctx.err(l));
31
+ return EXIT.REFUSED;
32
+ }
17
33
  const lockR = acquireLock(root);
18
34
  if (!lockR.ok) {
19
35
  renderRefusal(lockR.violations).forEach((l) => ctx.err(l));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@popovych.co/witness",
3
- "version": "0.8.0",
3
+ "version": "0.9.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.8.0",
4
+ "version": "0.9.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.8.0}"
13
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.9.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.8.0} 2>/dev/null || true
6
+ ${WITNESS_BIN:-npx -y @popovych.co/witness@0.9.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.8.0}"
18
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.9.0}"
19
19
  ```
20
20
 
21
21
  - **Never edit `specs/**` or `plans/**`** (the canon dirs — `paths:` in witness.config.yaml may relocate them) — not with an edit tool, not with a write tool, not with Bash redirection. The CLI is the sole writer of state; you author in scratch files under `$(mktemp -d)` and hand them to the CLI. (The canon guard blocks you; the trailer audit catches what it can't.)
@@ -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.8.0}"
13
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.9.0}"
14
14
  ```
15
15
 
16
16
  - **Never edit `specs/**` or `plans/**`** (the canon dirs — `paths:` in witness.config.yaml may relocate them) — not with an edit tool, not with a write tool, not with Bash redirection. The CLI is the sole writer of state; you author in scratch files under `$(mktemp -d)` and hand them to the CLI. (The canon guard blocks you; the trailer audit catches what it can't.)
@@ -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.8.0}"
19
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.9.0}"
20
20
  ```
21
21
 
22
22
  - **Never edit `specs/**`, `plans/**`, or `designs/**`** — the CLI is the sole writer of state. Author HTML in `$(mktemp -d)` and hand it to `witness design`. (The canon guard blocks direct edits; the trailer audit catches end-runs.)
@@ -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.8.0}"
18
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.9.0}"
19
19
  ```
20
20
 
21
21
  - **Never edit `specs/**` or `plans/**`** (the canon dirs — `paths:` in witness.config.yaml may relocate them) — not with an edit tool, not with a write tool, not with Bash redirection. The CLI is the sole writer of state; you author in scratch files under `$(mktemp -d)` and hand them to the CLI. (The canon guard blocks you; the trailer audit catches what it can't.)
@@ -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.8.0}"
18
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.9.0}"
19
19
  ```
20
20
 
21
21
  - **Never edit `specs/**` or `plans/**`** (the canon dirs — `paths:` in witness.config.yaml may relocate them) — not with an edit tool, not with a write tool, not with Bash redirection. The CLI is the sole writer of state; you author in scratch files under `$(mktemp -d)` and hand them to the CLI. (The canon guard blocks you; the trailer audit catches what it can't.)
@@ -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.8.0}"
18
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.9.0}"
19
19
  ```
20
20
 
21
21
  - **Never edit `specs/**` or `plans/**`** (the canon dirs — `paths:` in witness.config.yaml may relocate them) — not with an edit tool, not with a write tool, not with Bash redirection. The CLI is the sole writer of state; you author in scratch files under `$(mktemp -d)` and hand them to the CLI. (The canon guard blocks you; the trailer audit catches what it can't.)