@popovych.co/witness 0.6.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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 —
package/dist/cli.js CHANGED
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { configPath } from './config.js';
4
4
  import { primaryRoot } from './gitio.js';
5
+ import { renderRefusal, v } from './refusal.js';
5
6
  export const EXIT = { OK: 0, FINDINGS: 1, REFUSED: 2, BLOCKED: 3 };
6
7
  const VERBS = {
7
8
  recover: () => import('./verbs/recover.js'),
@@ -41,7 +42,7 @@ const VERB_USAGE = {
41
42
  calibrate: 'witness calibrate <exact-model-id> [--suite all|reviewers|skills] [--only <name>] [--samples <n>] [--publish]',
42
43
  check: 'witness check',
43
44
  clean: 'witness clean',
44
- decide: 'witness decide <gate> <target> --approve|--revise|--stop [--override] [--note <t>] [--upstream <artifact|effort>] [--pin <policy>]… [--show]',
45
+ decide: 'witness decide <gate> <target> --approve|--revise|--stop [--override] [--repair] [--note <t>] [--upstream <artifact|effort>] [--pin <policy>]… [--show]',
45
46
  design: 'witness design <spec-id> --file <html> | --reconfirm | --open',
46
47
  diff: 'witness diff <spec-id>',
47
48
  'dispatch-report': 'witness dispatch-report <plan-id> --steps-assigned <n> --steps-completed <n> [--tokens <n>] [--tool-uses <n>] [--duration-ms <n>]',
@@ -99,5 +100,21 @@ export async function main(ctx, argv) {
99
100
  ctx.err(usage());
100
101
  return EXIT.REFUSED;
101
102
  }
102
- return (await load()).run(ctx, rest);
103
+ try {
104
+ return await (await load()).run(ctx, rest);
105
+ }
106
+ catch (e) {
107
+ // Row 113. Eight verbs parse argv with node's strict `parseArgs`, which THROWS on a
108
+ // stray positional or a mistyped flag. bin.ts's crash net then rendered a typo as
109
+ // `unexpected-error … a bug — re-run with the same arguments and report this line`:
110
+ // the tool asked to be reported for a mistake it could answer with its own usage. This
111
+ // is the one throw class that is a user statement rather than a witness fault, and the
112
+ // usage line lives right here. Anything else still reaches bin.ts unchanged.
113
+ const code = e.code;
114
+ if (typeof code === 'string' && code.startsWith('ERR_PARSE_ARGS')) {
115
+ renderRefusal([v(verb, 'bad-arguments', String(e.message).split('.')[0].slice(0, 160), VERB_USAGE[verb] ?? `witness ${verb} --help`)]).forEach((l) => ctx.err(l));
116
+ return EXIT.REFUSED;
117
+ }
118
+ throw e;
119
+ }
103
120
  }
package/dist/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/gate.js CHANGED
@@ -8,13 +8,14 @@ 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, lastGateRun, roundsSinceApprove, } from './rounds.js';
17
+ import { ROUND_BOUND, appendKind, boundReached, fellBack, lastGateRun, liveExits, roundBudget, roundsSinceApprove, runsSinceReset, } from './rounds.js';
18
+ export { liveExits } from './rounds.js';
18
19
  import { gateSettled } from './verbs/next.js';
19
20
  import { prepareStamp, writeStamp } from './stamp.js';
20
21
  const GATES = new Map();
@@ -40,27 +41,16 @@ export function batteryFor(cfg, gate, cls) {
40
41
  }
41
42
  return ok(picked);
42
43
  }
43
- // Which decisions are legal RIGHT NOW is a pure function of journal state, so it belongs
44
- // where the state is. Skills used to recite a fixed triple, which is wrong at the bound
45
- // (D67's endgame set) — and now wrong in three more states.
46
- export function liveExits(gate, target, entries, stale) {
47
- // The bound outranks staleness: at the bound the gate short-circuits and will not run
48
- // again (:227), so "re-gate" is the D67 lie whatever the sha says. Stale content only
49
- // removes APPROVE from the endgame — a human cannot honestly stamp bytes no battery
50
- // read — which is the same set decide's stale-verdict refusal names.
51
- if (boundReached(entries, gate)) {
52
- return stale
53
- ? `witness decide ${gate} ${target} --revise --upstream <id> | --stop`
54
- : `witness decide ${gate} ${target} --approve --override | --revise --upstream <id> | --stop`;
55
- }
56
- if (stale)
57
- return `witness gate ${gate} ${target}`;
58
- return `witness decide ${gate} ${target} --approve | --revise --note "<why>" | --revise --upstream <id> | --stop`;
59
- }
60
- export function renderGateRun(ctx, entry, mode) {
44
+ // `entries` is the stream AS OF AFTER this entry: without it the renderer cannot tell the
45
+ // round that spends the budget from any other, and rows 109/110 are both about that round.
46
+ // `help: false` is for callers that render their own `exits:` line (`decide --show`).
47
+ export function renderGateRun(ctx, entry, mode, opts = {}) {
48
+ const entries = opts.entries ?? [];
49
+ const budget = opts.entries ? roundBudget(entries, entry.gate) : ROUND_BOUND;
50
+ const atBound = opts.entries !== undefined && boundReached(entries, entry.gate);
61
51
  ctx.out(kv('gate', entry.gate));
62
52
  ctx.out(kv('target', entry.artifact));
63
- ctx.out(kv('round', `${entry.round} of ${ROUND_BOUND}${mode === 'resume' ? ' (resume — content unchanged)' : ''}`));
53
+ ctx.out(kv('round', `${entry.round} of ${budget}${mode === 'resume' ? ' (resume — content unchanged)' : ''}`));
64
54
  ctx.out(kv('reviewed', entry.reviewed_sha.slice(0, 7)));
65
55
  ctx.out(kv('model', `${entry.model} · calibration: ${entry.calibration}${entry.cached ? ' · cached' : ''}`));
66
56
  if (entry.skipped?.length)
@@ -84,11 +74,20 @@ export function renderGateRun(ctx, entry, mode) {
84
74
  ctx.out(kv('standing-stop', entry.standing));
85
75
  ctx.out(kv('outcome', entry.outcome));
86
76
  if (entry.outcome !== 'passed') {
87
- // `[]` keeps this a pure formatter over the entry it was handed — callers that know
88
- // the stream (`--show`) render their own `exits:` line from the real entries. The
89
- // non-bound triple is correct for runGate's own post-run render: a run that reached
90
- // the bound short-circuits before rendering.
91
- ctx.out(`help: ${liveExits(entry.gate, entry.artifact, [], false)}`);
77
+ // Row 110. The round that SPENDS the budget is the one that has to say so. It rendered
78
+ // like any other, so the natural next act — fix the finding, re-gate — was advertised
79
+ // by the help line below and then refused twice: the gate short-circuits, and the edit
80
+ // that answered the finding forfeits `--approve` under D75. Nothing warned, and the
81
+ // finding is itself an instruction to edit. Printed above the `help:` suppression, not
82
+ // inside it: `decide --show` renders its own exits and is the surface a human reads
83
+ // while deciding — the one place this warning is most owed.
84
+ if (atBound) {
85
+ ctx.out(kv('last-round', 'the round budget is spent — the gate will not run again, and an edit from here forfeits --approve (the verdict would describe an older tree)'));
86
+ }
87
+ // Without `entries` this stays a pure formatter over the entry it was handed, and the
88
+ // off-bound triple is the right answer; with them it tells the truth at the bound too.
89
+ if (opts.help !== false)
90
+ ctx.out(`help: ${liveExits(entry.gate, entry.artifact, entries, false)}`);
92
91
  }
93
92
  }
94
93
  // Row 79: the human approving a plan sees the run's shape before it starts.
@@ -139,9 +138,9 @@ export async function runGate(ctx, gateName, target, flags) {
139
138
  renderRefusal(batteryR.violations).forEach((l) => ctx.err(l));
140
139
  return EXIT.REFUSED;
141
140
  }
142
- const skip = new Set(input.skipLenses ?? []);
141
+ const skip = new Map((input.skipLenses ?? []).map((s) => [s.lens, s.why]));
143
142
  const active = batteryR.value.filter((n) => !skip.has(n));
144
- const dropped = batteryR.value.filter((n) => skip.has(n));
143
+ const dropped = batteryR.value.filter((n) => skip.has(n)).map((n) => `${n} — ${skip.get(n)}`);
145
144
  const entries = readStream(root, target);
146
145
  const pins = policyPins(entries);
147
146
  const pinsText = pinsBlock(pins);
@@ -170,7 +169,7 @@ export async function runGate(ctx, gateName, target, flags) {
170
169
  lens.docs = docs;
171
170
  lenses.push(lens);
172
171
  }
173
- const hxR = resolveHarness(ctx.env, cfgR.value.raw);
172
+ const hxR = resolveJudge(ctx.env, cfgR.value.raw);
174
173
  if (!hxR.ok) {
175
174
  renderRefusal(hxR.violations).forEach((l) => ctx.err(l));
176
175
  return EXIT.REFUSED;
@@ -195,9 +194,14 @@ export async function runGate(ctx, gateName, target, flags) {
195
194
  // (D98a). Only the caller's own pin being below the floor is news at run time.
196
195
  if (warning && warningKind === 'below-floor')
197
196
  ctx.err(`warning: ${warning}`);
197
+ // Row 106: the key is built BEFORE invoking, so the only model knowable here is the one
198
+ // being asked for. Naming it `pin` is what stops the entry's `model` — what actually
199
+ // answered — from being compared against it by accident, which is how the streak brake
200
+ // below spent three releases never firing.
201
+ const pin = chain[0];
198
202
  const key = {
199
203
  reviewed_sha: input.reviewedSha, gate: spec.gate,
200
- prompts_sha: promptsSha(lenses, pinsText === '' ? undefined : pinsText), model: chain[0], witness: version(),
204
+ prompts_sha: promptsSha(lenses, pinsText === '' ? undefined : pinsText), pin, witness: version(),
201
205
  harness: harness.name,
202
206
  };
203
207
  // D99: `gateSettled` reads only the last run, so any new run un-settles the gate.
@@ -211,7 +215,24 @@ export async function runGate(ctx, gateName, target, flags) {
211
215
  .forEach((l) => ctx.err(l));
212
216
  return EXIT.REFUSED;
213
217
  }
214
- const kind = flags.fresh ? { kind: 'fresh' } : appendKind(entries, spec.gate, key);
218
+ let kind = flags.fresh ? { kind: 'fresh' } : appendKind(entries, spec.gate, key);
219
+ // Row 112. The cache key is the REVIEWED content digest, but the deterministic checks
220
+ // read inputs it cannot see — decompose's `goal-coverage`/`spec-coverage` read journaled
221
+ // `covers`, `amendment-ack` reads a sibling plan's pin, `graph` reads the whole canon. So
222
+ // correcting a goal mapping left the doc bytes untouched, the key unmoved, and `resume`
223
+ // served a blocking finding describing a state that no longer existed; `--fresh` was the
224
+ // only escape, and an operator who did not think of it spent a human decision on it.
225
+ // `resolve` has already recomputed the checks by this point — the fresh answer was in
226
+ // hand and thrown away. A flip is a new judgment, so it appends a real round rather than
227
+ // silently re-rendering; the verdicts are REPLAYED (`cached`), so no battery is spent.
228
+ // Compared on `ok` alone: a detail carries run output on some gates (timings, paths), and
229
+ // keying on it would append a round for every re-run of an unchanged tree.
230
+ const checkOk = (cs) => cs.map((c) => `${c.name}:${c.ok}`).sort().join('|');
231
+ if ((kind.kind === 'resume' || kind.kind === 'changed-nothing') &&
232
+ checkOk(kind.entry.checks) !== checkOk(input.checks) &&
233
+ kind.entry.outcome !== 'malformed' && (kind.entry.verdicts?.length ?? 0) > 0) {
234
+ kind = { kind: 'cached', from: kind.entry };
235
+ }
215
236
  // A key that moved for a NON-content reason — edited prompt, re-pinned model, new
216
237
  // witness version — un-settles just as quietly as --fresh did. Content moving explains
217
238
  // itself; this does not.
@@ -220,7 +241,7 @@ export async function runGate(ctx, gateName, target, flags) {
220
241
  ctx.err(`warning: reviewer setup changed — this run discards the settled approve on ${spec.gate} ${target}`);
221
242
  }
222
243
  if (kind.kind === 'resume') {
223
- renderGateRun(ctx, kind.entry, 'resume');
244
+ renderGateRun(ctx, kind.entry, 'resume', { entries });
224
245
  printDispatchArithmetic(ctx, root, spec.gate, target);
225
246
  return kind.entry.outcome === 'passed' ? EXIT.OK : EXIT.FINDINGS;
226
247
  }
@@ -238,11 +259,30 @@ export async function runGate(ctx, gateName, target, flags) {
238
259
  return EXIT.BLOCKED;
239
260
  }
240
261
  if (!flags.fresh) {
241
- // malformed rounds don't spend the bound (rounds.ts) — this brake is what
242
- // stops an unreliable battery from re-running for free forever instead
243
- const tail = gateRuns(entries, spec.gate).slice(-2);
244
- const sameSetup = (r) => r.outcome === 'malformed' && r.model === key.model && r.prompts_sha === key.prompts_sha &&
245
- (r.harness ?? 'claude-code') === key.harness;
262
+ // Both brakes guard the SAME window as the round budget (rounds.ts): exempt rounds
263
+ // could otherwise repeat for free, and a run on the far side of an approve was
264
+ // disposed of and can trip nothing.
265
+ const tail = runsSinceReset(entries, spec.gate).slice(-2);
266
+ const samePin = (r) => (r.pin ?? r.model) === key.pin && (r.harness ?? 'claude-code') === key.harness;
267
+ // Row 107. A fallen-back round does not spend the budget, and an exempt round that
268
+ // repeats forever is D67's livelock — the exemption and this trigger are one change.
269
+ // `prompts_sha` is deliberately NOT compared: a lens edit has nothing to do with
270
+ // whether a model answers, and comparing it would let an unrelated edit reset a brake
271
+ // on a dead pin. Checked first because when both hold, "the pinned model is not
272
+ // answering" is the true remedy and "your battery emits invalid verdicts" is not.
273
+ // No --fresh in the want: --fresh bypasses the brake and would spend a battery
274
+ // re-invoking the same dead pin. Fixing the pin moves key.pin, samePin goes false,
275
+ // and the battery runs — which is only reachable BECAUSE these rounds were exempt.
276
+ if (tail.length === 2 && tail.every((r) => fellBack(r) && samePin(r))) {
277
+ 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')])
278
+ .forEach((l) => ctx.err(l));
279
+ return EXIT.REFUSED;
280
+ }
281
+ // malformed rounds don't spend the bound either — this brake is what stops an
282
+ // unreliable battery from re-running for free forever instead. Row 106: "same setup"
283
+ // always meant the same PIN, and comparing the answering rung meant two malformed
284
+ // fallen-back rounds never matched, which is the one thing this exists to stop.
285
+ const sameSetup = (r) => r.outcome === 'malformed' && samePin(r) && r.prompts_sha === key.prompts_sha;
246
286
  if (tail.length === 2 && tail.every(sameSetup)) {
247
287
  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));
248
288
  return EXIT.REFUSED;
@@ -258,7 +298,7 @@ export async function runGate(ctx, gateName, target, flags) {
258
298
  const fallback = [];
259
299
  const rerolled = [];
260
300
  let rung = 0;
261
- let model = chain[0];
301
+ let model = pin;
262
302
  let cached = false;
263
303
  if (kind.kind === 'cached') {
264
304
  verdicts = kind.from.verdicts ?? [];
@@ -309,14 +349,22 @@ export async function runGate(ctx, gateName, target, flags) {
309
349
  }
310
350
  }
311
351
  }
312
- if (fallback.length > 0 && chain[0] !== SESSION_DEFAULT && fallback.includes(chain[0])) {
313
- ctx.err(`warning: head model ${chain[0]} failed to invoke — reviewers ran on ${model}; check gates.${spec.gate}.model`);
314
- }
315
352
  const blocking = verdicts.flatMap((x) => x.findings).filter((f) => f.blocking).length;
316
353
  const pinConflicts = verdicts.flatMap((x) => x.findings).filter((f) => f.contradicts_pin !== undefined).length;
317
354
  const standing = [
318
355
  input.standingStop,
319
356
  pinConflicts > 0 ? 'contradicts-pin — a finding disputes a settled policy pin; the human decides which one dies' : undefined,
357
+ // Row 98c, specified by row 107. Composed HERE, after the battery, because whether a
358
+ // fallback happened is not knowable at resolve() time. Dismissed by a plain
359
+ // --approve: --override is reserved for the bound and this is a first-round event.
360
+ // The remedy is deliberately absent — on round one the honest statement is that a
361
+ // human decides whether the verdict counts; the remedy becomes true only once the pin
362
+ // proves persistently dead, which is what fallback-streak above prints. It REPLACES
363
+ // the stderr warning that fired on this same condition: unjournaled and non-blocking,
364
+ // it let a substituted verdict pass behind a line that scrolls by.
365
+ model !== pin
366
+ ? `fallback — reviewers ran on ${model}, not the pinned ${pin}; the human decides whether that verdict counts`
367
+ : undefined,
320
368
  ].filter((s) => s !== undefined).join(' · ') || undefined;
321
369
  const checksGreen = input.checks.every((c) => c.ok);
322
370
  const outcome = malformed.length > 0 ? 'malformed'
@@ -349,7 +397,7 @@ export async function runGate(ctx, gateName, target, flags) {
349
397
  v: 1, t: 'gate-run', gate: spec.gate, artifact: target,
350
398
  round: roundsSinceApprove(entriesNow, spec.gate) + 1, run_id: newRunId(),
351
399
  reviewed_sha: input.reviewedSha, prompts_sha: key.prompts_sha,
352
- witness: key.witness, model, harness: harness.name, calibration: calibrationOf(model),
400
+ witness: key.witness, model, pin, harness: harness.name, calibration: calibrationOf(model),
353
401
  ...(localR.value.reviewerExtensions.length ? { reviewer_extensions: localR.value.reviewerExtensions } : {}),
354
402
  ...(cached ? { cached: true } : {}),
355
403
  ...(flags.manual ? { manual: true } : {}),
@@ -396,7 +444,7 @@ export async function runGate(ctx, gateName, target, flags) {
396
444
  renderRefusal(txn.violations).forEach((l) => ctx.err(l));
397
445
  return EXIT.REFUSED;
398
446
  }
399
- renderGateRun(ctx, entry, 'ran');
447
+ renderGateRun(ctx, entry, 'ran', { entries: [...entriesNow, entry] });
400
448
  printDispatchArithmetic(ctx, root, spec.gate, target);
401
449
  if (input.repin)
402
450
  ctx.out(kv('re-pinned', `derives-from → ${input.repin.sha.slice(0, 7)} (witnessed by the drift lane)`));
@@ -104,7 +104,7 @@ registerGate({
104
104
  }
105
105
  else {
106
106
  // not UI-work: drop the lens if a battery names it (stages shrink, never skip)
107
- skipLenses.push('design-reviewer');
107
+ skipLenses.push({ lens: 'design-reviewer', why: 'no design-from pin: not UI work' });
108
108
  }
109
109
  const checks = [];
110
110
  checks.push({ name: 'diff', ok: files.length > 0,
package/dist/harness.js CHANGED
@@ -1,7 +1,8 @@
1
- import { existsSync } from 'node:fs';
1
+ import { existsSync, readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { CLAUDE_THINKING_BUDGET, parsePin } from './pin.js';
4
4
  import { ok, refuse, v } from './refusal.js';
5
+ import { pinIn } from './version.js';
5
6
  export const HARNESSES = ['claude-code', 'pi'];
6
7
  export const DEFAULT_HARNESS = 'claude-code';
7
8
  // The six shipped stage skills, by directory name. `check`'s visibility probe asks
@@ -150,33 +151,59 @@ export function loadHarness(name) {
150
151
  }
151
152
  return ok(harness);
152
153
  }
153
- // Decision 5. Detection is the authority; config is the fallback. A config-authority
154
- // default in a fresh repo emits a runnable-LOOKING, unrunnable handoff behind a warning
155
- // that gets scrolled past — bug B2's exact shape. Detection tests PRESENCE: neither
156
- // CLAUDECODE=1 nor PI_CODING_AGENT=true is a documented value contract.
157
- //
158
- // Deliberately NOT wired into loadConfig: every verb calls that, so an invalid
159
- // `harness:` there would brick `witness check` on a key nothing read. Verbs that need
160
- // a harness ask for one; `check` reports a malformed config value as a finding.
161
- //
162
- // Row 90 removed the WITNESS_HARNESS env rung: configuration has one home, and tests
163
- // simulate harnesses by setting the detection vars production actually reads.
164
- export function resolveHarness(env, raw) {
165
- if (env.PI_CODING_AGENT !== undefined) {
166
- const r = loadHarness('pi');
167
- return r.ok ? ok({ harness: r.value, source: 'detected' }) : refuse(r.violations);
168
- }
169
- if (env.CLAUDECODE !== undefined) {
170
- const r = loadHarness('claude-code');
171
- return r.ok ? ok({ harness: r.value, source: 'detected' }) : refuse(r.violations);
172
- }
154
+ // Detection tests PRESENCE: neither CLAUDECODE=1 nor PI_CODING_AGENT=true is a documented
155
+ // value contract.
156
+ function detectedRung(env) {
157
+ if (env.PI_CODING_AGENT !== undefined)
158
+ return { source: 'detected', name: 'pi' };
159
+ if (env.CLAUDECODE !== undefined)
160
+ return { source: 'detected', name: 'claude-code' };
161
+ return undefined;
162
+ }
163
+ function declaredRung(raw) {
173
164
  const configured = raw.harness;
174
- if (configured !== undefined) {
175
- const r = loadHarness(String(configured));
176
- return r.ok ? ok({ harness: r.value, source: 'config' }) : refuse(r.violations);
165
+ return configured === undefined ? undefined : { source: 'config', name: String(configured) };
166
+ }
167
+ function walk(ladder) {
168
+ const rung = ladder.find((r) => r !== undefined)
169
+ ?? { source: 'default', name: DEFAULT_HARNESS };
170
+ const r = loadHarness(rung.name);
171
+ return r.ok ? ok({ harness: r.value, source: rung.source }) : refuse(r.violations);
172
+ }
173
+ // THE JUDGE — which harness runs this repo's gate reviewers, reads its calibration matrix
174
+ // and is probed for runnability. A committed declaration wins, because reviewer identity
175
+ // is a property of the repo's evidence trail and must be comparable across machines; row
176
+ // 88 already said an identity chosen by ambient environment is the opposite of a pin.
177
+ export function resolveJudge(env, raw) {
178
+ return walk([declaredRung(raw), detectedRung(env)]);
179
+ }
180
+ // THE DRIVER — which CLI is about to be typed at. Detection wins, because a launch or
181
+ // relay line is a fact about the session that will run it, and a config-authority default
182
+ // in a fresh repo emits a runnable-LOOKING, unrunnable handoff behind a warning that gets
183
+ // scrolled past — bug B2's exact shape.
184
+ export function resolveDriver(env, raw) {
185
+ return walk([detectedRung(env), declaredRung(raw)]);
186
+ }
187
+ // One renderer for both orientation surfaces, on modelFloorLines' precedent: the judge
188
+ // must read identically on `check` and `status`, or the two screens disagree about which
189
+ // binary judges the same repo. Provenance is RENDERED here, never stored — HarnessSource
190
+ // stays a resolution fact and the wording stays a presentation one.
191
+ //
192
+ // It prints in every state, including `declared`: that line read from a foreign session
193
+ // is exactly what row 105 exists to surface, and a line that appeared only in the bad
194
+ // state would teach people to read its absence as "nothing to know" — the confident
195
+ // silence row 104 spent a release killing. The nudge is not decoration: an UNDECLARED
196
+ // repo's judge still flips with the terminal, and declaring is the only thing that ends it.
197
+ export function judgeLine(r) {
198
+ if (!r.ok) {
199
+ // A diagnostic surface must not brick on a broken key — `check` reports it as a
200
+ // finding and the floor falls back — so say what was fallen back TO, and to what.
201
+ return `${DEFAULT_HARNESS} (default — harness: ${r.violations[0]?.got ?? '?'} is unreadable; witness check reports it)`;
177
202
  }
178
- const r = loadHarness(DEFAULT_HARNESS);
179
- return r.ok ? ok({ harness: r.value, source: 'default' }) : refuse(r.violations);
203
+ const nudge = 'undeclared; set harness: in witness.config.yaml to pin it';
204
+ if (r.value.source === 'config')
205
+ return `${r.value.harness.name} (declared in witness.config.yaml)`;
206
+ return `${r.value.harness.name} (${r.value.source} — ${nudge})`;
180
207
  }
181
208
  // Decision 9: the model flag is a renderer, not a string. Pi's default provider is
182
209
  // `google`, so a bare `--model claude-opus-5` in a Pi handoff resolves wrong or not at
@@ -222,14 +249,35 @@ export function relayLine(harness) {
222
249
  // upward walk, and implement runs with cwd inside .witness/worktrees/<plan-id> — an
223
250
  // untracked directory the installer never touched. A project-scope install therefore
224
251
  // loses every skill in the stage that does the most work.
225
- export function skillsVisibility(env, root, harness) {
252
+ //
253
+ // Row 103 needs the DIRECTORY, not just the verdict: with `latest` in hand, one pass
254
+ // reports both halves of the skew, and the skills half has to read the files it found.
255
+ export function resolveSkills(env, root, harness) {
226
256
  const home = env.HOME ?? env.USERPROFILE ?? '';
227
257
  const has = (dir) => STAGE_SKILLS.every((s) => existsSync(join(dir, s, 'SKILL.md')));
228
- if (home !== '' && has(join(home, harness.skills.global)))
229
- return 'global';
230
- if (has(join(root, harness.skills.project)))
231
- return 'project-only';
232
- return 'absent';
258
+ const global = home === '' ? undefined : join(home, harness.skills.global);
259
+ if (global !== undefined && has(global))
260
+ return { scope: 'global', dir: global };
261
+ const project = join(root, harness.skills.project);
262
+ if (has(project))
263
+ return { scope: 'project-only', dir: project };
264
+ return { scope: 'absent' };
265
+ }
266
+ export function skillsVisibility(env, root, harness) {
267
+ return resolveSkills(env, root, harness).scope;
268
+ }
269
+ // What pin does each installed skill carry? Nothing more — whether a pin is a PROBLEM
270
+ // takes the published `latest`, which is check's question and check's alone. A skill
271
+ // whose SKILL.md carries no pin is simply absent from the result: the pin is what makes
272
+ // a skill invoke a particular CLI, so one without it says nothing about skew.
273
+ export function skillPins(dir) {
274
+ return STAGE_SKILLS.flatMap((skill) => {
275
+ const p = join(dir, skill, 'SKILL.md');
276
+ if (!existsSync(p))
277
+ return [];
278
+ const pin = pinIn(readFileSync(p, 'utf8'));
279
+ return pin === undefined ? [] : [{ skill, pin }];
280
+ });
233
281
  }
234
282
  // Harness-compat rung of pin validation. Grammar and alias checks live in stagePin
235
283
  // (model.ts); THIS check needs the harness, which stagePin deliberately never resolves.