@popovych.co/witness 0.9.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/README.md CHANGED
@@ -117,6 +117,21 @@ witness commits the payload. It refuses the whole run if a payload path carries
117
117
  uncommitted change (`payload-dirty`) or if the CLI you are running is older than the
118
118
  payload already installed (`cli-behind-payload`).
119
119
 
120
+ From **0.10.0** onward `init` installs into **every home of the repository** — the primary
121
+ root and each live `.witness/worktrees/<plan-id>` — because a worktree is a branch
122
+ checkout, so its payload is a different file on a different timeline. A worktree cut
123
+ before an upgrade used to keep the old pin forever, and the old pin is what selects the
124
+ CLI that runs there. Every home is preflighted before any is written, so a dirty payload
125
+ in one refuses the whole run rather than leaving the set half-upgraded, and `witness check`
126
+ now reports `payload-stale` per home and names the stale one.
127
+
128
+ The same release makes the bound structural: every journal entry records the CLI that
129
+ wrote it, and a CLI older than anything the repository's state has seen refuses every verb
130
+ with `cli-behind-state` rather than answering from rules the repository has moved past.
131
+ `witness floor --show` reports the bound. If you are deliberately rolling back — a bad
132
+ release, say — lower it first with `witness floor --set <version> --note <why>`; the
133
+ decision is journaled, and `floor` is the one verb that still runs under a bound it fails.
134
+
120
135
  ### Support tiers
121
136
 
122
137
  | Tier | Agents | What they get |
package/dist/cli.js CHANGED
@@ -1,8 +1,9 @@
1
- import { existsSync, readFileSync } from 'node:fs';
2
- import { join } from 'node:path';
1
+ import { existsSync } from 'node:fs';
3
2
  import { configPath } from './config.js';
3
+ import { stateFloor } from './floor.js';
4
4
  import { primaryRoot } from './gitio.js';
5
5
  import { renderRefusal, v } from './refusal.js';
6
+ import { NPX_LATEST, compareTriple, version } from './version.js';
6
7
  export const EXIT = { OK: 0, FINDINGS: 1, REFUSED: 2, BLOCKED: 3 };
7
8
  const VERBS = {
8
9
  recover: () => import('./verbs/recover.js'),
@@ -13,6 +14,7 @@ const VERBS = {
13
14
  check: () => import('./verbs/check.js'),
14
15
  index: () => import('./verbs/index-verb.js'),
15
16
  satisfy: () => import('./verbs/satisfy.js'),
17
+ dismiss: () => import('./verbs/dismiss.js'),
16
18
  log: () => import('./verbs/log.js'),
17
19
  'test-evidence': () => import('./verbs/evidence.js'),
18
20
  'verify-red': () => import('./verbs/evidence.js').then((m) => ({ run: m.runVerifyRed })),
@@ -33,6 +35,9 @@ const VERBS = {
33
35
  rename: () => import('./verbs/rename.js'),
34
36
  sync: () => import('./verbs/sync.js'),
35
37
  calibrate: () => import('./verbs/calibrate.js'),
38
+ // Row 116's valve. Exempt from the floor gate in `main`, because a repository that
39
+ // refuses every verb must still be able to run the one that unrefuses it.
40
+ floor: () => import('./verbs/floor.js'),
36
41
  };
37
42
  // answered centrally so no verb can crash on --help (several parse argv
38
43
  // strictly); each line mirrors the verb's own usage refusal where one exists
@@ -43,6 +48,7 @@ const VERB_USAGE = {
43
48
  check: 'witness check',
44
49
  clean: 'witness clean',
45
50
  decide: 'witness decide <gate> <target> --approve|--revise|--stop [--override] [--repair] [--note <t>] [--upstream <artifact|effort>] [--pin <policy>]… [--show]',
51
+ floor: 'witness floor --show | --set <triple> --note <why>',
46
52
  design: 'witness design <spec-id> --file <html> | --reconfirm | --open',
47
53
  diff: 'witness diff <spec-id>',
48
54
  'dispatch-report': 'witness dispatch-report <plan-id> --steps-assigned <n> --steps-completed <n> [--tokens <n>] [--tool-uses <n>] [--duration-ms <n>]',
@@ -55,6 +61,7 @@ const VERB_USAGE = {
55
61
  recover: 'witness recover [--complete | --rollback]',
56
62
  rename: 'witness rename <old-id> <new-id>',
57
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>"',
58
65
  ship: 'witness ship <plan-id>',
59
66
  start: 'witness start <plan-id>',
60
67
  status: 'witness status — flows · blocked docs · reconcile rows · pending gates (bare `witness` is the same screen)',
@@ -62,10 +69,10 @@ const VERB_USAGE = {
62
69
  'test-evidence': 'witness test-evidence <plan-id> --phase red|green',
63
70
  'verify-red': 'witness verify-red <plan-id> [--base <ref>]',
64
71
  };
65
- export function version() {
66
- const pkg = JSON.parse(readFileSync(join(new URL('.', import.meta.url).pathname, '..', 'package.json'), 'utf8'));
67
- return pkg.version;
68
- }
72
+ // Row 116 moved the body to version.ts, which journal.ts can import without closing a
73
+ // cycle. Re-exported here because eleven call sites already ask the CLI shell for it and
74
+ // the question they are asking has not changed.
75
+ export { version };
69
76
  export function usage() {
70
77
  return [
71
78
  'witness — spec-led pipeline: specs are state, plans are motion',
@@ -80,6 +87,38 @@ export async function main(ctx, argv) {
80
87
  ctx.out(version());
81
88
  return EXIT.OK;
82
89
  }
90
+ // Row 116. Above the verb table on purpose — including above the bare-`witness`
91
+ // dashboard route, which reads the same state every verb does. The invariant is "one
92
+ // repository, one witness version", and a per-verb opt-in is a list some future verb
93
+ // forgets to join.
94
+ //
95
+ // This is the guard row 102's `cli-behind-payload` structurally could not be. That one
96
+ // fires inside the home it protects, so a home frozen at an old pin runs an old CLI that
97
+ // never learned to check — which is how two sessions a lifecycle apart each computed a
98
+ // different next stage, each redirected to the other's home, and each honestly ended its
99
+ // turn. The state is the one thing every home shares, so the state is where the bound
100
+ // belongs.
101
+ //
102
+ // Forward-only, and the text must never imply otherwise: a CLI published before this row
103
+ // cannot run this code. Homes already frozen are found by `check` and repaired by `init`.
104
+ //
105
+ // Two exemptions, both load-bearing. `floor` is how a bad publish is recovered from — a
106
+ // repository that refuses every verb must still be able to run the one that unrefuses it.
107
+ // An explicit help request is information, and refusing to print a usage line teaches
108
+ // the human nothing they can act on.
109
+ const helpRequested = verb === 'help' || verb === '--help'
110
+ || rest.includes('--help') || rest.includes('-h');
111
+ if (!helpRequested && verb !== 'floor') {
112
+ const gateRoot = primaryRoot(ctx.cwd);
113
+ if (gateRoot.ok) {
114
+ const floor = stateFloor(gateRoot.value);
115
+ if (floor !== undefined && (compareTriple(version(), floor.pin) ?? 0) < 0) {
116
+ renderRefusal([v('witness', 'cli-behind-state', `this CLI is ${version()}, this repository's state was written by ${floor.pin}`, `a CLI at or ahead of ${floor.pin} — run ${NPX_LATEST} init --agent <name>; if you are `
117
+ + `deliberately rolling back, lower the bound first with witness floor --set ${version()} --note <why>`)]).forEach((l) => ctx.err(l));
118
+ return EXIT.REFUSED;
119
+ }
120
+ }
121
+ }
83
122
  if (!verb || verb === 'help' || verb === '--help') {
84
123
  if (!verb) {
85
124
  const rootRes = primaryRoot(ctx.cwd);
@@ -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/floor.js ADDED
@@ -0,0 +1,74 @@
1
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { journalRel } from './journal.js';
4
+ import { compareTriple } from './version.js';
5
+ // Row 116. The state's own answer to "what is the oldest CLI allowed to touch this
6
+ // repository", derived from the `w` stamps entryLine writes and stored nowhere. Derived
7
+ // because a stored floor is a second source of truth that drifts from the entries it
8
+ // summarises — the shape rows 93, 95 and 96 all name — and because a derived one cannot
9
+ // be lowered by editing a file, only by a journaled decision (verbs/floor.ts).
10
+ //
11
+ // MAXIMUM, not most-recent: a downgraded CLI writing a lower stamp after a higher one is
12
+ // exactly the event this guards against, and a floor that fell to the last writer would
13
+ // ratify the regression it exists to refuse.
14
+ //
15
+ // `undefined` means the state carries no readable stamp at all — every repository written
16
+ // before this row shipped. It is silence, not zero: a floor of 0.0.0 would be a claim the
17
+ // state never made, and it would refuse nothing anyway.
18
+ export function stateFloor(root) {
19
+ const dir = join(root, '.witness', 'journal');
20
+ if (!existsSync(dir))
21
+ return undefined;
22
+ // An explicit decision outranks the derived maximum — including downward, which is the
23
+ // only reason the verb exists. Latest wins, so a second rollback supersedes the first:
24
+ // the last decision is the state, the same doctrine D94 applies to gate decisions.
25
+ const pinned = entries(root, FLOOR_STREAM)
26
+ .filter((e) => e.t === 'policy-pin' && e.key === FLOOR_KEY && typeof e.pin === 'string')
27
+ .at(-1);
28
+ if (pinned !== undefined)
29
+ return { pin: pinned.pin, stream: 'a human decision' };
30
+ let best;
31
+ for (const file of readdirSync(dir).filter((f) => f.endsWith('.jsonl')).sort()) {
32
+ const stream = file.slice(0, -'.jsonl'.length);
33
+ for (const e of entries(root, stream)) {
34
+ if (typeof e.w !== 'string')
35
+ continue;
36
+ const pin = e.w;
37
+ // `compareTriple(pin, pin)` is the parse check: undefined means CANNOT COMPARE, the
38
+ // same contract install.ts's two guards read it under. A stamp we cannot read must
39
+ // never raise a bound — that would refuse a legal CLI over a typo.
40
+ if (compareTriple(pin, pin) === undefined)
41
+ continue;
42
+ if (best === undefined || (compareTriple(pin, best.pin) ?? 0) > 0)
43
+ best = { pin, stream };
44
+ }
45
+ }
46
+ return best;
47
+ }
48
+ // The journal stream a floor decision lives in. Its first entry is a policy-pin, never a
49
+ // recap, so `effortStreams` does not mistake it for an effort.
50
+ export const FLOOR_STREAM = 'floor';
51
+ export const FLOOR_KEY = 'floor';
52
+ // Deliberately NOT journal.ts's readStream, whose bare JSON.parse throws. This runs on
53
+ // EVERY verb now, so one unreadable line would brick the whole CLI — including `floor`
54
+ // itself, the one verb that could unstick it. readStream's strict parse stays right for
55
+ // callers that need the entry; a bound derived from stamps needs only the lines it can
56
+ // actually read, and skipping the rest is the honest reading of a damaged file.
57
+ function entries(root, stream) {
58
+ const path = join(root, journalRel(stream));
59
+ if (!existsSync(path))
60
+ return [];
61
+ const out = [];
62
+ for (const raw of readFileSync(path, 'utf8').split('\n')) {
63
+ if (raw === '')
64
+ continue;
65
+ try {
66
+ out.push(JSON.parse(raw));
67
+ }
68
+ catch {
69
+ // a line we cannot parse carries no decision and no stamp we can trust — the same
70
+ // answer as a line that never had one
71
+ }
72
+ }
73
+ return out;
74
+ }
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.out(`help: ${liveExits(entry.gate, entry.artifact, entries, false)}`);
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 === '' ? undefined : pinsText), pin, witness: version(),
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
- ctx.out('help: edit the artifact (or code) before re-running, or decide --approve/--stop');
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.out(`help: witness decide ${spec.gate} ${target} --approve --override | --revise --upstream <id> | --stop`);
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.out(`help: witness decide ${spec.gate} ${target} --approve --override | --revise --upstream <id> | --stop`);
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;
@@ -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
  },
@@ -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')
@@ -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
  });
@@ -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' }] : [];
@@ -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
  });
package/dist/install.js CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { version } from './cli.js';
@@ -114,6 +114,45 @@ export function installPayload(root, harness) {
114
114
  }
115
115
  return ok(out);
116
116
  }
117
+ // Row 117. "Upgrade the repository" has always meant "upgrade one checkout of it":
118
+ // installPayload had exactly one call site and it targeted the primary root, so nothing
119
+ // ever refreshed a live worktree. A worktree is a branch checkout, so its payload is a
120
+ // different file on a different timeline — the same property row 87 relies on to get the
121
+ // payload to the agent at all, read the other way round.
122
+ //
123
+ // Root first, then worktrees in sorted order: the root is the home the human is standing
124
+ // in, and if a later home fails they need the report to read top-down.
125
+ export function payloadHomes(root) {
126
+ const dir = join(root, '.witness', 'worktrees');
127
+ if (!existsSync(dir))
128
+ return [root];
129
+ return [root, ...readdirSync(dir, { withFileTypes: true })
130
+ .filter((e) => e.isDirectory())
131
+ .map((e) => join(dir, e.name))
132
+ .sort()];
133
+ }
134
+ // Preflight EVERY home before writing ANY. install.ts's standing doctrine is that a
135
+ // refusal leaves nothing half-installed; across homes that matters more, not less — a
136
+ // half-upgraded set of homes IS the skew row 116 refuses and this row prevents.
137
+ export function installAllHomes(root, harness) {
138
+ const homes = payloadHomes(root);
139
+ const failures = homes.flatMap((home) => {
140
+ const pre = preflightPayload(home, harness);
141
+ // Name the home in the field, or a `payload-dirty` row over `.claude/commands/witness.md`
142
+ // is ambiguous across four checkouts of the same repository.
143
+ return pre.ok ? [] : pre.violations.map((x) => ({ ...x, field: home === root ? x.field : `${home}: ${x.field}` }));
144
+ });
145
+ if (failures.length > 0)
146
+ return refuse(failures);
147
+ const out = [];
148
+ for (const home of homes) {
149
+ const synced = installPayload(home, harness);
150
+ if (!synced.ok)
151
+ return refuse(synced.violations);
152
+ out.push({ home, synced: synced.value });
153
+ }
154
+ return ok(out);
155
+ }
117
156
  function hasCommand(entries, command) {
118
157
  return (entries ?? []).some((e) => (e.hooks ?? []).some((h) => h.command === command));
119
158
  }
package/dist/journal.js CHANGED
@@ -1,11 +1,18 @@
1
1
  import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
+ import { version } from './version.js';
3
4
  const dir = (root) => join(root, '.witness', 'journal');
4
5
  export const journalRel = (id) => `.witness/journal/${id}.jsonl`;
5
6
  export function streamExists(root, id) {
6
7
  return existsSync(join(root, journalRel(id)));
7
8
  }
8
- export const entryLine = (entry) => JSON.stringify({ v: 1, ...entry });
9
+ // Row 116. `w` is the CLI that wrote this line. Stamped HERE because entryLine is the
10
+ // single funnel every state write passes through, so one edit covers fourteen entry types
11
+ // and every verb without a widened signature to disagree over. It is what lets the state
12
+ // name its own floor (floor.ts) instead of a stored number that drifts from the entries it
13
+ // summarises — and it is why a repo can refuse a CLI older than its own history without
14
+ // consulting any payload file, which is the guard row 102 structurally could not be.
15
+ export const entryLine = (entry) => JSON.stringify({ v: 1, w: version(), ...entry });
9
16
  export function appendEntry(root, id, entry) {
10
17
  mkdirSync(dir(root), { recursive: true });
11
18
  const line = entryLine(entry);