@popovych.co/witness 0.5.1 → 0.8.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
@@ -81,12 +81,42 @@ npx @popovych.co/witness init --agent pi
81
81
  `<cwd>/.pi/skills` with no upward walk, and the implement stage runs with its cwd inside
82
82
  `.witness/worktrees/<plan-id>` — a directory the installer never touched. A
83
83
  project-scope install therefore loses every skill in the stage that does the most work.
84
- Global (`~/.pi/agent/skills`) is cwd-independent. `witness check` warns when the
85
- resolved harness cannot see all six skills.
84
+ Global (`~/.pi/agent/skills`) is cwd-independent. `witness check` warns when six skills
85
+ are installed where a worktree cannot see them, and states which harnesses have none.
86
86
 
87
87
  A tarball URL is version-pinned, so `skills update` cannot resolve forward: re-run `add`
88
88
  with the new version URL to upgrade.
89
89
 
90
+ ### Upgrading: skills first, then `init --agent`
91
+
92
+ Every surface that invokes witness pins the CLI — the engine prompt and all six skills
93
+ carry `npx -y @popovych.co/witness@<version>` — so a repo installed at an older release
94
+ only ever runs that release, which compares its payload against itself and reports clean.
95
+ The freeze is self-concealing, and a repo frozen at **0.6.0 or earlier cannot detect it**,
96
+ because the detection ships in a CLI that repo never invokes. Unstick it from outside, in
97
+ this order:
98
+
99
+ ```bash
100
+ npx skills@latest add https://registry.npmjs.org/@popovych.co/witness/-/witness-<new-version>.tgz
101
+ npx -y @popovych.co/witness@latest init --agent <name> # restamps the engine, guard and dashboard
102
+ ```
103
+
104
+ Skills first: their fresh pins are what invoke a CLI new enough to see the outdated
105
+ payload. Claude Code users on the marketplace plugin get both halves from
106
+ `/plugin marketplace add` and need no second step.
107
+
108
+ From **0.7.0** onward the order stops mattering: `witness check` asks the registry what
109
+ the published `latest` is and reports both halves of the skew — a CLI behind `latest`
110
+ (`cli-behind`) and any visible skill pinning something older (`skills-behind`). The query
111
+ is best-effort and silent on failure, so an offline machine reports nothing rather than a
112
+ complaint about the network.
113
+
114
+ `witness init --agent <name>` overwrites payload files it did not write and names what it
115
+ replaced (`payload-overwritten`); the previous content is one `git revert` away, because
116
+ witness commits the payload. It refuses the whole run if a payload path carries an
117
+ uncommitted change (`payload-dirty`) or if the CLI you are running is older than the
118
+ payload already installed (`cli-behind-payload`).
119
+
90
120
  ### Support tiers
91
121
 
92
122
  | Tier | Agents | What they get |
@@ -101,11 +131,11 @@ either.
101
131
 
102
132
  | Key | Meaning |
103
133
  |---|---|
104
- | `harness: claude-code \| pi` | fallback used only when detection cannot answer. Order: `PI_CODING_AGENT` → `CLAUDECODE` → `harness:` → `claude-code` |
134
+ | `harness: claude-code \| pi` | **the judge** — which harness runs this repo's gate reviewers, is measured by `calibrate`, and is probed by `check`. Judgment resolves `harness:` → `PI_CODING_AGENT` → `CLAUDECODE` → `claude-code`; session lines (`next`'s handoff, `dispatch`'s relay, `init --agent auto`) resolve detection first, because those name the CLI you are about to type at. Committed, so it binds every teammate's gates; `witness init --agent` writes it once and never re-points it. Undeclared repos are judged by whichever agent's session is open — `witness check` and `witness status` say so |
105
135
  | `gates.reviewerTimeoutMs` | milliseconds per reviewer invocation (default 600000) |
106
136
 
107
- There is no `provider:` key. `witness gate` spawns the RESOLVED harness's headless
108
- mode for every reviewer (Decision 88): claude-code renders bare Anthropic ids, pi
137
+ There is no `provider:` key. `witness gate` spawns the DECLARED harness's headless
138
+ mode for every reviewer (Decisions 88, 105): claude-code renders bare Anthropic ids, pi
109
139
  renders `provider/model[:thinking]` with the provider witness knows it needs. The pi
110
140
  reviewer runs hermetic (`--no-extensions --no-skills --no-context-files`); machine
111
141
  extensions it must keep — e.g. an OAuth adapter that supplies your Anthropic auth —
@@ -149,7 +179,8 @@ the wrong file refuses. There are no `WITNESS_*` env vars for configuration.
149
179
  | `sync` | pull --rebase + push state commits |
150
180
  | `calibrate <model>` | run the reviewer calibration battery |
151
181
  | `recover [--complete\|--rollback]` | resolve a crashed write transaction |
152
- | *(no verb)* | dashboard |
182
+ | `status` | orientation: flows, blocked docs, reconcile rows, pending gates, calibration state |
183
+ | *(no verb)* | the same screen as `status` |
153
184
 
154
185
  ## Exit codes
155
186
 
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/drift.js CHANGED
@@ -8,7 +8,7 @@ import { acquireLock } from './lock.js';
8
8
  import { appendEntry, entryLine, journalRel, readStream } from './journal.js';
9
9
  import { primaryRoot, stateCommit } from './gitio.js';
10
10
  import { renderRefusal, v, ok } from './refusal.js';
11
- import { resolveHarness } from './harness.js';
11
+ import { resolveJudge } from './harness.js';
12
12
  import { invokeReviewer, parseVerdictText, resolvePrompt } from './reviewer.js';
13
13
  import { runFullSuite, runnerConfig } from './runner.js';
14
14
  import { findById, loadCanon } from './scan.js';
@@ -120,7 +120,7 @@ export async function deepDrift(root, ctx, canon, specId) {
120
120
  // like every other. A silent claude fallback here would swap the reviewer identity
121
121
  // for pure-pi users on the one check that judges live specs.
122
122
  const cfgR = loadConfig(root);
123
- const hxR = resolveHarness(ctx.env, cfgR.ok ? cfgR.value.raw : {});
123
+ const hxR = resolveJudge(ctx.env, cfgR.ok ? cfgR.value.raw : {});
124
124
  if (!hxR.ok) {
125
125
  renderRefusal(hxR.violations).forEach(ctx.err);
126
126
  return EXIT.REFUSED;
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
@@ -8,18 +8,19 @@ import { appendEntry, entryLine, journalRel, policyPins, readStream } from './jo
8
8
  import { primaryRoot, stateCommit } from './gitio.js';
9
9
  import { loadCanon, findById } from './scan.js';
10
10
  import { newRunId } from './drift.js';
11
- import { resolveHarness } from './harness.js';
11
+ import { resolveJudge } from './harness.js';
12
12
  import { ok, refuse, renderRefusal, v } from './refusal.js';
13
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, fellBack, lastGateRun, roundsSinceApprove, runsSinceReset, } 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) {
@@ -163,7 +170,7 @@ export async function runGate(ctx, gateName, target, flags) {
163
170
  lens.docs = docs;
164
171
  lenses.push(lens);
165
172
  }
166
- const hxR = resolveHarness(ctx.env, cfgR.value.raw);
173
+ const hxR = resolveJudge(ctx.env, cfgR.value.raw);
167
174
  if (!hxR.ok) {
168
175
  renderRefusal(hxR.violations).forEach((l) => ctx.err(l));
169
176
  return EXIT.REFUSED;
@@ -183,15 +190,40 @@ 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}`);
198
+ // Row 106: the key is built BEFORE invoking, so the only model knowable here is the one
199
+ // being asked for. Naming it `pin` is what stops the entry's `model` — what actually
200
+ // answered — from being compared against it by accident, which is how the streak brake
201
+ // below spent three releases never firing.
202
+ const pin = chain[0];
189
203
  const key = {
190
204
  reviewed_sha: input.reviewedSha, gate: spec.gate,
191
- prompts_sha: promptsSha(lenses, pinsText === '' ? undefined : pinsText), model: chain[0], witness: version(),
205
+ prompts_sha: promptsSha(lenses, pinsText === '' ? undefined : pinsText), pin, witness: version(),
192
206
  harness: harness.name,
193
207
  };
208
+ // D99: `gateSettled` reads only the last run, so any new run un-settles the gate.
209
+ // Content moving is a legitimate, self-explaining reason. A flag is not — `--fresh`
210
+ // discarded a human decision with nothing printed and nothing journaled about it, and
211
+ // row 94 removed its other job (escaping the changed-nothing deadlock), so it can
212
+ // refuse and send the human through the verb that states a retraction and its reason.
213
+ const settledBefore = gateSettled(entries, spec.gate);
214
+ if (flags.fresh && settledBefore) {
215
+ renderRefusal([v('gate', 'settled-approve', `${spec.gate} ${target} is settled`, `witness decide ${spec.gate} ${target} --revise --note "<why>" — retract the approval, then re-gate`)])
216
+ .forEach((l) => ctx.err(l));
217
+ return EXIT.REFUSED;
218
+ }
194
219
  const kind = flags.fresh ? { kind: 'fresh' } : appendKind(entries, spec.gate, key);
220
+ // A key that moved for a NON-content reason — edited prompt, re-pinned model, new
221
+ // witness version — un-settles just as quietly as --fresh did. Content moving explains
222
+ // itself; this does not.
223
+ const lastRun = lastGateRun(entries, spec.gate);
224
+ if (settledBefore && kind.kind === 'fresh' && lastRun && lastRun.reviewed_sha === input.reviewedSha) {
225
+ ctx.err(`warning: reviewer setup changed — this run discards the settled approve on ${spec.gate} ${target}`);
226
+ }
195
227
  if (kind.kind === 'resume') {
196
228
  renderGateRun(ctx, kind.entry, 'resume');
197
229
  printDispatchArithmetic(ctx, root, spec.gate, target);
@@ -211,11 +243,30 @@ export async function runGate(ctx, gateName, target, flags) {
211
243
  return EXIT.BLOCKED;
212
244
  }
213
245
  if (!flags.fresh) {
214
- // malformed rounds don't spend the bound (rounds.ts) — this brake is what
215
- // stops an unreliable battery from re-running for free forever instead
216
- const tail = gateRuns(entries, spec.gate).slice(-2);
217
- const sameSetup = (r) => r.outcome === 'malformed' && r.model === key.model && r.prompts_sha === key.prompts_sha &&
218
- (r.harness ?? 'claude-code') === key.harness;
246
+ // Both brakes guard the SAME window as the round budget (rounds.ts): exempt rounds
247
+ // could otherwise repeat for free, and a run on the far side of an approve was
248
+ // disposed of and can trip nothing.
249
+ const tail = runsSinceReset(entries, spec.gate).slice(-2);
250
+ const samePin = (r) => (r.pin ?? r.model) === key.pin && (r.harness ?? 'claude-code') === key.harness;
251
+ // Row 107. A fallen-back round does not spend the budget, and an exempt round that
252
+ // repeats forever is D67's livelock — the exemption and this trigger are one change.
253
+ // `prompts_sha` is deliberately NOT compared: a lens edit has nothing to do with
254
+ // whether a model answers, and comparing it would let an unrelated edit reset a brake
255
+ // on a dead pin. Checked first because when both hold, "the pinned model is not
256
+ // answering" is the true remedy and "your battery emits invalid verdicts" is not.
257
+ // No --fresh in the want: --fresh bypasses the brake and would spend a battery
258
+ // re-invoking the same dead pin. Fixing the pin moves key.pin, samePin goes false,
259
+ // and the battery runs — which is only reachable BECAUSE these rounds were exempt.
260
+ if (tail.length === 2 && tail.every((r) => fellBack(r) && samePin(r))) {
261
+ renderRefusal([v(`gates.${spec.gate}.model`, 'fallback-streak', `${tail.length} consecutive rounds fell back from ${key.pin}`, 'a reachable gates.<gate>.model — the pinned model is not answering')])
262
+ .forEach((l) => ctx.err(l));
263
+ return EXIT.REFUSED;
264
+ }
265
+ // malformed rounds don't spend the bound either — this brake is what stops an
266
+ // unreliable battery from re-running for free forever instead. Row 106: "same setup"
267
+ // always meant the same PIN, and comparing the answering rung meant two malformed
268
+ // fallen-back rounds never matched, which is the one thing this exists to stop.
269
+ const sameSetup = (r) => r.outcome === 'malformed' && samePin(r) && r.prompts_sha === key.prompts_sha;
219
270
  if (tail.length === 2 && tail.every(sameSetup)) {
220
271
  renderRefusal([v('reviewers', 'malformed-streak', `${tail.length} consecutive malformed rounds on ${tail[1].model}`, `a changed gates.${spec.gate}.model pin or updated prompts — the battery is emitting invalid verdicts (or force with --fresh)`)]).forEach((l) => ctx.err(l));
221
272
  return EXIT.REFUSED;
@@ -231,7 +282,7 @@ export async function runGate(ctx, gateName, target, flags) {
231
282
  const fallback = [];
232
283
  const rerolled = [];
233
284
  let rung = 0;
234
- let model = chain[0];
285
+ let model = pin;
235
286
  let cached = false;
236
287
  if (kind.kind === 'cached') {
237
288
  verdicts = kind.from.verdicts ?? [];
@@ -282,14 +333,22 @@ export async function runGate(ctx, gateName, target, flags) {
282
333
  }
283
334
  }
284
335
  }
285
- if (fallback.length > 0 && chain[0] !== SESSION_DEFAULT && fallback.includes(chain[0])) {
286
- ctx.err(`warning: head model ${chain[0]} failed to invoke — reviewers ran on ${model}; check gates.${spec.gate}.model`);
287
- }
288
336
  const blocking = verdicts.flatMap((x) => x.findings).filter((f) => f.blocking).length;
289
337
  const pinConflicts = verdicts.flatMap((x) => x.findings).filter((f) => f.contradicts_pin !== undefined).length;
290
338
  const standing = [
291
339
  input.standingStop,
292
340
  pinConflicts > 0 ? 'contradicts-pin — a finding disputes a settled policy pin; the human decides which one dies' : undefined,
341
+ // Row 98c, specified by row 107. Composed HERE, after the battery, because whether a
342
+ // fallback happened is not knowable at resolve() time. Dismissed by a plain
343
+ // --approve: --override is reserved for the bound and this is a first-round event.
344
+ // The remedy is deliberately absent — on round one the honest statement is that a
345
+ // human decides whether the verdict counts; the remedy becomes true only once the pin
346
+ // proves persistently dead, which is what fallback-streak above prints. It REPLACES
347
+ // the stderr warning that fired on this same condition: unjournaled and non-blocking,
348
+ // it let a substituted verdict pass behind a line that scrolls by.
349
+ model !== pin
350
+ ? `fallback — reviewers ran on ${model}, not the pinned ${pin}; the human decides whether that verdict counts`
351
+ : undefined,
293
352
  ].filter((s) => s !== undefined).join(' · ') || undefined;
294
353
  const checksGreen = input.checks.every((c) => c.ok);
295
354
  const outcome = malformed.length > 0 ? 'malformed'
@@ -322,7 +381,7 @@ export async function runGate(ctx, gateName, target, flags) {
322
381
  v: 1, t: 'gate-run', gate: spec.gate, artifact: target,
323
382
  round: roundsSinceApprove(entriesNow, spec.gate) + 1, run_id: newRunId(),
324
383
  reviewed_sha: input.reviewedSha, prompts_sha: key.prompts_sha,
325
- witness: key.witness, model, harness: harness.name, calibration: calibrationOf(model),
384
+ witness: key.witness, model, pin, harness: harness.name, calibration: calibrationOf(model),
326
385
  ...(localR.value.reviewerExtensions.length ? { reviewer_extensions: localR.value.reviewerExtensions } : {}),
327
386
  ...(cached ? { cached: true } : {}),
328
387
  ...(flags.manual ? { manual: true } : {}),
@@ -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',