@popovych.co/witness 0.6.0 → 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 —
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,13 @@ 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, roundsSinceApprove, runsSinceReset, } from './rounds.js';
18
18
  import { gateSettled } from './verbs/next.js';
19
19
  import { prepareStamp, writeStamp } from './stamp.js';
20
20
  const GATES = new Map();
@@ -170,7 +170,7 @@ export async function runGate(ctx, gateName, target, flags) {
170
170
  lens.docs = docs;
171
171
  lenses.push(lens);
172
172
  }
173
- const hxR = resolveHarness(ctx.env, cfgR.value.raw);
173
+ const hxR = resolveJudge(ctx.env, cfgR.value.raw);
174
174
  if (!hxR.ok) {
175
175
  renderRefusal(hxR.violations).forEach((l) => ctx.err(l));
176
176
  return EXIT.REFUSED;
@@ -195,9 +195,14 @@ export async function runGate(ctx, gateName, target, flags) {
195
195
  // (D98a). Only the caller's own pin being below the floor is news at run time.
196
196
  if (warning && warningKind === 'below-floor')
197
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];
198
203
  const key = {
199
204
  reviewed_sha: input.reviewedSha, gate: spec.gate,
200
- prompts_sha: promptsSha(lenses, pinsText === '' ? undefined : pinsText), model: chain[0], witness: version(),
205
+ prompts_sha: promptsSha(lenses, pinsText === '' ? undefined : pinsText), pin, witness: version(),
201
206
  harness: harness.name,
202
207
  };
203
208
  // D99: `gateSettled` reads only the last run, so any new run un-settles the gate.
@@ -238,11 +243,30 @@ export async function runGate(ctx, gateName, target, flags) {
238
243
  return EXIT.BLOCKED;
239
244
  }
240
245
  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;
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;
246
270
  if (tail.length === 2 && tail.every(sameSetup)) {
247
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));
248
272
  return EXIT.REFUSED;
@@ -258,7 +282,7 @@ export async function runGate(ctx, gateName, target, flags) {
258
282
  const fallback = [];
259
283
  const rerolled = [];
260
284
  let rung = 0;
261
- let model = chain[0];
285
+ let model = pin;
262
286
  let cached = false;
263
287
  if (kind.kind === 'cached') {
264
288
  verdicts = kind.from.verdicts ?? [];
@@ -309,14 +333,22 @@ export async function runGate(ctx, gateName, target, flags) {
309
333
  }
310
334
  }
311
335
  }
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
336
  const blocking = verdicts.flatMap((x) => x.findings).filter((f) => f.blocking).length;
316
337
  const pinConflicts = verdicts.flatMap((x) => x.findings).filter((f) => f.contradicts_pin !== undefined).length;
317
338
  const standing = [
318
339
  input.standingStop,
319
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,
320
352
  ].filter((s) => s !== undefined).join(' · ') || undefined;
321
353
  const checksGreen = input.checks.every((c) => c.ok);
322
354
  const outcome = malformed.length > 0 ? 'malformed'
@@ -349,7 +381,7 @@ export async function runGate(ctx, gateName, target, flags) {
349
381
  v: 1, t: 'gate-run', gate: spec.gate, artifact: target,
350
382
  round: roundsSinceApprove(entriesNow, spec.gate) + 1, run_id: newRunId(),
351
383
  reviewed_sha: input.reviewedSha, prompts_sha: key.prompts_sha,
352
- witness: key.witness, model, harness: harness.name, calibration: calibrationOf(model),
384
+ witness: key.witness, model, pin, harness: harness.name, calibration: calibrationOf(model),
353
385
  ...(localR.value.reviewerExtensions.length ? { reviewer_extensions: localR.value.reviewerExtensions } : {}),
354
386
  ...(cached ? { cached: true } : {}),
355
387
  ...(flags.manual ? { manual: true } : {}),
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.
package/dist/install.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
+ import { version } from './cli.js';
4
5
  import { tryGit } from './gitio.js';
5
6
  import { ok, refuse, v } from './refusal.js';
7
+ import { NPX_LATEST, compareTriple, pinIn } from './version.js';
6
8
  // dist/install.js and src/install.ts both sit one level under the package root, so the
7
9
  // same '..' works built and under vitest — the pattern model.ts:14 already uses. This is
8
10
  // the only place that still needs it: locating the shipped `plugin/` tree, which is a
@@ -15,42 +17,83 @@ export function packageRoot() {
15
17
  // hook commands (verified against 2.1.220).
16
18
  const GUARD_CMD = 'node "$CLAUDE_PROJECT_DIR/.claude/hooks/guard-state.mjs"';
17
19
  const DASHBOARD_CMD = 'sh "$CLAUDE_PROJECT_DIR/.claude/hooks/session-dashboard.sh"';
18
- const PIN = /@popovych\.co\/witness@\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/g;
19
- // Revision 6. Committing the payload is load-bearing: the implement stage runs with cwd
20
- // inside .witness/worktrees/<plan-id>, which is a checkout of the branch, so only
21
- // committed files reach it. A gitignored target therefore has exactly one honest answer
22
- // — refuse. `git add -f` would override a rule the human wrote down; writing without
23
- // committing would leave the payload in the primary root and the worktree with no guard,
24
- // while every `check` reads clean. Called BEFORE the lock and before any scaffold write,
25
- // so a refusal leaves nothing half-installed.
20
+ // The engine file is the one payload entry every harness carries, and its pin is what
21
+ // decides which CLI the whole pipeline runs — so it is also the only file whose pin can
22
+ // answer "which version installed what is here".
23
+ const ENGINE_SOURCE = 'plugin/commands/witness.md';
24
+ // Revision 6, extended by row 102. Committing the payload is load-bearing: the implement
25
+ // stage runs with cwd inside .witness/worktrees/<plan-id>, which is a checkout of the
26
+ // branch, so only committed files reach it. A gitignored target therefore has exactly one
27
+ // honest answer — refuse. `git add -f` would override a rule the human wrote down; writing
28
+ // without committing would leave the payload in the primary root and the worktree with no
29
+ // guard, while every `check` reads clean.
30
+ //
31
+ // Row 102 adds two more preconditions, because the rule that spared an edited file is
32
+ // gone and the write now clobbers:
33
+ //
34
+ // payload-dirty — a clobbered edit is recoverable only from git, so an uncommitted
35
+ // payload change must refuse rather than be overwritten into
36
+ // nothing. --untracked-files=all, so a present-but-untracked
37
+ // payload counts.
38
+ // cli-behind-payload — an older CLI running in a repo a newer one installed would
39
+ // REVERT the payload, re-freezing the repo one version further
40
+ // back. Equal triples write (the witness-developer case);
41
+ // prerelease ordering is out of scope.
42
+ //
43
+ // All three refuse the WHOLE run, called BEFORE the lock and before any scaffold write,
44
+ // so a refusal leaves nothing half-installed — and a half-upgraded payload set (guard at
45
+ // one version, engine at another) is precisely the skew row 102 exists to close.
46
+ //
47
+ // harness.settings is deliberately outside the dirty guard: mergeSettings appends and
48
+ // never clobbers, so a dirty settings file is not at risk.
26
49
  export function preflightPayload(root, harness) {
27
- const ignored = [...harness.payload.map((p) => p.to), ...(harness.settings ? [harness.settings] : [])]
50
+ const targets = harness.payload.map((p) => p.to);
51
+ const ignored = [...targets, ...(harness.settings ? [harness.settings] : [])]
28
52
  .filter((rel) => tryGit(root, 'check-ignore', '-q', '--', rel).ok);
29
- if (ignored.length === 0)
30
- return ok(undefined);
31
- return refuse(ignored.map((rel) => v(rel, 'payload-ignored', 'matched by .gitignore', 'a committable path — worktrees are branch checkouts, so only committed payloads reach ' +
32
- '.witness/worktrees/<plan-id>; un-ignore it, or install a different agent here')));
33
- }
34
- // A shipped file and an installed file that differ ONLY by version pin are the same
35
- // file, one upgrade apart. Substituting the installed pin into the shipped text and
36
- // comparing is exact — no heuristics, no diffing.
37
- function pinOnlyDifference(shipped, installed) {
38
- const installedPin = installed.match(PIN)?.[0];
39
- if (installedPin === undefined)
40
- return false;
41
- return shipped.replace(PIN, installedPin) === installed;
53
+ if (ignored.length > 0) {
54
+ return refuse(ignored.map((rel) => v(rel, 'payload-ignored', 'matched by .gitignore', 'a committable path — worktrees are branch checkouts, so only committed payloads reach ' +
55
+ '.witness/worktrees/<plan-id>; un-ignore it, or install a different agent here')));
56
+ }
57
+ // A failed status call is not evidence of dirt: primaryRoot already proved this is a
58
+ // repo, and inventing a refusal out of a git error would block the upgrade this row
59
+ // exists to deliver.
60
+ const status = tryGit(root, 'status', '--porcelain', '--untracked-files=all', '--', ...targets);
61
+ const dirty = status.ok
62
+ ? status.out.split('\n').filter((l) => l !== '').map((l) => l.slice(3).trim())
63
+ : [];
64
+ // The remedy has to cover both authors of the dirt. `init` writes the payload and
65
+ // commits it under a lock but NOT a transaction (verbs/init.ts), so a crash between
66
+ // the two leaves a dirty payload the human never wrote — and telling them to commit it
67
+ // would be telling them to commit bytes they did not author. Name both cases.
68
+ if (dirty.length > 0) {
69
+ return refuse(dirty.map((rel) => v(rel, 'payload-dirty', 'uncommitted change on a payload path', 'a committed payload tree — init overwrites payload files now, and a clobbered edit is ' +
70
+ 'recoverable only from git; commit it if the change is yours, or revert it — a payload ' +
71
+ 'left dirty by a crashed init should be reverted, never committed')));
72
+ }
73
+ const engine = harness.payload.find((p) => p.from === ENGINE_SOURCE);
74
+ const installedPin = engine !== undefined && existsSync(join(root, engine.to))
75
+ ? pinIn(readFileSync(join(root, engine.to), 'utf8'))
76
+ : undefined;
77
+ // `?? 0` is the "cannot compare, so do not refuse" rule: an unparseable pin must never
78
+ // block an upgrade, exactly as compareTriple's undefined contract states.
79
+ if (engine !== undefined && installedPin !== undefined && (compareTriple(version(), installedPin) ?? 0) < 0) {
80
+ return refuse([v(engine.to, 'cli-behind-payload', `payload pins ${installedPin}, this CLI is ${version()}`, `a CLI at or ahead of the installed payload — run ${NPX_LATEST} init --agent ${harness.name}`)]);
81
+ }
82
+ return ok(undefined);
42
83
  }
43
- // Revision 1: SYNC, not install-once. These files are witness's artifacts that happen
44
- // to live in the user's repo, and the engine file's pin is the single point deciding
45
- // which CLI version the entire pipeline runs — so an install-once rule pinned every repo
46
- // to whatever version first touched it, forever, with `payload: already installed`
47
- // printed on every attempt to fix it. A file the human actually edited is still theirs:
48
- // report it, never clobber it.
84
+ // Revision 1: SYNC, not install-once. Row 102: three-way, not four. The payload files
85
+ // are witness's artifacts that happen to live in the user's repo — the engine file's pin
86
+ // is the single point deciding which CLI the entire pipeline runs, and there is no
87
+ // sanctioned way to customise any of them (the human's config home is
88
+ // witness.config.yaml, repo prose reaches reviewers through row 68's docs: registry).
89
+ // The rule that spared an edited file was defending an unsupported hack while the file
90
+ // it defended froze the repo forever, so it is gone: differing content is overwritten
91
+ // and NAMED, one `git revert` away because row 87 already commits the payload.
49
92
  export function installPayload(root, harness) {
50
93
  const pre = preflightPayload(root, harness);
51
94
  if (!pre.ok)
52
95
  return refuse(pre.violations);
53
- const out = { written: [], restamped: [], modified: [] };
96
+ const out = { written: [], overwritten: [] };
54
97
  for (const { from, to } of harness.payload) {
55
98
  const src = join(packageRoot(), from);
56
99
  if (!existsSync(src)) {
@@ -64,15 +107,10 @@ export function installPayload(root, harness) {
64
107
  out.written.push(to);
65
108
  continue;
66
109
  }
67
- const installed = readFileSync(dst, 'utf8');
68
- if (installed === shipped)
110
+ if (readFileSync(dst, 'utf8') === shipped)
69
111
  continue;
70
- if (pinOnlyDifference(shipped, installed)) {
71
- writeFileSync(dst, shipped);
72
- out.restamped.push(to);
73
- continue;
74
- }
75
- out.modified.push(to);
112
+ writeFileSync(dst, shipped);
113
+ out.overwritten.push(to);
76
114
  }
77
115
  return ok(out);
78
116
  }
@@ -113,14 +151,25 @@ export function mergeSettings(existing) {
113
151
  doc.hooks = { ...hooks, PreToolUse: pre, SessionStart: start };
114
152
  return { text: `${JSON.stringify(doc, null, 2)}\n`, changed: true };
115
153
  }
116
- // The config rung of Decision 5's ladder. Recorded once, on the run that installs the
117
- // first payload set; a later `--agent` for a second harness leaves it alone, because
118
- // detection outranks it anyway and rewriting config is not what that run was asked for.
154
+ // The config rung of Decision 5's ladder, and rung ONE of Decision 105's judgment ladder.
155
+ // Recorded once, on the run that installs the first payload set; a later `--agent` for a
156
+ // second harness leaves it alone, because installing a payload set is not being asked to
157
+ // re-point every subsequent verdict. `declared` is what lets the caller SAY so when the
158
+ // two disagree — `{changed: false}` alone cannot tell "already says this" from "says
159
+ // something else", which is why the mismatch was silent.
160
+ //
161
+ // The anchor matters: a commented `# harness: pi` in the scaffold must NOT read as a
162
+ // declaration. Relaxing this to /harness:/ would turn documentation into a permanent,
163
+ // write-once declaration on every repo that ran `witness init`.
119
164
  export function recordHarness(configText, name) {
120
- if (/^harness:/m.test(configText))
121
- return { text: configText, changed: false };
165
+ const found = /^harness:\s*(\S+)/m.exec(configText);
166
+ if (found)
167
+ return { text: configText, changed: false, declared: found[1] };
122
168
  const suffix = configText.endsWith('\n') ? '' : '\n';
123
- return { text: `${configText}${suffix}harness: ${name} # detection wins; this is the fallback\n`, changed: true };
169
+ return {
170
+ text: `${configText}${suffix}harness: ${name} # the judge — which harness runs this repo's gate reviewers; declared wins\n`,
171
+ changed: true,
172
+ };
124
173
  }
125
174
  export function writeSettings(root, rel, text) {
126
175
  const p = join(root, rel);
@@ -0,0 +1,39 @@
1
+ // Row 103. The freeze is self-concealing: version() reports the RUNNING CLI, and all
2
+ // seven invocation surfaces (the engine prompt plus six skills) pin the CLI — so a
3
+ // frozen repo only ever runs the frozen CLI, which compares the payload against itself
4
+ // and reports clean. The one fact living outside that loop is what the registry
5
+ // publishes, which is why this is the single network call in the codebase.
6
+ const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
7
+ // The scope's slash must be percent-encoded; the `@` must not be. Same shape pacote uses.
8
+ const PACKAGE = '@popovych.co%2Fwitness';
9
+ // Short on purpose: this rides inside `check`, and a validator that hangs on a bad
10
+ // network is worse than one that stays quiet about it.
11
+ const TIMEOUT_MS = 2000;
12
+ // Best-effort and SILENT on every failure — offline, air-gapped, proxied, rate-limited,
13
+ // HTML interstitial, garbage JSON. `undefined` means "we do not know", which is never a
14
+ // finding: an air-gapped machine must report nothing about the network rather than a
15
+ // complaint about it, and nothing here may touch check's exit code (row 101).
16
+ //
17
+ // WITNESS_REGISTRY is a TEST SEAM, not a configuration key — it overrides the base URL
18
+ // and the literal `off` skips the query, which is what keeps the suite off the network.
19
+ // It is deliberately undocumented, on the precedent of crashPoint's WITNESS_CRASH_AFTER
20
+ // (txn.ts:26): row 90 killed WITNESS_HARNESS to establish that configuration has exactly
21
+ // one home, and the README says so outright. If suppressing this call ever becomes a
22
+ // real user need, it belongs in .witness/config.local.yaml with the other machine facts.
23
+ export async function latestPublished(env) {
24
+ const base = env.WITNESS_REGISTRY ?? DEFAULT_REGISTRY;
25
+ if (base === 'off')
26
+ return undefined;
27
+ try {
28
+ const res = await fetch(`${base}/-/package/${PACKAGE}/dist-tags`, {
29
+ signal: AbortSignal.timeout(TIMEOUT_MS),
30
+ });
31
+ if (!res.ok)
32
+ return undefined;
33
+ const tags = (await res.json());
34
+ return typeof tags.latest === 'string' ? tags.latest : undefined;
35
+ }
36
+ catch {
37
+ return undefined;
38
+ }
39
+ }