@popovych.co/witness 0.5.1 → 0.6.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
@@ -149,7 +149,8 @@ the wrong file refuses. There are no `WITNESS_*` env vars for configuration.
149
149
  | `sync` | pull --rebase + push state commits |
150
150
  | `calibrate <model>` | run the reviewer calibration battery |
151
151
  | `recover [--complete\|--rollback]` | resolve a crashed write transaction |
152
- | *(no verb)* | dashboard |
152
+ | `status` | orientation: flows, blocked docs, reconcile rows, pending gates, calibration state |
153
+ | *(no verb)* | the same screen as `status` |
153
154
 
154
155
  ## Exit codes
155
156
 
package/dist/calibrate.js CHANGED
@@ -547,6 +547,13 @@ export async function runImplementSeed(ctx, harness, seed, agent) {
547
547
  if (!planDoc)
548
548
  return refuse([v('plan', 'seed-plan-missing', planId, 'the plan just written by the seed')]);
549
549
  const report = evidenceForDiff(wt.value.path, root, planDoc, base.value);
550
+ // Row 97 narrowed `required` to the parent tag, so an empty list no longer means "nothing
551
+ // was owed" — it means the agent never wrote a test tagged for the spec it was given, and
552
+ // `.every()` would pass that vacuously. The seed's whole question is whether the parent's
553
+ // red→green pair got witnessed.
554
+ if (report.required.length === 0) {
555
+ return ok({ ok: false, why: 'no test tagged for the parent spec — nothing was witnessed' });
556
+ }
550
557
  return ok({ ok: report.satisfied, why: report.satisfied ? 'evidence satisfied' : JSON.stringify(report.required) });
551
558
  }
552
559
  export async function runSkillSuites(ctx, harness, model, samples, opts = {}) {
package/dist/cli.js CHANGED
@@ -21,6 +21,10 @@ const VERBS = {
21
21
  decide: () => import('./verbs/decide.js'),
22
22
  design: () => import('./verbs/design.js'),
23
23
  start: () => import('./verbs/start.js'),
24
+ // The orientation screen has a name (D101). Bare `witness` stays as its alias, but
25
+ // an unnamed command cannot be referenced — commands/witness.md instructs the
26
+ // operator to read "the dashboard" and nothing said which command prints it.
27
+ status: () => import('./verbs/dashboard.js'),
24
28
  clean: () => import('./verbs/clean.js'),
25
29
  ship: () => import('./verbs/ship.js'),
26
30
  next: () => import('./verbs/next.js'),
@@ -52,6 +56,7 @@ const VERB_USAGE = {
52
56
  satisfy: 'witness satisfy <doc-id> --need <text | index>',
53
57
  ship: 'witness ship <plan-id>',
54
58
  start: 'witness start <plan-id>',
59
+ status: 'witness status — flows · blocked docs · reconcile rows · pending gates (bare `witness` is the same screen)',
55
60
  sync: 'witness sync',
56
61
  'test-evidence': 'witness test-evidence <plan-id> --phase red|green',
57
62
  'verify-red': 'witness verify-red <plan-id> [--base <ref>]',
package/dist/evidence.js CHANGED
@@ -32,7 +32,7 @@ export function checkoutRoot(cwd) {
32
32
  return refuse([v('cwd', 'not-a-repo', cwd, 'run inside a git checkout')]);
33
33
  return ok(res.out.trim());
34
34
  }
35
- export async function runSpecTests(runRoot, ctx, parentId, trustRoot) {
35
+ export async function runSpecTests(runRoot, ctx, parentId, trustRoot, opts = {}) {
36
36
  const cfg = loadConfig(runRoot);
37
37
  if (!cfg.ok)
38
38
  return cfg;
@@ -63,10 +63,20 @@ export async function runSpecTests(runRoot, ctx, parentId, trustRoot) {
63
63
  }
64
64
  return ok({ runner: 'filtered', tests, allOk: tests.every((t) => t.ok) });
65
65
  }
66
- const suite = await runFullSuite(runRoot, ctx, rc, trustRoot);
67
- if (!suite.ok)
68
- return suite;
69
- const tests = suite.value.tests
66
+ // A caller checking several specs at once runs the suite ONCE and hands it here. Without
67
+ // this the regression lane executes the whole suite per spec, which is the redundancy
68
+ // `runCriteria` already pays per CRITERION.
69
+ let all;
70
+ if (opts.suite !== undefined) {
71
+ all = opts.suite;
72
+ }
73
+ else {
74
+ const suite = await runFullSuite(runRoot, ctx, rc, trustRoot);
75
+ if (!suite.ok)
76
+ return suite;
77
+ all = suite.value.tests;
78
+ }
79
+ const tests = all
70
80
  .filter((t) => matchesTag(t.name, parentId))
71
81
  .map((t) => ({ name: t.name, ok: t.status === 'passed' }));
72
82
  if (tests.length === 0) {
@@ -183,11 +193,77 @@ export function diffTags(runRoot, base) {
183
193
  }
184
194
  return [...tags].sort();
185
195
  }
196
+ // Row 97. Editing someone else's tests creates a REGRESSION obligation — those tests still
197
+ // pass — never a red→green one: `red` means "I observed this behaviour missing before I
198
+ // built it", and producing a red on a spec you do not own means deliberately breaking it and
199
+ // journaling that as evidence. Detection reads the CURRENT CONTENT of every changed test
200
+ // file rather than added lines, because the case this exists for (a shared fixture grown
201
+ // from 3 to 24 respondents breaking a `@spec:report-view` e2e) touches no tagged line at all.
202
+ export function changedTestSpecs(runRoot, base, parentId) {
203
+ const tags = new Set();
204
+ for (const rel of changedFiles(runRoot, base).filter(isTestPath)) {
205
+ const abs = join(runRoot, rel);
206
+ if (!existsSync(abs))
207
+ continue; // deleted: no content left to owe anything
208
+ for (const tag of extractCanonicalTags(readFileSync(abs, 'utf8')))
209
+ tags.add(tag);
210
+ }
211
+ tags.delete(parentId); // the parent's obligation is red→green
212
+ return [...tags].sort();
213
+ }
214
+ // Never a `Result` refusal: this runs inside a gate's resolve(), and a refusal there aborts
215
+ // the whole gate — a fresh unescapable dead end, the exact class row 97 removes. Every
216
+ // runner problem (filter-matched-nothing included) degrades to a failed check carrying its
217
+ // detail, the shape `runCriteria` has always had.
218
+ //
219
+ // Nothing here journals. `journalEvidence`/`recordEvidence` are what write `test-evidence`
220
+ // entries and neither is on this path — an entry under a tag this plan does not own is
221
+ // exactly the false claim row 97 refuses to let anyone make.
222
+ export async function runRegression(runRoot, ctx, trustRoot, specIds, known) {
223
+ const out = [];
224
+ const memo = new Map();
225
+ const runnable = specIds.filter(known);
226
+ let suite;
227
+ const cfg = loadConfig(runRoot);
228
+ const rc = cfg.ok ? runnerConfig(cfg.value) : undefined;
229
+ if (rc?.ok && rc.value.mode === 'full-suite' && runnable.length > 0) {
230
+ const first = await runFullSuite(runRoot, ctx, rc.value, trustRoot);
231
+ if (first.ok)
232
+ suite = first.value.tests; // one suite run for every spec below
233
+ }
234
+ for (const id of specIds) {
235
+ if (!known(id)) {
236
+ out.push({ spec: id, state: 'unknown', detail: 'no such spec in canon — reported, not run' });
237
+ continue;
238
+ }
239
+ let run = memo.get(id);
240
+ if (run === undefined) {
241
+ run = await runSpecTests(runRoot, ctx, id, trustRoot, { suite });
242
+ memo.set(id, run);
243
+ }
244
+ if (!run.ok) {
245
+ out.push({ spec: id, state: 'unrunnable', detail: run.violations.map((x) => x.rule).join(' · ') });
246
+ continue;
247
+ }
248
+ const failed = run.value.tests.filter((t) => !t.ok).length;
249
+ out.push({
250
+ spec: id, state: run.value.allOk ? 'green' : 'red',
251
+ detail: `${run.value.tests.length} tagged · ${failed} failed`,
252
+ });
253
+ }
254
+ return out;
255
+ }
186
256
  export function evidenceForDiff(runRoot, stateRoot, plan, base) {
187
257
  const planId = String(plan.meta.id);
258
+ const parentId = String(plan.meta.parent);
188
259
  const entries = readStream(stateRoot, planId).filter((e) => e.t === 'test-evidence');
189
260
  const matching = (e, tag) => (Array.isArray(e.tests) ? e.tests : []).filter((t) => matchesTag(t.name, tag));
190
- const required = diffTags(runRoot, base).map((tag) => {
261
+ // Row 97: red→green is a claim about the NEW BEHAVIOUR THIS PLAN BUILDS, so the only tag
262
+ // it can be made for is the plan's own parent — `test-evidence` interpolates
263
+ // `plan.meta.parent` and nothing else, which is why a foreign tag here was unsatisfiable
264
+ // AND (before row 93) unwaivable. Every other spec the diff's tests touched is a
265
+ // regression obligation, checked by `runRegression` at the gate.
266
+ const required = diffTags(runRoot, base).filter((tag) => tag === parentId).map((tag) => {
191
267
  // latest-cycle semantics: the newest red matching the tag is the verdict on
192
268
  // "was failure observed"; a green only counts if it post-dates that red.
193
269
  // One early vacuous red must not poison the tag forever (append-only journal).
package/dist/gate.js CHANGED
@@ -14,12 +14,13 @@ 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, gateRuns, roundsSinceApprove, } from './rounds.js';
17
+ import { ROUND_BOUND, appendKind, boundReached, gateRuns, lastGateRun, roundsSinceApprove, } from './rounds.js';
18
+ import { gateSettled } from './verbs/next.js';
18
19
  import { prepareStamp, writeStamp } from './stamp.js';
19
20
  const GATES = new Map();
20
21
  export function registerGate(spec) { GATES.set(spec.gate, spec); }
21
22
  export function gateSpec(name) { return GATES.get(name); }
22
- export const DEFAULT_BATTERIES = {
23
+ const DEFAULT_BATTERIES = {
23
24
  decompose: ['slicing-critic'],
24
25
  plan: ['plan-critic'],
25
26
  implement: {
@@ -43,11 +44,17 @@ export function batteryFor(cfg, gate, cls) {
43
44
  // where the state is. Skills used to recite a fixed triple, which is wrong at the bound
44
45
  // (D67's endgame set) — and now wrong in three more states.
45
46
  export function liveExits(gate, target, entries, stale) {
46
- if (stale)
47
- return `witness gate ${gate} ${target}`;
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.
48
51
  if (boundReached(entries, gate)) {
49
- return `witness decide ${gate} ${target} --approve --override | --revise --upstream <id> | --stop`;
52
+ return stale
53
+ ? `witness decide ${gate} ${target} --revise --upstream <id> | --stop`
54
+ : `witness decide ${gate} ${target} --approve --override | --revise --upstream <id> | --stop`;
50
55
  }
56
+ if (stale)
57
+ return `witness gate ${gate} ${target}`;
51
58
  return `witness decide ${gate} ${target} --approve | --revise --note "<why>" | --revise --upstream <id> | --stop`;
52
59
  }
53
60
  export function renderGateRun(ctx, entry, mode) {
@@ -183,15 +190,35 @@ export async function runGate(ctx, gateName, target, flags) {
183
190
  renderRefusal(modelR.violations).forEach((l) => ctx.err(l));
184
191
  return EXIT.REFUSED;
185
192
  }
186
- const { chain, calibrationOf, warning } = modelR.value;
187
- if (warning)
193
+ const { chain, calibrationOf, warning, warningKind } = modelR.value;
194
+ // matrix-empty is a fact about this witness build, reported once by `status`/`check`
195
+ // (D98a). Only the caller's own pin being below the floor is news at run time.
196
+ if (warning && warningKind === 'below-floor')
188
197
  ctx.err(`warning: ${warning}`);
189
198
  const key = {
190
199
  reviewed_sha: input.reviewedSha, gate: spec.gate,
191
200
  prompts_sha: promptsSha(lenses, pinsText === '' ? undefined : pinsText), model: chain[0], witness: version(),
192
201
  harness: harness.name,
193
202
  };
203
+ // D99: `gateSettled` reads only the last run, so any new run un-settles the gate.
204
+ // Content moving is a legitimate, self-explaining reason. A flag is not — `--fresh`
205
+ // discarded a human decision with nothing printed and nothing journaled about it, and
206
+ // row 94 removed its other job (escaping the changed-nothing deadlock), so it can
207
+ // refuse and send the human through the verb that states a retraction and its reason.
208
+ const settledBefore = gateSettled(entries, spec.gate);
209
+ if (flags.fresh && settledBefore) {
210
+ renderRefusal([v('gate', 'settled-approve', `${spec.gate} ${target} is settled`, `witness decide ${spec.gate} ${target} --revise --note "<why>" — retract the approval, then re-gate`)])
211
+ .forEach((l) => ctx.err(l));
212
+ return EXIT.REFUSED;
213
+ }
194
214
  const kind = flags.fresh ? { kind: 'fresh' } : appendKind(entries, spec.gate, key);
215
+ // A key that moved for a NON-content reason — edited prompt, re-pinned model, new
216
+ // witness version — un-settles just as quietly as --fresh did. Content moving explains
217
+ // itself; this does not.
218
+ const lastRun = lastGateRun(entries, spec.gate);
219
+ if (settledBefore && kind.kind === 'fresh' && lastRun && lastRun.reviewed_sha === input.reviewedSha) {
220
+ ctx.err(`warning: reviewer setup changed — this run discards the settled approve on ${spec.gate} ${target}`);
221
+ }
195
222
  if (kind.kind === 'resume') {
196
223
  renderGateRun(ctx, kind.entry, 'resume');
197
224
  printDispatchArithmetic(ctx, root, spec.gate, target);
@@ -8,9 +8,9 @@ import { designRel } from '../design.js';
8
8
  import { git } from '../gitio.js';
9
9
  import { latestRecap, readStream } from '../journal.js';
10
10
  import { findById } from '../scan.js';
11
- import { changedFiles, diffBase, evidenceForDiff, screensDir } from '../evidence.js';
11
+ import { changedFiles, changedTestSpecs, diffBase, evidenceForDiff, runRegression, screensDir } from '../evidence.js';
12
12
  import { runCriteria } from '../criteria.js';
13
- import { effortOf, worktreeTreeSha } from '../reviewed.js';
13
+ import { effortOf, implementReviewedSha } from '../reviewed.js';
14
14
  import { worktreePath } from '../worktree.js';
15
15
  import { registerGate } from '../gate.js';
16
16
  export function codePromptBody(wt, base, files, header) {
@@ -113,8 +113,26 @@ registerGate({
113
113
  const missing = report.required.filter((r) => !(r.red && r.green && !r.vacuous));
114
114
  checks.push({ name: 'evidence', ok: report.satisfied,
115
115
  detail: report.satisfied
116
- ? `${report.required.length} tags with red→green pairs`
116
+ ? (report.required.length > 0
117
+ ? `${String(plan.meta.parent)}: red→green`
118
+ : `no ${String(plan.meta.parent)} tests in the diff — nothing to witness`)
117
119
  : missing.map((m) => `${m.tag}: red=${m.red} green=${m.green}${m.vacuous ? ' vacuous' : ''}`).join(' · ') });
120
+ // Row 97: the obligation created by editing someone's tests is that those tests still
121
+ // pass — not that their whole spec is satisfied, which is the out-of-band drift sweep's
122
+ // job. `runCriteria` is deliberately NOT used here: it executes the foreign spec's `cmd`
123
+ // criteria, arbitrary trust-gated commands, inside an unattended gate.
124
+ const foreign = changedTestSpecs(wt, base, String(plan.meta.parent));
125
+ const regression = await runRegression(wt, ctx, root, foreign, (id) => findById(canon, id) !== undefined);
126
+ checks.push({
127
+ name: 'regression',
128
+ // `unknown` does not fail: witness cannot judge an obligation for a spec it does not
129
+ // have, and failing it re-creates the unsatisfiable check this row deletes. It is
130
+ // named in the detail, which is where a typo'd tag surfaces.
131
+ ok: regression.every((r) => r.state === 'green' || r.state === 'unknown'),
132
+ detail: regression.length === 0
133
+ ? 'no foreign spec tests touched'
134
+ : regression.map((r) => `${r.spec}:${r.state}`).join(' · '),
135
+ });
118
136
  const lane = await runCriteria(wt, ctx, parent, { trustRoot: root });
119
137
  checks.push({
120
138
  name: 'drift-lane',
@@ -127,7 +145,7 @@ registerGate({
127
145
  const recap = effort ? latestRecap(root, effort) : undefined;
128
146
  return ok({
129
147
  class: (recap?.class ?? 'feature'),
130
- reviewedSha: worktreeTreeSha(wt),
148
+ reviewedSha: implementReviewedSha(wt, base, plan),
131
149
  artifactSha: canonicalSha(plan.meta, plan.body),
132
150
  reviewed: { kind: 'tree', root: wt, files },
133
151
  promptBody: codePromptBody(wt, base, files, `### Plan under implementation: ${planId}\n${serializeDoc({ meta: plan.meta, body: plan.body })}`),
@@ -137,8 +155,14 @@ registerGate({
137
155
  skipLenses,
138
156
  });
139
157
  },
140
- currentSha(root, _canon, _cfg, planId) {
158
+ currentSha(root, canon, cfg, planId) {
141
159
  const wt = worktreePath(root, planId);
142
- return existsSync(wt) ? worktreeTreeSha(wt) : undefined;
160
+ const plan = findById(canon, planId);
161
+ if (!existsSync(wt) || !plan)
162
+ return undefined;
163
+ const baseR = diffBase(wt, cfg);
164
+ // undefined is "cannot compute", never "moved": an unresolvable base must not
165
+ // un-settle a gate or invert `decide --show`'s staleness line.
166
+ return baseR.ok ? implementReviewedSha(wt, baseR.value, plan) : undefined;
143
167
  },
144
168
  });
@@ -7,7 +7,7 @@ import { changedFiles, diffBase } from '../evidence.js';
7
7
  import { runCriteria } from '../criteria.js';
8
8
  import { execCommand } from '../runner.js';
9
9
  import { ensureTrusted } from '../allowlist.js';
10
- import { effortOf, worktreeTreeSha } from '../reviewed.js';
10
+ import { diffReviewedSha, effortOf } from '../reviewed.js';
11
11
  import { worktreePath } from '../worktree.js';
12
12
  import { codePromptBody } from './implement.js';
13
13
  import { registerGate } from '../gate.js';
@@ -52,13 +52,15 @@ registerGate({
52
52
  // differently for the same journal — the split-brain that reads as a deadlock when
53
53
  // two sessions compare notes.
54
54
  //
55
- // The missing sha argument is DELIBERATE and load-bearing, not an oversight. D75's
56
- // `worktreeTreeSha` covers the whole worktree, so ship's own `pr #N` stamp — pulled
57
- // into the worktree by the watch-phase rebase — moves the tree and would lapse the
58
- // very gate ship is checking: approve → pr → lapse → gate, the livelock D75/D77
59
- // record. `next` re-arms on tree movement because authoring is what it routes to;
60
- // ship asks only whether implement was ever settled. Passing a sha here re-opens
61
- // that livelock — do not "fix" this by adding one.
55
+ // The missing sha argument is still DELIBERATE, but row 96 killed one of its two
56
+ // reasons and the surviving one is now the whole reason. It used to rest on
57
+ // `worktreeTreeSha` hashing the entire worktree, so ship's own `pr #N` stamp — pulled
58
+ // in by the watch-phase rebase — moved the tree and lapsed the very gate ship was
59
+ // checking: approve → pr → lapse → gate, the D75/D77 livelock. Under the diff identity
60
+ // that reason is DEAD: the worktree never edits `plans/<id>.md`, so the stamp is always
61
+ // equal to base and can never enter the reviewed set. What remains: `next` re-arms on
62
+ // movement because authoring is what it routes to, while ship asks only whether
63
+ // implement was EVER settled. Passing a sha here re-opens the livelock — do not add one.
62
64
  const implementSettled = gateSettled(entries, 'implement');
63
65
  checks.push({ name: 'implement-gate', ok: implementSettled,
64
66
  detail: lastImplement
@@ -85,7 +87,7 @@ registerGate({
85
87
  const recap = effort ? latestRecap(root, effort) : undefined;
86
88
  return ok({
87
89
  class: (recap?.class ?? 'feature'),
88
- reviewedSha: worktreeTreeSha(wt),
90
+ reviewedSha: diffReviewedSha(wt, base),
89
91
  artifactSha: canonicalSha(plan.meta, plan.body),
90
92
  reviewed: { kind: 'tree', root: wt, files },
91
93
  promptBody: codePromptBody(wt, base, files, `### Parent spec (from main): ${String(parent.meta.id)}\n${serializeDoc({ meta: parent.meta, body: parent.body })}\n\n### Plan: ${planId}\n${serializeDoc({ meta: plan.meta, body: plan.body })}`),
@@ -95,8 +97,12 @@ registerGate({
95
97
  repin,
96
98
  });
97
99
  },
98
- currentSha(root, _canon, _cfg, planId) {
100
+ currentSha(root, _canon, cfg, planId) {
99
101
  const wt = worktreePath(root, planId);
100
- return existsSync(wt) ? worktreeTreeSha(wt) : undefined;
102
+ if (!existsSync(wt))
103
+ return undefined;
104
+ const baseR = diffBase(wt, cfg);
105
+ // undefined is "cannot compute", never "moved" — same doctrine as the implement gate.
106
+ return baseR.ok ? diffReviewedSha(wt, baseR.value) : undefined;
101
107
  },
102
108
  });
package/dist/gitio.js CHANGED
@@ -39,6 +39,39 @@ export function stateDirs(root) {
39
39
  export function isStatePath(root, rel) {
40
40
  return stateDirs(root).some((d) => rel === d || rel.startsWith(d + '/'));
41
41
  }
42
+ // Row 96b. Witness's own state commits reach `origin/main` on every `sync`, and `shipPhase`
43
+ // re-gates on ANY base advance — so the pipeline manufactured its own base movement (7 ship
44
+ // runs for 3 rounds on the audited plan). An advance whose every commit touches only state
45
+ // paths is bookkeeping, not base movement, and this is the ONE predicate both `baseMoved`
46
+ // and `rebaseIfMoved` ask; two copies would let ship rebase in the phase that was just told
47
+ // the base had not moved.
48
+ //
49
+ // Paths are primary and the trailer is secondary, and the conjunction is what keeps both
50
+ // forgeries conservative: a forged trailer on a source commit still reads as real movement
51
+ // (paths decide), and a hand-made commit that happens to touch only state paths reads as
52
+ // real movement too (no trailer). A needless rebase costs a round; a skipped one merges a
53
+ // tree no battery read.
54
+ export function stateOnlyAdvance(wt, root, from, to) {
55
+ const range = tryGit(wt, 'log', '--format=%H', `${from}..${to}`);
56
+ if (!range.ok)
57
+ return false;
58
+ const shas = range.out.split('\n').filter(Boolean);
59
+ if (shas.length === 0)
60
+ return false;
61
+ return shas.every((sha) => {
62
+ const trailer = tryGit(wt, 'show', '--no-patch', '--format=%(trailers:key=Witness-State,valueonly=true)', sha);
63
+ if (!trailer.ok || trailer.out.trim() !== '1')
64
+ return false;
65
+ // Per COMMIT, never the range's union diff: a commit that adds and deletes a source
66
+ // file inside the range cancels out in a union and would read as state-only. An empty
67
+ // name list (a merge commit) is unjudgeable here and therefore not state-only.
68
+ const touched = tryGit(wt, 'show', '--pretty=format:', '--name-only', sha);
69
+ if (!touched.ok)
70
+ return false;
71
+ const paths = touched.out.split('\n').filter(Boolean);
72
+ return paths.length > 0 && paths.every((p) => isStatePath(root, p));
73
+ });
74
+ }
42
75
  const LOCAL_STATE_FILES = new Set([
43
76
  '.witness/lock',
44
77
  '.witness/txn.json',
package/dist/model.js CHANGED
@@ -64,10 +64,28 @@ export function resolveModel(cfg, matrix, gate) {
64
64
  const head = chain[0];
65
65
  const headLabel = head === SESSION_DEFAULT ? '(session default)' : head;
66
66
  const matrixEmpty = matrix.shipped.length === 0 && matrix.local.length === 0;
67
- const warning = calibrationOf(head) !== 'none'
67
+ const warningKind = calibrationOf(head) !== 'none'
68
68
  ? undefined
69
- : matrixEmpty
69
+ : matrixEmpty ? 'matrix-empty' : 'below-floor';
70
+ const warning = warningKind === undefined
71
+ ? undefined
72
+ : warningKind === 'matrix-empty'
70
73
  ? `calibration matrix is empty — no calibrated model exists yet; ${headLabel} runs uncalibrated`
71
74
  : `reviewer model ${headLabel} is below the model floor — no calibration matrix entry covers it`;
72
- return ok({ chain, calibrationOf, warning });
75
+ return ok({ chain, calibrationOf, warning, warningKind });
76
+ }
77
+ // One renderer for both orientation surfaces (`status` and `check`). Lives here because
78
+ // the grouping rule — one line per distinct warning, labelled with the gates it covers —
79
+ // is a property of the resolution, not of either screen. Returns `[]` when every gate's
80
+ // model is calibrated.
81
+ export function modelFloorLines(root, cfg, harness) {
82
+ const matrix = loadMatrix(root, harness);
83
+ const byWarning = new Map();
84
+ for (const gate of ['decompose', 'plan', 'implement', 'ship', 'design']) {
85
+ const r = resolveModel(cfg, matrix, gate);
86
+ if (r.ok && r.value.warning) {
87
+ byWarning.set(r.value.warning, [...(byWarning.get(r.value.warning) ?? []), gate]);
88
+ }
89
+ }
90
+ return [...byWarning].map(([warning, gates]) => `${gates.join(' · ')}: ${warning}`);
73
91
  }
package/dist/reviewed.js CHANGED
@@ -1,21 +1,44 @@
1
- import { execFileSync } from 'node:child_process';
2
1
  import { createHash } from 'node:crypto';
3
- import { mkdtempSync, rmSync } from 'node:fs';
4
- import { tmpdir } from 'node:os';
2
+ import { existsSync } from 'node:fs';
5
3
  import { join } from 'node:path';
6
- import { canonicalJson, canonicalSha } from './sha.js';
4
+ import { canonicalJson, canonicalSha, planContentSha } from './sha.js';
5
+ import { changedFiles } from './evidence.js';
6
+ import { git } from './gitio.js';
7
7
  import { effortAbandoned, effortStreams, latestRecap, readStream } from './journal.js';
8
8
  import { findById } from './scan.js';
9
- export function worktreeTreeSha(root) {
10
- const tmp = mkdtempSync(join(tmpdir(), 'witness-idx-'));
11
- const env = { ...process.env, GIT_INDEX_FILE: join(tmp, 'index') };
12
- try {
13
- execFileSync('git', ['add', '-A'], { cwd: root, env });
14
- return execFileSync('git', ['write-tree'], { cwd: root, env, encoding: 'utf8' }).trim();
15
- }
16
- finally {
17
- rmSync(tmp, { recursive: true, force: true });
18
- }
9
+ // Row 96a. The reviewed identity is the DIFF the battery read: the base commit plus the
10
+ // content of every path `changedFiles` reports — exactly what `codePromptBody` renders as
11
+ // `### Changed files`, `### Diff vs base <sha>` and the untracked blocks. `worktreeTreeSha`
12
+ // hashed the WHOLE worktree via `add -A`, so witness's own journal entries and stamps —
13
+ // pulled in by ship's pre-battery rebase — moved the identity the verdict was keyed to, and
14
+ // the entry DESCRIBING a verdict invalidated it one hop later.
15
+ //
16
+ // The base term is required, not optional. A rebase can change the diff TEXT while every
17
+ // blob stays byte-identical; drop the base and a cached verdict replays against a diff no
18
+ // reviewer ever read, and implement loses the rebase re-arming it has today.
19
+ //
20
+ // Deleted paths carry an explicit marker: `hash-object` throws on a missing file, and
21
+ // dropping the path silently would make "this file is gone" invisible to the identity.
22
+ const DELETED_BLOB = '(deleted)';
23
+ export function diffReviewedSha(runRoot, base) {
24
+ const pairs = changedFiles(runRoot, base).map((rel) => existsSync(join(runRoot, rel))
25
+ ? `${rel}\0${git(runRoot, 'hash-object', '--', rel)}`
26
+ : `${rel}\0${DELETED_BLOB}`);
27
+ return sha256([base, '\n', pairs.sort().join('\n')]);
28
+ }
29
+ // Row 95: implement's identity is the diff AND the plan, because the battery reads both —
30
+ // `codePromptBody` serializes the plan into the prompt — so a re-authored plan re-arms the
31
+ // gate. It lives here rather than in `gates/implement.ts` because there are THREE readers,
32
+ // not two: the gate's `resolve`, the gate's `currentSha`, and `flowAction`, which derives
33
+ // the sha itself instead of calling `currentSha`. Two of the three agreeing is the same
34
+ // split-brain rows 93 and 95 are about — the gate keyed on one sha while `next` checked
35
+ // another, and every settled gate read as lapsed one turn later.
36
+ export function implementReviewedSha(runRoot, base, plan) {
37
+ return createHash('sha256')
38
+ .update(diffReviewedSha(runRoot, base))
39
+ .update('\0')
40
+ .update(planContentSha(plan.meta, plan.body))
41
+ .digest('hex');
19
42
  }
20
43
  export function effortWrites(root, effort) {
21
44
  const out = new Map();
package/dist/sha.js CHANGED
@@ -22,6 +22,16 @@ export function canonicalSha(meta, body) {
22
22
  .update(body.replace(/\s+$/, '') + '\n')
23
23
  .digest('hex');
24
24
  }
25
+ // Row 95: a re-authored plan re-arms the implement gate, so the plan's content joins that
26
+ // gate's reviewed identity — the battery reads the plan (`codePromptBody` serializes it into
27
+ // the prompt), so the identity should say so. `derives-from` is excluded ON TOP of
28
+ // VOLATILE_FIELDS because ship's own `repin` rewrites it inside the same transaction as the
29
+ // gate run: keep it and the entry invalidates itself the moment it is written, which is
30
+ // row 96's self-invalidation in a second place.
31
+ export function planContentSha(meta, body) {
32
+ const { 'derives-from': _repinned, ...rest } = meta;
33
+ return canonicalSha(rest, body);
34
+ }
25
35
  export function short(sha) {
26
36
  return sha.slice(0, 7);
27
37
  }
package/dist/ship.js CHANGED
@@ -4,7 +4,7 @@ import { loadConfig } from './config.js';
4
4
  import { acquireLock } from './lock.js';
5
5
  import { crashPoint, guardTxn, withTxn } from './txn.js';
6
6
  import { journalRel, readStream } from './journal.js';
7
- import { primaryRoot, stateCommit, tryGit } from './gitio.js';
7
+ import { primaryRoot, stateCommit, stateOnlyAdvance, tryGit } from './gitio.js';
8
8
  import { findById, loadCanon } from './scan.js';
9
9
  import { ok, refuse, renderRefusal, v } from './refusal.js';
10
10
  import { kv } from './toon.js';
@@ -12,8 +12,9 @@ import { runGate } from './gate.js';
12
12
  import { lastGateRun } from './rounds.js';
13
13
  import { prepareStamp, writeStamp } from './stamp.js';
14
14
  import { branchName, worktreePath } from './worktree.js';
15
- import { gateSettled } from './verbs/next.js';
16
- import { worktreeTreeSha } from './reviewed.js';
15
+ import { authoringOwed, gateSettled } from './verbs/next.js';
16
+ import { diffReviewedSha } from './reviewed.js';
17
+ import { diffBase } from './evidence.js';
17
18
  export function shipPhase(plan, entries, treeSha, baseMoved = false) {
18
19
  // A PR exists, but the watch is only legitimate while the verdict still describes both
19
20
  // this tree AND this base. Either one moving re-arms the gate — otherwise a rebase (or
@@ -116,7 +117,18 @@ export function stampPr(ctx, root, planId, pr) {
116
117
  lockR.value();
117
118
  }
118
119
  }
119
- export function rebaseIfMoved(wt, shipBranch, remote = 'origin') {
120
+ // One read-only fetch decides the whole routing. A failed fetch (no remote, offline) leaves
121
+ // this false and the phases behave as they did — the gate phase's own rebaseIfMoved is where
122
+ // that condition turns into a structured refusal.
123
+ export function baseMoved(wt, root, shipBranch, remote = 'origin') {
124
+ if (!tryGit(wt, 'fetch', remote, shipBranch).ok)
125
+ return false;
126
+ const base = `${remote}/${shipBranch}`;
127
+ if (tryGit(wt, 'merge-base', '--is-ancestor', base, 'HEAD').ok)
128
+ return false;
129
+ return !stateOnlyAdvance(wt, root, 'HEAD', base);
130
+ }
131
+ export function rebaseIfMoved(wt, root, shipBranch, remote = 'origin') {
120
132
  // Resolve the base from the REMOTE tip, not the local ref: with several flows merging
121
133
  // concurrently, local <shipBranch> lags every merge since the human's last pull, so a
122
134
  // local ancestry check reports "clean" against a base that is provably behind and the
@@ -134,6 +146,10 @@ export function rebaseIfMoved(wt, shipBranch, remote = 'origin') {
134
146
  const base = `${remote}/${shipBranch}`;
135
147
  if (tryGit(wt, 'merge-base', '--is-ancestor', base, 'HEAD').ok)
136
148
  return ok('clean');
149
+ // Same predicate as baseMoved: an advance made entirely of witness's own state commits is
150
+ // bookkeeping, and rebasing onto it moves the base term of every reviewed sha for nothing.
151
+ if (stateOnlyAdvance(wt, root, 'HEAD', base))
152
+ return ok('clean');
137
153
  // --autostash: this runs in the GATE phase, and implement deliberately leaves the
138
154
  // worktree uncommitted (ship owns the sole code commit, in the pr phase below), so the
139
155
  // tree is normally dirty and a bare rebase refuses with "you have unstaged changes".
@@ -186,17 +202,26 @@ export async function runShip(ctx, planId) {
186
202
  // One read-only fetch decides the whole routing. A failed fetch (no remote, offline)
187
203
  // leaves baseMoved false and the phases behave as they did — the gate phase's own
188
204
  // rebaseIfMoved is where that condition turns into a structured refusal.
189
- const baseMoved = tryGit(wt, 'fetch', 'origin', shipBranch).ok
190
- && !tryGit(wt, 'merge-base', '--is-ancestor', `origin/${shipBranch}`, 'HEAD').ok;
191
- let phase = shipPhase(plan, entries, worktreeTreeSha(wt), baseMoved);
192
- if (baseMoved)
205
+ const moved = baseMoved(wt, root, shipBranch);
206
+ const baseR = diffBase(wt, cfgR.value);
207
+ const reviewedSha = baseR.ok ? diffReviewedSha(wt, baseR.value) : undefined;
208
+ let phase = shipPhase(plan, entries, reviewedSha, moved);
209
+ if (moved)
193
210
  ctx.out(kv('ship', `${shipBranch} moved — rebasing and re-reviewing before the watch`));
194
211
  if (phase === 'gate') {
212
+ // D94: the gate cannot judge unchanged content twice — it answers changed-nothing
213
+ // and appends nothing, so routing the human back at it burns a turn on a command
214
+ // that has already declined. The owed work is the edit the revise asked for.
215
+ if (authoringOwed(entries, 'ship', reviewedSha)) {
216
+ ctx.out(kv('ship', `${planId} — revise owed`));
217
+ ctx.out(`help: edit the code in ${wt} · then re-run witness ship ${planId}`);
218
+ return EXIT.FINDINGS;
219
+ }
195
220
  // Rebase BEFORE the battery, never after: the reviewers must judge the tree that will
196
221
  // actually merge. Reviewing first spends a battery — and a human decision — on a tree
197
222
  // without any sibling's merged work, which then lapses (gateSettled's reviewed_sha)
198
223
  // the moment the rebase lands.
199
- const rebase = rebaseIfMoved(wt, shipBranch);
224
+ const rebase = rebaseIfMoved(wt, root, shipBranch);
200
225
  if (!rebase.ok) {
201
226
  renderRefusal(rebase.violations).forEach((l) => ctx.err(l));
202
227
  // A textual conflict is a hand-back, not a refusal: the human resolves it in the
@@ -11,6 +11,7 @@ import { auditStateCommits, dirtyStatePaths, primaryRoot, tryGit } from '../giti
11
11
  import { contentAtSha } from '../history.js';
12
12
  import { effortStreams, readStream } from '../journal.js';
13
13
  import { sourceTags } from '../matcher.js';
14
+ import { modelFloorLines } from '../model.js';
14
15
  import { evaluateNeeds } from '../needs.js';
15
16
  import { renderRefusal } from '../refusal.js';
16
17
  import { pendingDecision } from '../rounds.js';
@@ -246,6 +247,14 @@ export async function run(ctx, argv = []) {
246
247
  const errors = findings.filter((x) => x.level === 'error');
247
248
  if (findings.length)
248
249
  rows('findings', ['level', 'area', 'field', 'rule', 'detail'], findings).forEach(ctx.out);
250
+ // The calibration state reads the same here as on `status` (D98a) — one renderer, so
251
+ // the fact the gate run no longer repeats still reaches both orientation surfaces.
252
+ if (cfg.ok) {
253
+ const hxR = resolveHarness(ctx.env, cfg.value.raw);
254
+ for (const line of modelFloorLines(root, cfg.value, hxR.ok ? hxR.value.harness.name : 'claude-code')) {
255
+ ctx.out(kv('model-floor', line));
256
+ }
257
+ }
249
258
  ctx.out(kv('checks', `${canon.docs.length} docs · ${auditStateCommits(root).length} commits audited · ${errors.length} errors`));
250
259
  return errors.length ? EXIT.FINDINGS : EXIT.OK;
251
260
  }
@@ -3,11 +3,10 @@ import { EXIT, version } from '../cli.js';
3
3
  import { loadConfig } from '../config.js';
4
4
  import { designArtifactCurrent, designPending } from '../design.js';
5
5
  import { reconcileRows } from '../drift.js';
6
- import { DEFAULT_BATTERIES } from '../gate.js';
7
6
  import { primaryRoot } from '../gitio.js';
8
7
  import { resolveHarness } from '../harness.js';
9
8
  import { effortAbandoned, effortStreams, latestRecap, readStream } from '../journal.js';
10
- import { loadMatrix, resolveModel } from '../model.js';
9
+ import { modelFloorLines } from '../model.js';
11
10
  import { computeNext, flowAction } from './next.js';
12
11
  import { renderRefusal } from '../refusal.js';
13
12
  import { pendingDecision } from '../rounds.js';
@@ -60,18 +59,11 @@ export async function run(ctx, _argv) {
60
59
  // Diagnostic surface: a broken harness config must not brick the dashboard —
61
60
  // `check` reports that as a finding, so the floor lines fall back to claude-code.
62
61
  const hxR = resolveHarness(ctx.env, cfg.value.raw);
63
- const matrix = loadMatrix(root, hxR.ok ? hxR.value.harness.name : 'claude-code');
64
- // one line per distinct warning, labeled with the gates it applies to —
65
- // per-gate model pins can put each gate in a different calibration state
66
- const byWarning = new Map();
67
- for (const gate of Object.keys(DEFAULT_BATTERIES)) {
68
- const modelR = resolveModel(cfg.value, matrix, gate);
69
- if (modelR.ok && modelR.value.warning) {
70
- byWarning.set(modelR.value.warning, [...(byWarning.get(modelR.value.warning) ?? []), gate]);
71
- }
72
- }
73
- for (const [warning, gates] of byWarning) {
74
- ctx.out(kv('model-floor', `${gates.join(' · ')}: ${warning}`));
62
+ // One line per distinct warning, labelled with the gates it applies to — per-gate
63
+ // model pins can put each gate in a different calibration state. Shared with
64
+ // `check` (D98a): the calibration fact must read the same on both surfaces.
65
+ for (const line of modelFloorLines(root, cfg.value, hxR.ok ? hxR.value.harness.name : 'claude-code')) {
66
+ ctx.out(kv('model-floor', line));
75
67
  }
76
68
  }
77
69
  const txn = pendingTxn(root);
@@ -9,6 +9,7 @@ import { acquireLock } from '../lock.js';
9
9
  import { crashPoint, guardTxn, withTxn } from '../txn.js';
10
10
  import { appendEntry, entryLine, journalRel, policyPins, readStream, streamExists } from '../journal.js';
11
11
  import { primaryRoot, stateCommit } from '../gitio.js';
12
+ import { effortOf } from '../reviewed.js';
12
13
  import { findById, loadCanon } from '../scan.js';
13
14
  import { newRunId } from '../drift.js';
14
15
  import { renderRefusal, v } from '../refusal.js';
@@ -41,6 +42,16 @@ export async function run(ctx, argv) {
41
42
  const entries = readStream(root, target);
42
43
  const last = lastGateRun(entries, gate);
43
44
  const pending = pendingDecision(entries, gate);
45
+ // Hoisted above `--show` (D94): both the anchor rule below and `--show`'s staleness
46
+ // line need `spec.currentSha`, which takes canon and config. A decision verb that
47
+ // cannot read the config cannot honestly report state either, so refusing here — and
48
+ // for `--show` too — is the correct order rather than a regression.
49
+ const cfgR = loadConfig(root);
50
+ if (!cfgR.ok) {
51
+ renderRefusal(cfgR.violations).forEach((l) => ctx.err(l));
52
+ return EXIT.REFUSED;
53
+ }
54
+ const canon = loadCanon(root);
44
55
  if (argv.includes('--show')) {
45
56
  if (!last) {
46
57
  ctx.out(kv('decide', `no gate-runs for ${gate} ${target}`));
@@ -53,7 +64,14 @@ export async function run(ctx, argv) {
53
64
  .map((e) => e);
54
65
  // A caused_by decision is a REOPEN, not a disposition — it can never settle the run
55
66
  // above it, and pairing the two is what presented 15 settled findings as current.
56
- const disposition = decisionsAfter.find((d) => d.caused_by === undefined);
67
+ // The LAST disposition is the state (D94): `find` returned the first, so a
68
+ // revise→approve reported `decision: revise` on a gate that is settled.
69
+ const disposition = decisionsAfter.filter((d) => d.caused_by === undefined).at(-1);
70
+ // Staleness is a fact about content, not about being reopened. Hardcoding it printed
71
+ // `witness gate …` in the one state where the gate answers changed-nothing. An
72
+ // uncomputable sha is NOT staleness — same doctrine as the approve-time check.
73
+ const shownSha = spec.currentSha?.(root, canon, cfgR.value, target);
74
+ const stale = shownSha !== undefined && shownSha !== last.reviewed_sha;
57
75
  if (reopen) {
58
76
  ctx.out(kv('gate', gate));
59
77
  ctx.out(kv('target', target));
@@ -62,7 +80,7 @@ export async function run(ctx, argv) {
62
80
  if (reopen.note)
63
81
  ctx.out(kv('note', reopen.note));
64
82
  ctx.out(kv('last-run', `round ${last.round} @${short(last.reviewed_sha)} — ${last.outcome}${disposition ? `, ${disposition.decision}` : ''} · witness log ${target}`));
65
- ctx.out(kv('exits', liveExits(gate, target, entries, true)));
83
+ ctx.out(kv('exits', liveExits(gate, target, entries, stale)));
66
84
  return EXIT.OK;
67
85
  }
68
86
  if (disposition && disposition.decision !== 'revise' && disposition.decision !== 'revise-upstream') {
@@ -72,6 +90,9 @@ export async function run(ctx, argv) {
72
90
  if (disposition.note)
73
91
  ctx.out(kv('note', disposition.note));
74
92
  ctx.out(kv('last-run', `round ${last.round} @${short(last.reviewed_sha)} — ${last.outcome} · witness log ${target}`));
93
+ // no exits: the gate is genuinely terminal here. Point at the one verb that owns
94
+ // what comes next rather than re-deriving routing in a second place (D101).
95
+ ctx.out('help: witness next');
75
96
  return EXIT.OK;
76
97
  }
77
98
  // pending (no disposition) or revise (the author's input): the verdict is actionable
@@ -84,7 +105,7 @@ export async function run(ctx, argv) {
84
105
  if (disposition.note)
85
106
  ctx.out(kv('note', disposition.note));
86
107
  }
87
- ctx.out(kv('exits', liveExits(gate, target, entries, false)));
108
+ ctx.out(kv('exits', liveExits(gate, target, entries, stale)));
88
109
  return EXIT.OK;
89
110
  }
90
111
  const picked = ['--approve', '--revise', '--stop'].filter((f) => argv.includes(f));
@@ -113,7 +134,27 @@ export async function run(ctx, argv) {
113
134
  // run, or the target livelocks (incident c2692b93)
114
135
  const boundEndgame = atBound && (decision === 'stop' || (decision === 'approve' && override) ||
115
136
  (decision === 'revise' && upstream !== undefined));
116
- const anchor = pending ?? (boundEndgame ? last : undefined);
137
+ // D94: a revise is the author's INPUT, not a disposition — it leaves the run
138
+ // undisposed, and with the content unchanged `gate` answers `changed-nothing` and
139
+ // appends nothing, so a pending decision can never reappear. Every exit the human is
140
+ // shown then refuses, and the only escape found in the field was an edit they did not
141
+ // need. The verdict still describes current content, so approving it is a true
142
+ // statement about bytes a battery read.
143
+ //
144
+ // `undefined` means the sha CANNOT be computed (no worktree, missing parent) and must
145
+ // never read as "moved" — the same doctrine the approve-time staleness check states
146
+ // below. That check still runs and still refuses a genuinely stale approve.
147
+ const afterLast = last
148
+ ? entries.slice(entries.lastIndexOf(last) + 1)
149
+ .filter((e) => e.t === 'human-decision' && e.gate === gate)
150
+ .map((e) => e)
151
+ : [];
152
+ const onlyRevises = afterLast.length > 0 &&
153
+ afterLast.every((d) => d.decision === 'revise' || d.decision === 'revise-upstream');
154
+ const nowSha = last !== undefined ? spec.currentSha?.(root, canon, cfgR.value, target) : undefined;
155
+ const unchanged = last !== undefined && (nowSha === undefined || nowSha === last.reviewed_sha);
156
+ const revisedAnchor = onlyRevises && unchanged && (decision === 'approve' || decision === 'stop');
157
+ const anchor = pending ?? ((boundEndgame || revisedAnchor) ? last : undefined);
117
158
  if (!anchor) {
118
159
  // at the bound "run the gate" is a lie — it would only short-circuit back
119
160
  // here; name the decisions that actually work instead
@@ -135,11 +176,6 @@ export async function run(ctx, argv) {
135
176
  renderRefusal([v('decision', 'override-required', 'approve at the round bound', 'witness decide … --approve --override')]).forEach((l) => ctx.err(l));
136
177
  return EXIT.REFUSED;
137
178
  }
138
- const cfgR = loadConfig(root);
139
- if (!cfgR.ok) {
140
- renderRefusal(cfgR.violations).forEach((l) => ctx.err(l));
141
- return EXIT.REFUSED;
142
- }
143
179
  // A standing stop is only human judgment if the human was shown the thing. The gate
144
180
  // refuses the same way, but the gate does not run at the round bound (gate.ts:176) —
145
181
  // and approve is the act that stamps, so the line has to hold here too. Sha-keyed:
@@ -152,7 +188,6 @@ export async function run(ctx, argv) {
152
188
  return EXIT.REFUSED;
153
189
  }
154
190
  }
155
- const canon = loadCanon(root);
156
191
  // A `human-decision` entry is a RECORD — honest about the sha it judged whatever the
157
192
  // tree does afterward, which is why D76 declined a write-time staleness check. A stamp
158
193
  // is an ASSERTION about current content (`status: approved`, `design: {sha}`), and D75
@@ -232,13 +267,33 @@ export async function run(ctx, argv) {
232
267
  renderRefusal([v('upstream', 'unknown-artifact', upstream, 'a canon doc id')]).forEach((l) => ctx.err(l));
233
268
  return EXIT.REFUSED;
234
269
  }
235
- else {
236
- const upGate = upDoc.meta.type === 'plan' ? 'plan' : 'decompose';
237
- entry.upstream = { artifact: upstream, gate: upGate };
270
+ else if (upDoc.meta.type === 'plan') {
271
+ entry.upstream = { artifact: upstream, gate: 'plan' };
238
272
  reopen = {
239
273
  stream: upstream,
240
274
  entry: {
241
- v: 1, t: 'human-decision', gate: upGate, artifact: upstream, round: anchor.round,
275
+ v: 1, t: 'human-decision', gate: 'plan', artifact: upstream, round: anchor.round,
276
+ decision: 'revise', caused_by: { artifact: target, gate, round: anchor.round },
277
+ ...(note ? { note } : {}),
278
+ },
279
+ };
280
+ }
281
+ else {
282
+ // Row 95: decompose gates are keyed on EFFORTS — `computeNext` asks
283
+ // `openReopen(effortEntries, 'decompose')` and nothing anywhere reads a spec's stream
284
+ // for one — so a decompose reopen written on the spec was unreachable by construction.
285
+ // Resolve the owner, exactly as the design gate's branch above already does.
286
+ const owner = effortOf(root, upstream);
287
+ if (owner === undefined) {
288
+ renderRefusal([v('upstream', 'unknown-owner', upstream, 'a spec some effort wrote — the decompose reopen is booked on the effort stream')])
289
+ .forEach((l) => ctx.err(l));
290
+ return EXIT.REFUSED;
291
+ }
292
+ entry.upstream = { artifact: owner, gate: 'decompose' };
293
+ reopen = {
294
+ stream: owner,
295
+ entry: {
296
+ v: 1, t: 'human-decision', gate: 'decompose', artifact: owner, round: anchor.round,
242
297
  decision: 'revise', caused_by: { artifact: target, gate, round: anchor.round },
243
298
  ...(note ? { note } : {}),
244
299
  },
@@ -300,6 +355,9 @@ export async function run(ctx, argv) {
300
355
  ctx.out(rows('pins', ['ordinal', 'text'], pins).join('\n'));
301
356
  if (note)
302
357
  ctx.out(kv('note', note));
358
+ if (upstream !== undefined && entry.upstream && upstream !== entry.upstream.artifact) {
359
+ ctx.out(kv('upstream', `${upstream} is owned by effort ${entry.upstream.artifact} — the decompose reopen is booked there`));
360
+ }
303
361
  if (gate === 'decompose')
304
362
  ctx.out(`help: scope itself implicated → witness recap --amend ${target}`);
305
363
  else if (entry.upstream)
@@ -7,8 +7,8 @@ import { primaryRoot } from '../gitio.js';
7
7
  import { findById, loadCanon } from '../scan.js';
8
8
  import { designArtifactCurrent, designPending, designUnseen } from '../design.js';
9
9
  import { effortAbandoned, effortStreams, latestRecap, readStream } from '../journal.js';
10
- import { effortOf, effortReviewedSha, effortSpecs, effortWrites, planPairSha, worktreeTreeSha } from '../reviewed.js';
11
- import { changedFiles, diffBase, evidenceForDiff } from '../evidence.js';
10
+ import { effortOf, effortReviewedSha, effortSpecs, effortWrites, implementReviewedSha, planPairSha } from '../reviewed.js';
11
+ import { changedFiles, diffBase, evidenceForDiff, isTestPath } from '../evidence.js';
12
12
  import { SESSION_DEFAULT, stagePin } from '../model.js';
13
13
  import { handoffLine, relayLine, resolveHarness } from '../harness.js';
14
14
  import { worktreeFlow, worktreePath } from '../worktree.js';
@@ -47,7 +47,7 @@ export function gateSettled(entries, gate, currentSha) {
47
47
  // immediately decline, and — since nothing is appended — `next` says it again next turn.
48
48
  // The work owed is AUTHORING, so route to the stage. The design stage already does this
49
49
  // via designArtifactCurrent; this is the same rule for decompose and plan.
50
- function authoringOwed(entries, gate, currentSha) {
50
+ export function authoringOwed(entries, gate, currentSha) {
51
51
  const last = lastGateRun(entries, gate);
52
52
  if (!last)
53
53
  return false;
@@ -55,15 +55,19 @@ function authoringOwed(entries, gate, currentSha) {
55
55
  return false;
56
56
  if (openReopen(entries, gate) !== undefined)
57
57
  return true;
58
- const after = entries.slice(entries.lastIndexOf(last) + 1);
59
- return after.some((e) => e.t === 'human-decision' &&
60
- e.gate === gate &&
61
- ['revise', 'revise-upstream'].includes(e.decision));
58
+ // The LAST decision is the state (D94). `.some()` read by presence, so an approve
59
+ // answering a revise left this still claiming authoring — the same read-by-position
60
+ // defect as decide --show's disposition.
61
+ const recent = entries.slice(entries.lastIndexOf(last) + 1)
62
+ .filter((e) => e.t === 'human-decision' && e.gate === gate)
63
+ .map((e) => e)
64
+ .at(-1);
65
+ return recent !== undefined && ['revise', 'revise-upstream'].includes(recent.decision);
62
66
  }
63
- // D75 re-arms a gate whose reviewed sha has moved, and `worktreeTreeSha` covers the
64
- // WHOLE worktree — so any state commit reaching it lapses the gate. That is correct, but
65
- // the row it produces is a bare `gate implement`, indistinguishable from a CLI stuck on
66
- // a stale answer. A human who just watched that gate pass reads it as the latter, and
67
+ // D75 re-arms a gate whose reviewed sha has moved — under row 96 that is the diff moving
68
+ // or the base moving, no longer any file in the worktree. Correct either way, but the row
69
+ // it produces is a bare `gate implement`, indistinguishable from a CLI stuck on a stale
70
+ // answer. A human who just watched that gate pass reads it as the latter, and
67
71
  // with a second session in the worktree answering `ship` the pair looks like a deadlock
68
72
  // with no error anywhere. The lapse is a fact the CLI already knows; say it.
69
73
  function lapseNote(entries, gate, currentSha) {
@@ -75,6 +79,42 @@ function lapseNote(entries, gate, currentSha) {
75
79
  return undefined;
76
80
  return `${gate} approval lapsed — judged @${last.reviewed_sha.slice(0, 7)}, worktree now @${currentSha.slice(0, 7)} — re-gate to judge the current tree`;
77
81
  }
82
+ // The owed evidence phase is a DERIVATION (row 85: a placeholder is honest only where
83
+ // the CLI genuinely cannot know the answer). `evidenceForDiff` already computes
84
+ // red/green/vacuous per tag on the way to the gate's own check — this reads that report
85
+ // instead of printing the menu and making the caller resolve it.
86
+ //
87
+ // `verify-red` is preferred where a plain red probe is guaranteed vacuous: with the
88
+ // implementation already written the suite passes as it stands, so the reconstructing
89
+ // verb is the only one that can witness a genuine red.
90
+ function evidenceRow(id, parentTag, files, report, seat) {
91
+ const base = { stage: 'implement', target: id, ...seat };
92
+ if (report === undefined) {
93
+ return {
94
+ line: `witness test-evidence ${id} --phase red`, ...base,
95
+ note: 'nothing changed yet — write the failing test first',
96
+ };
97
+ }
98
+ const owed = report.required
99
+ .filter((r) => !(r.red && r.green && !r.vacuous))
100
+ .map((r) => `${r.tag} ${!r.red || r.vacuous ? 'red' : 'green'}`)
101
+ .join(' · ');
102
+ const parent = report.required.find((r) => r.tag === parentTag);
103
+ const liveRed = parent !== undefined && parent.red && !parent.vacuous;
104
+ const line = !liveRed && files.some((f) => !isTestPath(f))
105
+ ? `witness verify-red ${id}`
106
+ : `witness test-evidence ${id} --phase ${liveRed ? 'green' : 'red'}`;
107
+ return { line, ...base, ...noteOf(owed === '' ? undefined : `evidence owed: ${owed}`) };
108
+ }
109
+ // One list for both readers. `flowAction` needs it to name an effort that can carry a plan
110
+ // write, and `computeNext` has always built exactly this — a second derivation here is the
111
+ // mistake rows 93 and 95 are both about.
112
+ function liveEfforts(root) {
113
+ return effortStreams(root)
114
+ .map((slug) => ({ slug, entries: readStream(root, slug) }))
115
+ .filter((e) => latestRecap(root, e.slug) !== undefined && !effortAbandoned(e.entries))
116
+ .sort((a, b) => a.slug.localeCompare(b.slug));
117
+ }
78
118
  // The action for ONE flow. A flow is a plan with status `in-progress`: it begins at
79
119
  // `witness start` and ends at the merge stamp. Anything else returns undefined and is
80
120
  // not a flow. Exported because `--flow` and the dashboard must answer with the SAME
@@ -94,22 +134,53 @@ export function flowAction(root, cfg, plan) {
94
134
  return { line: `witness start ${id}`, target: id, note: 'worktree missing — start recreates it' };
95
135
  if (plan.meta.pr !== undefined)
96
136
  return { line: `witness ship ${id}`, stage: 'ship', target: id, ...atRoot };
137
+ // Row 95: a reopen on this plan's OWN plan gate is routable motion — the plan is what
138
+ // needs re-authoring — and every reader was blind to it. Above the implement settle check
139
+ // on purpose: an implement gate settled against an earlier plan version must not carry
140
+ // the flow past a reopen saying the plan itself is wrong. Below the `pr` row on purpose
141
+ // too: a flow already at ship is answered by ship. No `home:` — plan authoring writes
142
+ // canon at the root, and this matches what computeNext's plans-first loop prints.
143
+ if (openReopen(entries, 'plan') !== undefined) {
144
+ const act = planWriteAction(root, liveEfforts(root), id, String(plan.meta.parent), id);
145
+ return { ...act, ...noteOf(act.note, 'plan gate reopened — re-author, then re-gate the plan') };
146
+ }
97
147
  const baseR = diffBase(wt, cfg);
98
148
  const files = baseR.ok ? changedFiles(wt, baseR.value) : [];
149
+ // Rows 95 + 96: the identity is the diff plus the plan's content, never the worktree, and
150
+ // it comes from the SAME derivation the implement gate keys on — deriving it here a second
151
+ // way is what made every settled gate read as lapsed. undefined means the base cannot be
152
+ // resolved — "cannot compute", never "moved", so a settled gate stays settled.
153
+ const diffSha = baseR.ok ? implementReviewedSha(wt, baseR.value, plan) : undefined;
154
+ // D93: the gate owns its deterministic checks; the router reads the verdict and never
155
+ // re-derives the predicate. Testing `evidence` in front of this is what made a human
156
+ // `--approve` invisible to the one verb the driving loop calls every turn — the
157
+ // journal said settled, `next` said test-evidence, and no error printed anywhere.
158
+ // Sha-sensitivity is load-bearing here: emptying the worktree after an approval moves
159
+ // the sha and re-arms the gate, which is why this keeps `diffSha` while
160
+ // `gates/ship.ts` deliberately omits it (row 92).
161
+ if (gateSettled(entries, 'implement', diffSha)) {
162
+ return { line: `witness ship ${id}`, stage: 'ship', target: id, ...atRoot };
163
+ }
99
164
  // evidenceForDiff is vacuously "satisfied" when nothing has changed yet (an empty
100
165
  // required-tags list trivially passes .every()) — a fresh worktree needs the
101
166
  // implement-stage hint too, not a premature jump to "gate implement".
102
- const satisfied = files.length > 0 && baseR.ok && evidenceForDiff(wt, root, plan, baseR.value).satisfied;
103
- if (!satisfied)
104
- return { line: `witness test-evidence ${id} --phase red|green`, stage: 'implement', target: id, ...inWorktree };
105
- const treeSha = worktreeTreeSha(wt);
106
- if (!gateSettled(entries, 'implement', treeSha)) {
167
+ const report = baseR.ok && files.length > 0 ? evidenceForDiff(wt, root, plan, baseR.value) : undefined;
168
+ if (report === undefined || !report.satisfied) {
169
+ return evidenceRow(id, String(plan.meta.parent), files, report, inWorktree);
170
+ }
171
+ // D94: with the content unchanged the gate answers `changed-nothing` and appends
172
+ // nothing, so routing there returns this same line every turn. The revise asked for an
173
+ // edit — say that, and seat the session where the edit happens.
174
+ if (authoringOwed(entries, 'implement', diffSha)) {
107
175
  return {
108
- line: `witness gate implement ${id}`, target: id, ...inWorktree,
109
- ...noteOf(lapseNote(entries, 'implement', treeSha)),
176
+ line: `witness test-evidence ${id} --phase green`, stage: 'implement', target: id, ...inWorktree,
177
+ note: 'revise owed — edit the code in the worktree · re-run the evidence cycle · then re-gate',
110
178
  };
111
179
  }
112
- return { line: `witness ship ${id}`, stage: 'ship', target: id, ...atRoot };
180
+ return {
181
+ line: `witness gate implement ${id}`, target: id, ...inWorktree,
182
+ ...noteOf(lapseNote(entries, 'implement', diffSha)),
183
+ };
113
184
  }
114
185
  // How far along a flow is — the drain order when several are actionable. Most-advanced
115
186
  // first is WIP-limiting, not cosmetic: it pushes flows toward merge instead of fanning
@@ -128,9 +199,16 @@ function flowRank(a) {
128
199
  // created). Such a flow is NOT tier-1 motion — offering it would loop the driving loop
129
200
  // on an action that only re-reports "awaiting decision", and would starve tier 2 of the
130
201
  // very decision that unblocks it.
131
- function flowBlocked(entries) {
132
- return ['plan', 'implement', 'ship'].some((gate) => pendingDecision(entries, gate) !== undefined ||
133
- (boundReached(entries, gate) && !gateSettled(entries, gate)));
202
+ function flowBlocked(root, plan, entries) {
203
+ if (['plan', 'implement', 'ship'].some((gate) => pendingDecision(entries, gate) !== undefined ||
204
+ (boundReached(entries, gate) && !gateSettled(entries, gate))))
205
+ return true;
206
+ // Row 95's split: a reopen on the plan's own plan gate is NOT a block — flowAction routes
207
+ // it — but a reopen on the PARENT's decompose is, because that work belongs to the effort
208
+ // and tier 3 is where it surfaces. Keyed on the effort that owns the PARENT SPEC, which is
209
+ // where `decide --revise --upstream <spec>` books it.
210
+ const effort = effortOf(root, String(plan.meta.parent));
211
+ return effort !== undefined && openReopen(readStream(root, effort), 'decompose') !== undefined;
134
212
  }
135
213
  // Every human-owed decision in the repo, in the order tier 2 would surface them. One
136
214
  // source for both tiers: tier 2 takes the first, tier 1 rides them all in `note:`.
@@ -198,10 +276,7 @@ export function computeNext(root, ctx, canon, cfg) {
198
276
  if (canon.errors.length > 0 || canon.docs.some((d) => d.violations.length > 0)) {
199
277
  return { line: 'witness check' };
200
278
  }
201
- const efforts = effortStreams(root)
202
- .map((slug) => ({ slug, entries: readStream(root, slug) }))
203
- .filter((e) => latestRecap(root, e.slug) !== undefined && !effortAbandoned(e.entries))
204
- .sort((a, b) => a.slug.localeCompare(b.slug));
279
+ const efforts = liveEfforts(root);
205
280
  // Hoisted above tier 1: both doc lists are read by every tier below, and they used to
206
281
  // be declared interleaved between the pending-decision scans.
207
282
  const plans = canon.docs.filter((d) => d.meta.type === 'plan')
@@ -212,7 +287,7 @@ export function computeNext(root, ctx, canon, cfg) {
212
287
  // on a human decision belonging to a DIFFERENT flow: ship always stops (gates/ship.ts),
213
288
  // so a global decision-halt freezes every other flow at the first ship gate.
214
289
  const flows = plans
215
- .filter((plan) => !flowBlocked(readStream(root, String(plan.meta.id))))
290
+ .filter((plan) => !flowBlocked(root, plan, readStream(root, String(plan.meta.id))))
216
291
  .map((plan) => flowAction(root, cfg, plan))
217
292
  .filter((a) => a !== undefined)
218
293
  .sort((a, b) => flowRank(b) - flowRank(a) || String(a.target).localeCompare(String(b.target)));
@@ -225,7 +225,7 @@ export async function run(ctx, argv) {
225
225
  ctx.out(kv('written', id));
226
226
  ctx.out(kv('path', rel));
227
227
  ctx.out(kv('sha', short(sha)));
228
- ctx.out(kv('status', 'draft'));
228
+ ctx.out(kv('status', String(built.meta.status)));
229
229
  ctx.out('next: witness check · witness index');
230
230
  return EXIT.OK;
231
231
  }
@@ -288,10 +288,29 @@ function buildPlanMeta(root, id, manifest, recap, canon, existing, body) {
288
288
  designFrom = stamp.sha;
289
289
  }
290
290
  }
291
+ // Row 95: a plan's lifecycle status is not re-derived by re-authoring it. `flowAction`
292
+ // now routes an in-flight plan's plan-gate reopen HERE, and a hardcoded `draft` would
293
+ // demote a plan that holds a `pr` and a live worktree out of flow-hood — `flowAction`,
294
+ // `flowBlocked`, `dashboard` and `--flow` would all lie about it for a window. Stated
295
+ // rather than hidden: `in-progress` no longer implies a settled plan gate. Plan-only —
296
+ // a re-authored SPEC still returns to draft.
297
+ //
298
+ // The rule is PRESERVE AN IN-FLIGHT STATUS, and the two terminal statuses part ways
299
+ // because only one of them is history. `done` refuses: that plan merged, and re-authoring
300
+ // it would rewrite what shipped. `abandoned` returns to `draft`, because row 85's recovery
301
+ // IS re-authoring an abandoned plan under a live effort — `abandon <effort>` stamps the
302
+ // effort's plans abandoned, `next` then names a live effort to carry the write, and
303
+ // refusing here would strand the very loop row 85 unstranded. A revived plan is new work,
304
+ // so it re-enters the ladder at draft rather than keeping a status it no longer has.
305
+ const existingStatus = existing ? String(existing.meta.status) : 'draft';
306
+ if (existingStatus === 'done') {
307
+ violations.push(v('status', 'terminal-status', existingStatus, 'a plan that has not merged — a done plan is superseded by a new plan, never re-authored'));
308
+ }
309
+ const priorStatus = existingStatus === 'abandoned' ? 'draft' : existingStatus;
291
310
  const meta = {
292
311
  id,
293
312
  type: 'plan',
294
- status: 'draft',
313
+ status: priorStatus,
295
314
  parent: parentId,
296
315
  'derives-from': computed,
297
316
  ...(designFrom !== undefined ? { 'design-from': designFrom } : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@popovych.co/witness",
3
- "version": "0.5.1",
3
+ "version": "0.6.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.5.1",
4
+ "version": "0.6.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.5.1}"
13
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.6.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.
@@ -43,4 +43,4 @@ Every turn is `witness next`, read, act on the first matching row, repeat:
43
43
  - An exit 2/3 you cannot mechanically satisfy — `needs-unmet` (show the human `witness satisfy <id> --need <text>`), `slug-reuse`, lock contention, calibration floor stops under `--manual`.
44
44
  - A skill ended its turn for a gate stop — that stop is yours too; do not restart the loop until the human decides.
45
45
 
46
- **Never merge a PR** — merging is the human's act on GitHub; the lazy stamp finishes the lifecycle on the next scan. **Never edit `specs/` or `plans/`** (or wherever the repo's `paths:` config relocates them) — that is what the skills' write path is for. If the dashboard warns the reviewer model is uncalibrated, surface the warning; under `--manual` treat it as a stop.
46
+ **Never merge a PR** — merging is the human's act on GitHub; the lazy stamp finishes the lifecycle on the next scan. **Never edit `specs/` or `plans/`** (or wherever the repo's `paths:` config relocates them) — that is what the skills' write path is for. Run `$WITNESS status` — the orientation screen (flows, blocked docs, reconcile rows, pending gates) — when you need position rather than the next action; bare `$WITNESS` prints the same screen. If it reports the reviewer model is uncalibrated, surface that; under `--manual` treat it as a stop.
@@ -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.5.1} 2>/dev/null || true
6
+ ${WITNESS_BIN:-npx -y @popovych.co/witness@0.6.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.5.1}"
18
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.6.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.5.1}"
13
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.6.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.5.1}"
19
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.6.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.5.1}"
18
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.6.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.)
@@ -48,6 +48,8 @@ Protocol per step, in order — red/green/refactor with witnessed evidence:
48
48
  6. Early exit: if you exceed **~15 inner-loop iterations** inside one step, or you have left the step's scope to fight infrastructure, the economy is to relay — finish the red→green you are in (never stop mid-red), report, and relay so a fresh context continues. Exceeding your slice is never the right economy.
49
49
  7. Fat artifacts (the living designs/<spec>.html, long docs) are read **by section** — grep for the anchors/ids you need, then read those offsets — never whole into your context; you would re-pay their tokens on every subsequent request.
50
50
 
51
+ **Tests you did not come to change.** When a step makes you edit a test file tagged for a spec other than your parent — a shared fixture, a helper, a suite-wide rename — you owe that spec **green now**, never a red→green pair: `red` means you observed the behavior missing before you built it, and you are not building that spec. The gate's `regression` check runs those specs' tagged tests and fails if any is red. Fix them in the worktree; never journal evidence under a tag this plan does not own, and never weaken the foreign test to pass.
52
+
51
53
  UI work TDDs at the browser, end-to-end: when a step changes what the browser renders or how the user interacts with it (markup, styles, routes, client-side behavior), the failing test in step 1 is a **Puppeteer** test driving the real UI **through the whole slice** — headless, tagged in its NAME like any test, living in the repo's regular suite so the criteria runner reaches it (Puppeteer is a library, not a runner — a test the runner can't reach has unwitnessable evidence). Everything the repo owns runs real: the test owns the slice's lifecycle — build, boot the backend and its store, serve the frontend; never assume a running dev server (evidence runs in a clean worktree) — but inside your own inner loop, keep the app server alive across iterations where the harness allows; the per-run floor is boot + compile, not test time. Fake only what the repo does not own (third-party services). Stubbing the slice's own backend — request interception, mocked fetch, canned API fixtures — turns the test UI-only, and the implement gate's pr-test lens flags it like any substitution. Unit/component tests may accompany a browser test, never replace it.
52
54
 
53
55
  **Capture the screens you build.** When `$WITNESS_SCREENS_DIR` is set (the CLI exports it during `test-evidence`), the browser test writes a named PNG per screen-level moment the step produces — `$WITNESS_SCREENS_DIR/<name>.png` via the driver's screenshot call. Name captures for the moments a reviewer must see, not every frame: `initial` (screen as it opens), `filled` (primary inputs populated), `error` (a validation/error state the slice can reach), `success` (the post-action confirmation). One capture per meaningful state; skip states the step doesn't touch. Guard the writes on the env var so the same test screenshots nothing during the gate's own drift lane. These captures are the design-reviewer's evidence — `test-evidence` witnesses a sha per PNG, and at the gate a **vision `design-reviewer`** (feature and fix batteries, only when the plan pins a `design-from` design) judges them against the repo's `docs.design` canon and the approved `designs/<spec>.html` look. A UI plan that witnesses zero captures is refused `screens-matched-nothing` before any reviewer runs — a screen with no screenshot has unwitnessable design evidence, exactly as a browser test outside the runner has unwitnessable behavior evidence.
@@ -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.5.1}"
18
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.6.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.5.1}"
18
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.6.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.)