@popovych.co/witness 0.4.1 → 0.5.1

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
@@ -101,13 +101,26 @@ either.
101
101
 
102
102
  | Key | Meaning |
103
103
  |---|---|
104
- | `harness: claude-code \| pi` | fallback used only when detection cannot answer. Order: `WITNESS_HARNESS` → `PI_CODING_AGENT` → `CLAUDECODE` → `harness:` → `claude-code` |
104
+ | `harness: claude-code \| pi` | fallback used only when detection cannot answer. Order: `PI_CODING_AGENT` → `CLAUDECODE` → `harness:` → `claude-code` |
105
+ | `gates.reviewerTimeoutMs` | milliseconds per reviewer invocation (default 600000) |
106
+
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
109
+ renders `provider/model[:thinking]` with the provider witness knows it needs. The pi
110
+ reviewer runs hermetic (`--no-extensions --no-skills --no-context-files`); machine
111
+ extensions it must keep — e.g. an OAuth adapter that supplies your Anthropic auth —
112
+ are declared in machine config, not env:
113
+
114
+ ```yaml
115
+ # .witness/config.local.yaml — machine facts, gitignored, never committed
116
+ reviewerExtensions:
117
+ - /Users/you/.pi/agent/npm/node_modules/pi-claude-oauth-adapter
118
+ opener: xdg-open # optional; nonstandard desktops only
119
+ ```
105
120
 
106
- There is no `provider:` key. Harnesses whose `--model` flag is `provider/id` (Pi) are
107
- rendered with the provider witness knows they need. That is not a preference: `witness
108
- gate` spawns the Claude CLI for every reviewer on every harness, and `gates.<stage>.model`
109
- drives **both** those reviewers and the implement session's own model — so the pin must be
110
- an Anthropic id, and a Pi implement session on a non-Anthropic model is not expressible.
121
+ Repo facts live in `witness.config.yaml` (committed); machine facts live in
122
+ `.witness/config.local.yaml` (gitignored). Every key has exactly one home — a key in
123
+ the wrong file refuses. There are no `WITNESS_*` env vars for configuration.
111
124
 
112
125
  ## Verbs
113
126
 
package/dist/calibrate.js CHANGED
@@ -114,14 +114,14 @@ export function distribute(samples, buckets) {
114
114
  }
115
115
  export const threshold = (samples) => Math.ceil(0.9 * samples);
116
116
  export const injectSamples = (samples) => Math.max(2, Math.ceil(samples / 5));
117
- export function runSample(ctx, harness, model, lens, suite, overlay) {
117
+ export function runSample(ctx, harness, model, lens, suite, overlay, extras) {
118
118
  const { dir, files } = materialize(suite, overlay);
119
119
  const { reviewed, context } = composeReviewed(suite, dir, files);
120
120
  // same anchor-menu injection as the gate path — calibration must measure the
121
121
  // prompt condition production reviewers actually see
122
122
  const menu = anchorMenu(reviewed);
123
123
  const prompt = `${lens}\n\n${menu ? `${menu}\n\n` : ''}${renderReviewed(reviewed, context)}\n`;
124
- const invoked = invokeReviewer(ctx, harness, { cwd: dir, prompt, model });
124
+ const invoked = invokeReviewer(ctx, harness, { cwd: dir, prompt, model, ...extras });
125
125
  if (!invoked.ok)
126
126
  return invoked; // invocation-layer failure aborts the run
127
127
  const raw = parseVerdictText(invoked.value.text);
@@ -139,7 +139,7 @@ export function runSample(ctx, harness, model, lens, suite, overlay) {
139
139
  function side() {
140
140
  return { ok: 0, total: 0 };
141
141
  }
142
- export async function runReviewerSuite(ctx, harness, model, reviewer, samples) {
142
+ export async function runReviewerSuite(ctx, harness, model, reviewer, samples, extras) {
143
143
  const suiteR = loadReviewerSuite(reviewer);
144
144
  if (!suiteR.ok)
145
145
  return suiteR;
@@ -156,7 +156,7 @@ export async function runReviewerSuite(ctx, harness, model, reviewer, samples) {
156
156
  const perSeed = distribute(samples, suite.seeds.length);
157
157
  for (const [i, seed] of suite.seeds.entries()) {
158
158
  for (let n = 0; n < perSeed[i]; n += 1) {
159
- const s = runSample(ctx, harness, model, lens, suite, seed.overlay);
159
+ const s = runSample(ctx, harness, model, lens, suite, seed.overlay, extras);
160
160
  if (!s.ok)
161
161
  return s;
162
162
  catches.total += 1;
@@ -166,7 +166,7 @@ export async function runReviewerSuite(ctx, harness, model, reviewer, samples) {
166
166
  }
167
167
  for (const seed of suite.injects) {
168
168
  for (let n = 0; n < injectSamples(samples); n += 1) {
169
- const s = runSample(ctx, harness, model, lens, suite, seed.overlay);
169
+ const s = runSample(ctx, harness, model, lens, suite, seed.overlay, extras);
170
170
  if (!s.ok)
171
171
  return s;
172
172
  inject.total += 1;
@@ -175,7 +175,7 @@ export async function runReviewerSuite(ctx, harness, model, reviewer, samples) {
175
175
  }
176
176
  }
177
177
  for (let n = 0; n < samples; n += 1) {
178
- const s = runSample(ctx, harness, model, lens, suite);
178
+ const s = runSample(ctx, harness, model, lens, suite, undefined, extras);
179
179
  if (!s.ok)
180
180
  return s;
181
181
  clean.total += 1;
@@ -185,13 +185,13 @@ export async function runReviewerSuite(ctx, harness, model, reviewer, samples) {
185
185
  const pass = catches.ok >= threshold(catches.total) && clean.ok >= threshold(clean.total) && inject.ok === inject.total;
186
186
  return ok({ reviewer, catches, clean, inject, pass });
187
187
  }
188
- export async function runReviewerSuites(ctx, harness, model, samples, only) {
188
+ export async function runReviewerSuites(ctx, harness, model, samples, only, extras) {
189
189
  const names = [...PROMPT_NAMES].sort().filter((n) => only === undefined || n === only);
190
190
  if (names.length === 0)
191
191
  return refuse([v('--only', 'unknown-reviewer', only ?? '', PROMPT_NAMES.join(' '))]);
192
192
  const scores = [];
193
193
  for (const name of names) {
194
- const r = await runReviewerSuite(ctx, harness, model, name, samples);
194
+ const r = await runReviewerSuite(ctx, harness, model, name, samples, extras);
195
195
  if (!r.ok)
196
196
  return r;
197
197
  scores.push(r.value);
@@ -324,7 +324,7 @@ function firstCriterionTag(meta) {
324
324
  const withTest = criteria.find((c) => typeof c.test === 'string');
325
325
  return withTest ? String(withTest.test).replace(/^@spec:/, '') : '';
326
326
  }
327
- export async function runDecomposeSeed(ctx, harness, model, seed) {
327
+ export async function runDecomposeSeed(ctx, harness, model, seed, extras) {
328
328
  const { root } = seedScratchRepo(`decompose-${seed.id}`);
329
329
  const canonDir = join(seed.dir, 'canon');
330
330
  if (existsSync(canonDir)) {
@@ -348,7 +348,7 @@ export async function runDecomposeSeed(ctx, harness, model, seed) {
348
348
  `### witness index\n\n${indexH.out()}`,
349
349
  NONINTERACTIVE_OVERRIDE,
350
350
  ].join('\n\n');
351
- const invoked = invokeReviewer(ctx, harness, { cwd: root, prompt, model });
351
+ const invoked = invokeReviewer(ctx, harness, { cwd: root, prompt, model, ...extras });
352
352
  if (!invoked.ok)
353
353
  return invoked;
354
354
  const raw = parseVerdictText(invoked.value.text);
@@ -413,7 +413,7 @@ export async function runDecomposeSeed(ctx, harness, model, seed) {
413
413
  return ok({ ok: false, why: `goals uncovered: ${uncovered.join(',')}` });
414
414
  return ok({ ok: true, why: 'first-try valid' });
415
415
  }
416
- export async function runPlanSeed(ctx, harness, model, seed) {
416
+ export async function runPlanSeed(ctx, harness, model, seed, extras) {
417
417
  const { root } = seedScratchRepo(`plan-${seed.id}`);
418
418
  const recapText = readFileSync(join(seed.dir, 'recap.json'), 'utf8');
419
419
  const recap = JSON.parse(recapText);
@@ -446,7 +446,7 @@ export async function runPlanSeed(ctx, harness, model, seed) {
446
446
  `### witness diff ${parentId}\n\n${diffH.out()}`,
447
447
  NONINTERACTIVE_OVERRIDE,
448
448
  ].join('\n\n');
449
- const invoked = invokeReviewer(ctx, harness, { cwd: root, prompt, model });
449
+ const invoked = invokeReviewer(ctx, harness, { cwd: root, prompt, model, ...extras });
450
450
  if (!invoked.ok)
451
451
  return invoked;
452
452
  const raw = parseVerdictText(invoked.value.text);
@@ -560,7 +560,7 @@ export async function runSkillSuites(ctx, harness, model, samples, opts = {}) {
560
560
  let total = 0;
561
561
  for (const [i, seed] of seeds.entries()) {
562
562
  for (let n = 0; n < perSeed[i]; n += 1) {
563
- const r = await runDecomposeSeed(ctx, harness, model, seed);
563
+ const r = await runDecomposeSeed(ctx, harness, model, seed, opts.extras);
564
564
  if (!r.ok)
565
565
  return r;
566
566
  total += 1;
@@ -577,7 +577,7 @@ export async function runSkillSuites(ctx, harness, model, samples, opts = {}) {
577
577
  let total = 0;
578
578
  for (const [i, seed] of seeds.entries()) {
579
579
  for (let n = 0; n < perSeed[i]; n += 1) {
580
- const r = await runPlanSeed(ctx, harness, model, seed);
580
+ const r = await runPlanSeed(ctx, harness, model, seed, opts.extras);
581
581
  if (!r.ok)
582
582
  return r;
583
583
  total += 1;
package/dist/config.js CHANGED
@@ -98,6 +98,71 @@ export function resolveImplement(raw) {
98
98
  }
99
99
  return ok({ stepsPerDispatch: val });
100
100
  }
101
+ export const DEFAULT_REVIEWER_TIMEOUT_MS = 600_000;
102
+ // gates.* is otherwise read loosely at its consumers (stagePin); the timeout is
103
+ // resolved here, typed, because a typo'd key silently defaulting to ten minutes is
104
+ // exactly the ambient-value class row 90 removed from env.
105
+ export function resolveGates(raw) {
106
+ const conf = raw.gates;
107
+ if (conf === undefined)
108
+ return ok({ reviewerTimeoutMs: DEFAULT_REVIEWER_TIMEOUT_MS });
109
+ if (typeof conf !== 'object' || conf === null || Array.isArray(conf)) {
110
+ return refuse([v('gates', 'invalid', String(conf), 'a map of gate settings')]);
111
+ }
112
+ const val = conf.reviewerTimeoutMs;
113
+ if (val === undefined)
114
+ return ok({ reviewerTimeoutMs: DEFAULT_REVIEWER_TIMEOUT_MS });
115
+ if (typeof val !== 'number' || !Number.isInteger(val) || val < 1) {
116
+ return refuse([v('gates.reviewerTimeoutMs', 'invalid', String(val), 'an integer >= 1 — milliseconds each reviewer invocation may take')]);
117
+ }
118
+ return ok({ reviewerTimeoutMs: val });
119
+ }
120
+ const LOCAL_KEYS = ['reviewerExtensions', 'opener'];
121
+ export const localConfigPath = (root) => join(root, '.witness', 'config.local.yaml');
122
+ export function loadLocalConfig(root) {
123
+ const p = localConfigPath(root);
124
+ if (!existsSync(p))
125
+ return ok({ reviewerExtensions: [] });
126
+ let raw;
127
+ try {
128
+ raw = parseYaml(readFileSync(p, 'utf8'));
129
+ }
130
+ catch (e) {
131
+ return refuse([v('.witness/config.local.yaml', 'yaml-parse', String(e.message).slice(0, 120), 'valid YAML')]);
132
+ }
133
+ const obj = (raw ?? {});
134
+ if (typeof obj !== 'object' || Array.isArray(obj)) {
135
+ return refuse([v('.witness/config.local.yaml', 'invalid', String(obj), 'a map of machine-local keys')]);
136
+ }
137
+ const violations = [];
138
+ for (const key of Object.keys(obj)) {
139
+ if (!LOCAL_KEYS.includes(key)) {
140
+ violations.push(v(key, 'unknown-local-key', key, `a machine-local key (${LOCAL_KEYS.join(' | ')}) — repo facts belong in witness.config.yaml`));
141
+ }
142
+ }
143
+ let reviewerExtensions = [];
144
+ const ext = obj.reviewerExtensions;
145
+ if (ext !== undefined) {
146
+ if (!Array.isArray(ext) || !ext.every((x) => typeof x === 'string' && x !== '')) {
147
+ violations.push(v('reviewerExtensions', 'invalid', JSON.stringify(ext), 'a list of extension paths handed to the harness reviewer spawn'));
148
+ }
149
+ else {
150
+ reviewerExtensions = ext;
151
+ }
152
+ }
153
+ let opener;
154
+ if (obj.opener !== undefined) {
155
+ if (typeof obj.opener !== 'string' || obj.opener === '') {
156
+ violations.push(v('opener', 'invalid', String(obj.opener), 'a command name or path'));
157
+ }
158
+ else {
159
+ opener = obj.opener;
160
+ }
161
+ }
162
+ if (violations.length)
163
+ return refuse(violations);
164
+ return ok({ reviewerExtensions, ...(opener !== undefined ? { opener } : {}) });
165
+ }
101
166
  // Lenient path resolution for callers without a loaded Config (canon scan, git
102
167
  // state scoping): a missing or broken config falls back to the defaults — the
103
168
  // verbs own the refusal messaging via loadConfig, which validates paths strictly.
@@ -144,12 +209,16 @@ export function loadConfig(root) {
144
209
  const implement = resolveImplement(obj);
145
210
  if (!implement.ok)
146
211
  return refuse(implement.violations);
212
+ const gates = resolveGates(obj);
213
+ if (!gates.ok)
214
+ return refuse(gates.violations);
147
215
  return ok({
148
216
  schema,
149
217
  raw: obj,
150
218
  paths: paths.value,
151
219
  docs: docs.value,
152
220
  implement: implement.value,
221
+ gates: gates.value,
153
222
  warning: schema < SCHEMA_VERSION ? `schema ${schema} < ${SCHEMA_VERSION} — run witness migrate (reserved)` : undefined,
154
223
  });
155
224
  }
package/dist/drift.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { join } from 'node:path';
3
3
  import { EXIT } from './cli.js';
4
- import { loadConfig } from './config.js';
4
+ import { loadConfig, loadLocalConfig } from './config.js';
5
5
  import { runCriteria } from './criteria.js';
6
6
  import { serializeDoc, writeDoc } from './fm.js';
7
7
  import { acquireLock } from './lock.js';
@@ -125,8 +125,20 @@ export async function deepDrift(root, ctx, canon, specId) {
125
125
  renderRefusal(hxR.violations).forEach(ctx.err);
126
126
  return EXIT.REFUSED;
127
127
  }
128
+ // Repo config may be broken here by design — the timeout falls to the default. A
129
+ // broken LOCAL file refuses instead: silently dropping declared extensions
130
+ // reproduces the exact misdiagnosed 400 this release exists to kill.
131
+ const localR = loadLocalConfig(root);
132
+ if (!localR.ok) {
133
+ renderRefusal(localR.violations).forEach(ctx.err);
134
+ return EXIT.REFUSED;
135
+ }
128
136
  const prompt = `${lensR.value.contents}\n\n## Reviewed content\n\n### Spec: ${specId}\n${serializeDoc({ meta: doc.meta, body: doc.body })}\n\n(Deterministic lane: ${lane.ok ? lane.value.criteria.map((c) => `${c.id}:${c.ok ? 'ok' : 'fail'}`).join(' · ') : 'unrunnable'})\n`;
129
- const invoked = invokeReviewer(ctx, hxR.value.harness, { cwd: root, prompt });
137
+ const invoked = invokeReviewer(ctx, hxR.value.harness, {
138
+ cwd: root, prompt,
139
+ timeoutMs: cfgR.ok ? cfgR.value.gates.reviewerTimeoutMs : undefined,
140
+ extensions: localR.value.reviewerExtensions,
141
+ });
130
142
  if (!invoked.ok) {
131
143
  renderRefusal(invoked.violations).forEach(ctx.err);
132
144
  return EXIT.REFUSED;
package/dist/gate.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { join } from 'node:path';
2
2
  import { EXIT, version } from './cli.js';
3
- import { loadConfig } from './config.js';
3
+ import { loadConfig, loadLocalConfig } from './config.js';
4
4
  import { writeDoc } from './fm.js';
5
5
  import { crashPoint, guardTxn, withTxn } from './txn.js';
6
6
  import { acquireLock } from './lock.js';
@@ -169,6 +169,15 @@ export async function runGate(ctx, gateName, target, flags) {
169
169
  return EXIT.REFUSED;
170
170
  }
171
171
  const harness = hxR.value.harness;
172
+ const localR = loadLocalConfig(root);
173
+ if (!localR.ok) {
174
+ renderRefusal(localR.violations).forEach((l) => ctx.err(l));
175
+ return EXIT.REFUSED;
176
+ }
177
+ const extras = {
178
+ timeoutMs: cfgR.value.gates.reviewerTimeoutMs,
179
+ extensions: localR.value.reviewerExtensions,
180
+ };
172
181
  const modelR = resolveModel(cfgR.value, loadMatrix(root, harness.name), spec.gate);
173
182
  if (!modelR.ok) {
174
183
  renderRefusal(modelR.violations).forEach((l) => ctx.err(l));
@@ -240,7 +249,7 @@ export async function runGate(ctx, gateName, target, flags) {
240
249
  let answered;
241
250
  for (;;) {
242
251
  const id = chain[rung];
243
- const r = invokeReviewer(ctx, harness, { cwd: root, prompt, model: id === SESSION_DEFAULT ? undefined : id });
252
+ const r = invokeReviewer(ctx, harness, { cwd: root, prompt, model: id === SESSION_DEFAULT ? undefined : id, ...extras });
244
253
  if (r.ok) {
245
254
  answered = r.value.text;
246
255
  model = id;
@@ -314,6 +323,7 @@ export async function runGate(ctx, gateName, target, flags) {
314
323
  round: roundsSinceApprove(entriesNow, spec.gate) + 1, run_id: newRunId(),
315
324
  reviewed_sha: input.reviewedSha, prompts_sha: key.prompts_sha,
316
325
  witness: key.witness, model, harness: harness.name, calibration: calibrationOf(model),
326
+ ...(localR.value.reviewerExtensions.length ? { reviewer_extensions: localR.value.reviewerExtensions } : {}),
317
327
  ...(cached ? { cached: true } : {}),
318
328
  ...(flags.manual ? { manual: true } : {}),
319
329
  ...(fallback.length ? { fallback } : {}),
@@ -11,6 +11,7 @@ import { effortOf, worktreeTreeSha } from '../reviewed.js';
11
11
  import { worktreePath } from '../worktree.js';
12
12
  import { codePromptBody } from './implement.js';
13
13
  import { registerGate } from '../gate.js';
14
+ import { gateSettled } from '../verbs/next.js';
14
15
  import { lastGateRun } from '../rounds.js';
15
16
  import { serializeDoc } from '../fm.js';
16
17
  async function commandLane(wt, root, ctx, name, cmd) {
@@ -46,16 +47,19 @@ registerGate({
46
47
  const checks = [];
47
48
  const entries = readStream(root, planId);
48
49
  const lastImplement = lastGateRun(entries, 'implement');
49
- const implementSettled = (() => {
50
- if (!lastImplement)
51
- return false;
52
- if (lastImplement.outcome === 'passed')
53
- return true;
54
- const after = entries.slice(entries.lastIndexOf(lastImplement) + 1);
55
- return after.some((e) => e.t === 'human-decision' &&
56
- e.gate === 'implement' &&
57
- e.decision === 'approve');
58
- })();
50
+ // One settle predicate, not two. This block used to inline its own copy of
51
+ // gateSettled, which meant `ship` and `next` could answer "is implement settled?"
52
+ // differently for the same journal — the split-brain that reads as a deadlock when
53
+ // two sessions compare notes.
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.
62
+ const implementSettled = gateSettled(entries, 'implement');
59
63
  checks.push({ name: 'implement-gate', ok: implementSettled,
60
64
  detail: lastImplement
61
65
  ? `last implement round ${lastImplement.round}: ${lastImplement.outcome}`
package/dist/gitio.js CHANGED
@@ -44,6 +44,12 @@ const LOCAL_STATE_FILES = new Set([
44
44
  '.witness/txn.json',
45
45
  '.witness/allow.json',
46
46
  '.witness/calibration.local.yaml',
47
+ // Row 90's machine-config file is this tier too, and the exemption is load-bearing
48
+ // rather than belt-and-braces: init only gitignores it for repos scaffolded at
49
+ // >=0.5.0, so on every pre-0.5.0 repo the file is untracked-but-visible under a
50
+ // state dir — and an unrelated-dirty refusal there would brick the very gate the
51
+ // declared extensions exist to unblock.
52
+ '.witness/config.local.yaml',
47
53
  ]);
48
54
  export function dirtyStatePaths(root) {
49
55
  const res = tryGit(root, 'status', '--porcelain', '--untracked-files=all', '--', ...stateDirs(root));
@@ -57,13 +63,35 @@ export function dirtyStatePaths(root) {
57
63
  .filter((p) => !LOCAL_STATE_FILES.has(p));
58
64
  }
59
65
  export const TRAILER = 'Witness-State: 1';
66
+ // Row 87's rule: nothing to COMMIT is the same legal silent success as nothing to write.
67
+ // It lives here, at the one choke point every state commit passes through, because git
68
+ // rejects BOTH empty cases fatally and a caller cannot tell them apart in advance:
69
+ // • no paths at all → `commit --only ... --` dies "No paths with --include/--only"
70
+ // (a drift sweep with no live specs journals nothing and stamps nothing);
71
+ // • paths with no diff → `commit --only` exits non-zero (a restamp that restores a
72
+ // file to exactly its HEAD content, or a pathspec the repo gitignores).
73
+ // Either way there is no state to record, so HEAD is already the answer. Implementing
74
+ // this at init.ts's call site alone left every other caller throwing out of gitio and
75
+ // surfacing through bin.ts as `unexpected-error` — "report a bug" on a healthy repo.
76
+ function headSha(root) {
77
+ const head = tryGit(root, 'rev-parse', 'HEAD');
78
+ return head.ok ? head.out : ''; // unborn HEAD: no commit exists to name
79
+ }
60
80
  export function commitWithTrailer(root, files, subject) {
81
+ if (files.length === 0)
82
+ return ok({ sha: headSha(root) });
61
83
  // tryGit, not git: a path already fully processed by a prior `git mv` (both sides
62
84
  // of a rename staged atomically) is absent from working tree AND index, so `add`
63
85
  // reports it unmatched — even though the rename itself is already staged. `commit
64
86
  // --only` resolves its pathspecs against the index-vs-HEAD diff, where the rename
65
87
  // is visible, so it still succeeds and is the real validation here.
66
88
  tryGit(root, 'add', '--', ...files);
89
+ // Ask git what actually changed rather than assuming every file we touched is dirty.
90
+ // Runs AFTER the add, so a freshly staged untracked path reads as `A ` and commits;
91
+ // a staged rename reads as `R ` on whichever side the pathspec names.
92
+ if (tryGit(root, 'status', '--porcelain', '--', ...files).out === '') {
93
+ return ok({ sha: headSha(root) });
94
+ }
67
95
  // --no-verify: state commits are machine-authored and scope-restricted to non-source
68
96
  // paths (isStatePath), so a host lint/test hook has nothing to validate here — while
69
97
  // its stash/restore step can destroy the human's unrelated dirty work, and a
package/dist/harness.js CHANGED
@@ -39,7 +39,15 @@ function parsePiEnvelope(stdout) {
39
39
  }
40
40
  const assistant = end?.messages?.filter((m) => m.role === 'assistant').at(-1);
41
41
  if (assistant?.stopReason === 'error') {
42
- return refuse([v('pi', 'reviewer-invocation', (assistant.errorMessage ?? 'provider error').slice(0, 200), 'a provider the pinned model can reach — check auth and billing for that provider')]);
42
+ const msg = (assistant.errorMessage ?? 'provider error').slice(0, 200);
43
+ // Anthropic's extra-usage 400 against subscription OAuth is the hermetic spawn
44
+ // disabling an auth-supplying extension, NOT an auth/billing problem — the old
45
+ // want text cost a real user rounds of re-login (row 89 overturned row 88's
46
+ // billing-asymmetry residual on this evidence).
47
+ if (/Third-party apps|extra usage/i.test(msg)) {
48
+ return refuse([v('pi', 'reviewer-invocation', msg, 'a credential headless pi can use — the reviewer runs hermetic (--no-extensions); if your provider auth is supplied by a pi extension, declare its path in .witness/config.local.yaml reviewerExtensions')]);
49
+ }
50
+ return refuse([v('pi', 'reviewer-invocation', msg, 'a provider the pinned model can reach — check auth and billing for that provider')]);
43
51
  }
44
52
  const text = (assistant?.content ?? []).filter((c) => c.type === 'text').map((c) => c.text ?? '').join('\n');
45
53
  if (text === '') {
@@ -68,7 +76,7 @@ const REGISTRY = {
68
76
  ],
69
77
  skills: { project: '.claude/skills', global: '.claude/skills' },
70
78
  reviewer: {
71
- spawn(pin) {
79
+ spawn(pin, _extensions) {
72
80
  const args = ['-p', '--output-format', 'json'];
73
81
  const env = {};
74
82
  if (pin !== undefined) {
@@ -105,10 +113,20 @@ const REGISTRY = {
105
113
  // Hermetic (Decision 88): every omitted flag here is a machine-local variable that
106
114
  // would silently change reviewer behavior — this machine's `defaultThinkingLevel:
107
115
  // xhigh` was the probable true cause of row 87's "stalls on long prompts".
116
+ //
117
+ // Declared reviewerExtensions (machine config) are the ONE sanctioned readmission —
118
+ // auth transport, journaled per gate-run, never part of the verdict-cache key. The
119
+ // worker below keeps full discovery on purpose: skills and context files are what
120
+ // the implement seed measures; auth extensions ride along with everything else there.
108
121
  reviewer: {
109
- spawn(pin) {
110
- const args = ['-p', '--mode', 'json', '--no-session', '--no-extensions',
111
- '--no-skills', '--no-context-files', '--thinking', pin?.thinking ?? 'off'];
122
+ spawn(pin, extensions) {
123
+ const args = ['-p', '--mode', 'json', '--no-session', '--no-extensions'];
124
+ // Declared machine extensions ride INSIDE the hermetic set (row 89): pi's
125
+ // --no-extensions disables discovery but explicit -e paths still load, so
126
+ // auth-supplying adapters work without readmitting ambient machine state.
127
+ for (const e of extensions ?? [])
128
+ args.push('-e', e);
129
+ args.push('--no-skills', '--no-context-files', '--thinking', pin?.thinking ?? 'off');
112
130
  if (pin !== undefined)
113
131
  args.push('--model', `${pin.provider ?? 'anthropic'}/${pin.model}`);
114
132
  return { cmd: 'pi', args, env: {} };
@@ -132,7 +150,6 @@ export function loadHarness(name) {
132
150
  }
133
151
  return ok(harness);
134
152
  }
135
- const relabel = (violations, field) => violations.map((x) => ({ ...x, field }));
136
153
  // Decision 5. Detection is the authority; config is the fallback. A config-authority
137
154
  // default in a fresh repo emits a runnable-LOOKING, unrunnable handoff behind a warning
138
155
  // that gets scrolled past — bug B2's exact shape. Detection tests PRESENCE: neither
@@ -141,12 +158,10 @@ const relabel = (violations, field) => violations.map((x) => ({ ...x, field }));
141
158
  // Deliberately NOT wired into loadConfig: every verb calls that, so an invalid
142
159
  // `harness:` there would brick `witness check` on a key nothing read. Verbs that need
143
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.
144
164
  export function resolveHarness(env, raw) {
145
- const override = env.WITNESS_HARNESS;
146
- if (override !== undefined && override !== '') {
147
- const r = loadHarness(override);
148
- return r.ok ? ok({ harness: r.value, source: 'env' }) : refuse(relabel(r.violations, 'WITNESS_HARNESS'));
149
- }
150
165
  if (env.PI_CODING_AGENT !== undefined) {
151
166
  const r = loadHarness('pi');
152
167
  return r.ok ? ok({ harness: r.value, source: 'detected' }) : refuse(r.violations);
package/dist/opener.js CHANGED
@@ -16,12 +16,13 @@ function resolves(cmd) {
16
16
  return probe.status === 0;
17
17
  }
18
18
  // Show a file to the human. Returns the resolved command alongside the outcome so the
19
- // caller can journal WHAT was run — a degenerate opener (`WITNESS_OPENER=/usr/bin/true`)
20
- // then reads as a degenerate opener in the record instead of as a human looking at a
21
- // screen. `WITNESS_OPENER` overrides the binary: test seam, and the real escape hatch
22
- // for nonstandard desktops, so it accepts a bare name off PATH as well as a path.
23
- export function openArtifact(env, absPath) {
24
- const argv = env.WITNESS_OPENER ? [env.WITNESS_OPENER] : platformOpener();
19
+ // caller can journal WHAT was run — a degenerate opener (`opener: /usr/bin/true` in
20
+ // .witness/config.local.yaml) then reads as a degenerate opener in the record instead
21
+ // of as a human looking at a screen. `opener` is the machine-config value (row 90 —
22
+ // nonstandard desktops declare it there; tests inject it as a parameter), a bare name
23
+ // off PATH or a path.
24
+ export function openArtifact(opener, absPath) {
25
+ const argv = opener !== undefined && opener !== '' ? [opener] : platformOpener();
25
26
  const [cmd, ...pre] = argv;
26
27
  // Probe before spawning: a detached spawn reports ENOENT asynchronously, and the
27
28
  // caller needs the outcome synchronously to journal it in the same transaction.
package/dist/reviewer.js CHANGED
@@ -94,8 +94,8 @@ export function invokeReviewer(ctx, harness, opts) {
94
94
  return refuse(pinR.violations);
95
95
  pin = pinR.value;
96
96
  }
97
- const { cmd, args, env } = harness.reviewer.spawn(pin);
98
- const timeout = Number(ctx.env.WITNESS_REVIEWER_TIMEOUT_MS) || REVIEWER_TIMEOUT_MS;
97
+ const { cmd, args, env } = harness.reviewer.spawn(pin, opts.extensions);
98
+ const timeout = opts.timeoutMs ?? REVIEWER_TIMEOUT_MS;
99
99
  for (let attempt = 0;; attempt += 1) {
100
100
  const r = spawnSync(cmd, args, {
101
101
  cwd: opts.cwd,
@@ -111,7 +111,7 @@ export function invokeReviewer(ctx, harness, opts) {
111
111
  if (r.error.code === 'ETIMEDOUT') {
112
112
  if (attempt < TIMEOUT_RETRIES)
113
113
  continue;
114
- return refuse([v(cmd, 'reviewer-timeout', `no response in ${timeout}ms after ${attempt + 1} attempts`, 'a reviewer that answers within the timeout — raise WITNESS_REVIEWER_TIMEOUT_MS if the model is simply slow')]);
114
+ return refuse([v(cmd, 'reviewer-timeout', `no response in ${timeout}ms after ${attempt + 1} attempts`, 'a reviewer that answers within the timeout — raise gates.reviewerTimeoutMs in witness.config.yaml if the model is simply slow')]);
115
115
  }
116
116
  return refuse([v(cmd, 'reviewer-invocation', String(r.error.message), `a runnable ${cmd} binary on PATH — gates invoke reviewers headlessly; witness check probes this`)]);
117
117
  }
@@ -1,7 +1,7 @@
1
1
  import { EXIT } from '../cli.js';
2
2
  import { kv, rows } from '../toon.js';
3
3
  import { renderRefusal, v } from '../refusal.js';
4
- import { loadConfig } from '../config.js';
4
+ import { loadConfig, loadLocalConfig } from '../config.js';
5
5
  import { resolveHarness } from '../harness.js';
6
6
  import { MODEL_ALIASES } from '../model.js';
7
7
  import { primaryRoot } from '../gitio.js';
@@ -78,13 +78,26 @@ export async function run(ctx, argv) {
78
78
  return EXIT.REFUSED;
79
79
  }
80
80
  const harness = hxR.value.harness;
81
- const reviewers = runReviewers ? await runReviewerSuites(ctx, harness, model, flags.samples, reviewerOnly) : undefined;
81
+ // Row 89: calibration measures through the SAME spawn as the gate battery, declared
82
+ // extensions included — a reviewer scored without its auth adapter is not the
83
+ // reviewer production runs.
84
+ const localR = loadLocalConfig(rootR.value);
85
+ if (!localR.ok) {
86
+ for (const line of renderRefusal(localR.violations))
87
+ ctx.err(line);
88
+ return EXIT.REFUSED;
89
+ }
90
+ const extras = {
91
+ timeoutMs: cfgR.ok ? cfgR.value.gates.reviewerTimeoutMs : undefined,
92
+ extensions: localR.value.reviewerExtensions,
93
+ };
94
+ const reviewers = runReviewers ? await runReviewerSuites(ctx, harness, model, flags.samples, reviewerOnly, extras) : undefined;
82
95
  if (reviewers && !reviewers.ok) {
83
96
  for (const line of renderRefusal(reviewers.violations))
84
97
  ctx.err(line);
85
98
  return EXIT.REFUSED;
86
99
  }
87
- const skills = runSkills ? await runSkillSuites(ctx, harness, model, flags.samples, { only: skillOnly }) : undefined;
100
+ const skills = runSkills ? await runSkillSuites(ctx, harness, model, flags.samples, { only: skillOnly, extras }) : undefined;
88
101
  if (skills && !skills.ok) {
89
102
  for (const line of renderRefusal(skills.violations))
90
103
  ctx.err(line);
@@ -4,10 +4,10 @@ import { adoptedCommits } from '../adopt.js';
4
4
  import { EXIT, version } from '../cli.js';
5
5
  import { HARNESSES, resolveHarness, skillsVisibility } from '../harness.js';
6
6
  import { probe } from '../probe.js';
7
- import { loadConfig } from '../config.js';
7
+ import { loadConfig, loadLocalConfig, localConfigPath } from '../config.js';
8
8
  import { designPending } from '../design.js';
9
9
  import { runDrift } from '../drift.js';
10
- import { auditStateCommits, dirtyStatePaths, primaryRoot } from '../gitio.js';
10
+ import { auditStateCommits, dirtyStatePaths, primaryRoot, tryGit } from '../gitio.js';
11
11
  import { contentAtSha } from '../history.js';
12
12
  import { effortStreams, readStream } from '../journal.js';
13
13
  import { sourceTags } from '../matcher.js';
@@ -37,6 +37,24 @@ export async function run(ctx, argv = []) {
37
37
  cfg.violations.forEach((x) => findings.push(f('error', 'config', x.field, x.rule, x.got)));
38
38
  else if (cfg.value.warning)
39
39
  findings.push(f('warn', 'config', 'schema', 'older-schema', cfg.value.warning));
40
+ // Machine config is read here as FINDINGS, never a refusal — same doctrine that
41
+ // keeps harness resolution out of loadConfig (row 87): check must be able to
42
+ // report a broken machine file rather than brick on it.
43
+ const local = loadLocalConfig(root);
44
+ if (!local.ok) {
45
+ local.violations.forEach((x) => findings.push(f('error', 'local-config', x.field, x.rule, x.got)));
46
+ }
47
+ else {
48
+ for (const p of local.value.reviewerExtensions) {
49
+ if (!existsSync(p))
50
+ findings.push(f('warn', 'local-config', 'reviewerExtensions', 'extension-path-missing', p));
51
+ }
52
+ }
53
+ // init's gitignore write is scaffold-once, so pre-0.5.0 repos never receive the new
54
+ // ignore line — this finding is their honest path to it.
55
+ if (existsSync(localConfigPath(root)) && !tryGit(root, 'check-ignore', '-q', '--', '.witness/config.local.yaml').ok) {
56
+ findings.push(f('warn', 'local-config', '.witness/config.local.yaml', 'local-config-unignored', 'machine-local file — add it to .gitignore'));
57
+ }
40
58
  const canon0 = loadCanon(root);
41
59
  const lazy = lazyStamp(root, ctx, canon0);
42
60
  const canon = lazy.stamped.length > 0 ? loadCanon(root) : canon0;
@@ -3,7 +3,7 @@ import { hostname, userInfo } from 'node:os';
3
3
  import { dirname, join, resolve } from 'node:path';
4
4
  import { parseArgs } from 'node:util';
5
5
  import { EXIT } from '../cli.js';
6
- import { loadConfig } from '../config.js';
6
+ import { loadConfig, loadLocalConfig } from '../config.js';
7
7
  import { designPending, designRel, designStamp, htmlSha, validateDesignArtifact } from '../design.js';
8
8
  import { writeDoc } from '../fm.js';
9
9
  import { dirtyStatePaths, primaryRoot, stateCommit } from '../gitio.js';
@@ -85,9 +85,14 @@ function openOnly(ctx, root, rel, id) {
85
85
  return EXIT.REFUSED;
86
86
  }
87
87
  const sha = htmlSha(readFileSync(abs, 'utf8'));
88
- const { outcome, command } = openArtifact(ctx.env, abs);
88
+ const localR = loadLocalConfig(root);
89
+ if (!localR.ok) {
90
+ renderRefusal(localR.violations).forEach(ctx.err);
91
+ return EXIT.REFUSED;
92
+ }
93
+ const { outcome, command } = openArtifact(localR.value.opener, abs);
89
94
  if (outcome === 'failed') {
90
- renderRefusal([v('design', 'opener-failed', `${command} did not resolve`, `a working platform opener, or WITNESS_OPENER — meanwhile open it yourself: file://${abs}`)]).forEach(ctx.err);
95
+ renderRefusal([v('design', 'opener-failed', `${command} did not resolve`, `a working platform opener, or opener: in .witness/config.local.yaml — meanwhile open it yourself: file://${abs}`)]).forEach(ctx.err);
91
96
  return EXIT.REFUSED;
92
97
  }
93
98
  // `by` is the account and machine the spawn happened on — not a claim about who looked.
@@ -5,7 +5,7 @@ import { EXIT } from '../cli.js';
5
5
  import { configPath } from '../config.js';
6
6
  import { writeDoc } from '../fm.js';
7
7
  import { commitWithTrailer, primaryRoot, tryGit } from '../gitio.js';
8
- import { resolveHarness } from '../harness.js';
8
+ import { loadHarness, resolveHarness } from '../harness.js';
9
9
  import { installPayload, mergeSettings, preflightPayload, readIfExists, recordHarness, writeSettings, } from '../install.js';
10
10
  import { probe } from '../probe.js';
11
11
  import { acquireLock } from '../lock.js';
@@ -18,6 +18,7 @@ schema: 1
18
18
  # conventions: [docs/code/architecture.md] # → injected into code-reviewer at implement + ship (cache-keyed)
19
19
  gates:
20
20
  model: claude-fable-5 # reviewer model pin — exact id, aliases refused
21
+ # reviewerTimeoutMs: 600000 # ms per reviewer invocation (machine auth/extension knobs live in .witness/config.local.yaml)
21
22
  # any gate block may pin its own model (wins over the global pin), e.g.
22
23
  # decompose: { reviewers: [slicing-critic], model: claude-opus-4-8 }
23
24
  decompose: { reviewers: [slicing-critic] }
@@ -37,6 +38,7 @@ const GITIGNORE_BLOCK = `# witness local (never committed)
37
38
  .witness/txn.json
38
39
  .witness/allow.json
39
40
  .witness/calibration.local.yaml
41
+ .witness/config.local.yaml
40
42
  .witness/screens/
41
43
  `;
42
44
  const PRINCIPLES_BODY = `# Principles
@@ -73,15 +75,14 @@ export async function run(ctx, argv = []) {
73
75
  let harness;
74
76
  if (agent !== undefined) {
75
77
  // `auto` is the one value that consults the detection rungs; a named agent is a
76
- // claim and refuses when false, listing the harnesses that exist.
77
- const hxR = agent === 'auto'
78
- ? resolveHarness(ctx.env, {})
79
- : resolveHarness({ WITNESS_HARNESS: agent }, {});
78
+ // claim and refuses when false, listing the harnesses that exist (row 90: no env
79
+ // impersonation — a name resolves through the registry directly).
80
+ const hxR = agent === 'auto' ? resolveHarness(ctx.env, {}) : loadHarness(agent);
80
81
  if (!hxR.ok) {
81
82
  renderRefusal(hxR.violations.map((x) => ({ ...x, field: '--agent' }))).forEach(ctx.err);
82
83
  return EXIT.REFUSED;
83
84
  }
84
- harness = hxR.value.harness;
85
+ harness = 'harness' in hxR.value ? hxR.value.harness : hxR.value;
85
86
  // Revision 6: pre-flight before the lock and before any scaffold write, so a
86
87
  // refusal here leaves the repo exactly as it was.
87
88
  const pre = preflightPayload(root, harness);
@@ -14,7 +14,7 @@ import { handoffLine, relayLine, resolveHarness } from '../harness.js';
14
14
  import { worktreeFlow, worktreePath } from '../worktree.js';
15
15
  import { lazyStamp } from '../stamp.js';
16
16
  import { ok, refuse, renderRefusal, v } from '../refusal.js';
17
- import { kv } from '../toon.js';
17
+ import { kv, rows } from '../toon.js';
18
18
  import { boundReached, lastGateRun, openReopen, pendingDecision } from '../rounds.js';
19
19
  // A gate is settled only if the verdict that settled it still describes the CURRENT
20
20
  // content. `reviewed_sha` is the tree we actually judged; when the caller can compute
@@ -60,6 +60,21 @@ function authoringOwed(entries, gate, currentSha) {
60
60
  e.gate === gate &&
61
61
  ['revise', 'revise-upstream'].includes(e.decision));
62
62
  }
63
+ // D75 re-arms a gate whose reviewed sha has moved, and `worktreeTreeSha` covers the
64
+ // WHOLE worktree — so any state commit reaching it lapses the gate. That is correct, but
65
+ // the row it produces is a bare `gate implement`, indistinguishable from a CLI stuck on
66
+ // a stale answer. A human who just watched that gate pass reads it as the latter, and
67
+ // with a second session in the worktree answering `ship` the pair looks like a deadlock
68
+ // with no error anywhere. The lapse is a fact the CLI already knows; say it.
69
+ function lapseNote(entries, gate, currentSha) {
70
+ const last = lastGateRun(entries, gate);
71
+ if (!last || currentSha === undefined || last.reviewed_sha === currentSha)
72
+ return undefined;
73
+ // sha-free: asks "was it ever settled", which is the only thing that can lapse
74
+ if (!gateSettled(entries, gate))
75
+ return undefined;
76
+ return `${gate} approval lapsed — judged @${last.reviewed_sha.slice(0, 7)}, worktree now @${currentSha.slice(0, 7)} — re-gate to judge the current tree`;
77
+ }
63
78
  // The action for ONE flow. A flow is a plan with status `in-progress`: it begins at
64
79
  // `witness start` and ends at the merge stamp. Anything else returns undefined and is
65
80
  // not a flow. Exported because `--flow` and the dashboard must answer with the SAME
@@ -87,8 +102,13 @@ export function flowAction(root, cfg, plan) {
87
102
  const satisfied = files.length > 0 && baseR.ok && evidenceForDiff(wt, root, plan, baseR.value).satisfied;
88
103
  if (!satisfied)
89
104
  return { line: `witness test-evidence ${id} --phase red|green`, stage: 'implement', target: id, ...inWorktree };
90
- if (!gateSettled(entries, 'implement', worktreeTreeSha(wt)))
91
- return { line: `witness gate implement ${id}`, target: id, ...inWorktree };
105
+ const treeSha = worktreeTreeSha(wt);
106
+ if (!gateSettled(entries, 'implement', treeSha)) {
107
+ return {
108
+ line: `witness gate implement ${id}`, target: id, ...inWorktree,
109
+ ...noteOf(lapseNote(entries, 'implement', treeSha)),
110
+ };
111
+ }
92
112
  return { line: `witness ship ${id}`, stage: 'ship', target: id, ...atRoot };
93
113
  }
94
114
  // How far along a flow is — the drain order when several are actionable. Most-advanced
@@ -198,8 +218,13 @@ export function computeNext(root, ctx, canon, cfg) {
198
218
  .sort((a, b) => flowRank(b) - flowRank(a) || String(a.target).localeCompare(String(b.target)));
199
219
  if (flows.length > 0) {
200
220
  const waiting = pendingDecisionsAll(root, efforts, specs, plans);
221
+ // noteOf, not an overwrite: a lapse note explains WHY this row is the gate rather
222
+ // than the ship it was a moment ago, and must not be evicted by the waiting list.
201
223
  return waiting.length > 0
202
- ? { ...flows[0], note: `${waiting.length} waiting: ${waiting.map((w) => `${w.gate} ${w.target}`).join(' · ')}` }
224
+ ? {
225
+ ...flows[0],
226
+ ...noteOf(flows[0].note, `${waiting.length} waiting: ${waiting.map((w) => `${w.gate} ${w.target}`).join(' · ')}`),
227
+ }
203
228
  : flows[0];
204
229
  }
205
230
  // TIER 2 — human-owed decisions and bound endgames. Unchanged from today's rungs; they
@@ -398,6 +423,14 @@ export async function run(ctx, argv) {
398
423
  const lazy = lazyStamp(root, ctx, canon);
399
424
  if (lazy.stamped.length > 0)
400
425
  canon = loadCanon(root);
426
+ // `dashboard` renders these; `next` computed them and dropped them. A merge stamp that
427
+ // cannot proceed (commit refused, PR closed unmerged, lock held) leaves the flow parked
428
+ // at ship forever, and `next` — the one verb the driving loop calls every turn — said
429
+ // nothing at all. Printed BEFORE the routing block so it cannot split the contiguous
430
+ // next:/stage:/target:/note:/home:/run:/relay: unit the stage skills read verbatim.
431
+ if (lazy.stale.length > 0) {
432
+ rows('stale', ['plan', 'why'], lazy.stale).forEach(ctx.out);
433
+ }
401
434
  const { values } = parseArgs({ args: argv, options: { flow: { type: 'string' } }, allowPositionals: true });
402
435
  // Precedence: an explicit --flow is a CLAIM about a flow and refuses when false.
403
436
  // A worktree cwd is AMBIENT context — it may scope a read-only question, never select
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@popovych.co/witness",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
4
4
  "description": "Evidence-gated spec-driven development pipeline for AI coding agents: brainstorm to PR in six stages, where specs are state, plans are motion, and no transition passes without evidence. Drives from Claude Code or Pi.",
5
5
  "keywords": [
6
6
  "spec-driven-development",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "witness",
3
3
  "description": "Spec-driven development with evidence gates: brainstorm → PR through six stages and five gates. Skills author, the pinned witness CLI writes state and witnesses evidence; gates stop where judgment is owed.",
4
- "version": "0.4.1",
4
+ "version": "0.5.1",
5
5
  "author": {
6
6
  "name": "Kostiantyn Popovych"
7
7
  },
@@ -10,7 +10,7 @@ The CLI decides, you act. If `$ARGUMENTS` contains `--manual`, append `--manual`
10
10
  Resolve the CLI once:
11
11
 
12
12
  ```bash
13
- WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.4.1}"
13
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.5.1}"
14
14
  ```
15
15
 
16
16
  If the repo has no `witness.config.yaml`, run `$WITNESS init` first (one bootstrap commit), then proceed. If init or any verb refuses on a dirty tree, the human commits or cleans it — **never `git commit` yourself, anywhere, at any stage**: the CLI makes its own state commits, and the ship phase makes the sole code commit.
@@ -3,4 +3,4 @@
3
3
  # context). Silent everywhere that isn't a witness repo; failures swallowed:
4
4
  # session start must never hang or die on this.
5
5
  [ -f witness.config.yaml ] || exit 0
6
- ${WITNESS_BIN:-npx -y @popovych.co/witness@0.4.1} 2>/dev/null || true
6
+ ${WITNESS_BIN:-npx -y @popovych.co/witness@0.5.1} 2>/dev/null || true
@@ -15,7 +15,7 @@ witness: ends in a confirmed scope recap, writes no document. See NOTICE.md. -->
15
15
  Resolve the CLI once per session:
16
16
 
17
17
  ```bash
18
- WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.4.1}"
18
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.5.1}"
19
19
  ```
20
20
 
21
21
  - **Never edit `specs/**` or `plans/**`** (the canon dirs — `paths:` in witness.config.yaml may relocate them) — not with an edit tool, not with a write tool, not with Bash redirection. The CLI is the sole writer of state; you author in scratch files under `$(mktemp -d)` and hand them to the CLI. (The canon guard blocks you; the trailer audit catches what it can't.)
@@ -10,7 +10,7 @@ description: Slice a witness effort's confirmed recap into vertical spec slices,
10
10
  Resolve the CLI once per session:
11
11
 
12
12
  ```bash
13
- WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.4.1}"
13
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.5.1}"
14
14
  ```
15
15
 
16
16
  - **Never edit `specs/**` or `plans/**`** (the canon dirs — `paths:` in witness.config.yaml may relocate them) — not with an edit tool, not with a write tool, not with Bash redirection. The CLI is the sole writer of state; you author in scratch files under `$(mktemp -d)` and hand them to the CLI. (The canon guard blocks you; the trailer audit catches what it can't.)
@@ -16,7 +16,7 @@ No text copied verbatim; the design-critic lens is new material. -->
16
16
  Resolve the CLI once per session:
17
17
 
18
18
  ```bash
19
- WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.4.1}"
19
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.5.1}"
20
20
  ```
21
21
 
22
22
  - **Never edit `specs/**`, `plans/**`, or `designs/**`** — the CLI is the sole writer of state. Author HTML in `$(mktemp -d)` and hand it to `witness design`. (The canon guard blocks direct edits; the trailer audit catches end-runs.)
@@ -69,7 +69,7 @@ $WITNESS design <spec-id> --open # opens the artifact for the human — requir
69
69
  ```
70
70
 
71
71
  - **Register, then show.** `witness gate design` refuses `design-unseen` until the human has been shown the artifact's current bytes, so `--open` is part of the flow, not a nicety. Re-authoring re-arms it: new bytes, new showing. `witness next` names this step by itself — follow it.
72
- - The opener failing (`opener-failed`) prints a `file://` path. Give the human that path verbatim and stop; do not work around it by pointing `WITNESS_OPENER` at something that does not show anything.
72
+ - The opener failing (`opener-failed`) prints a `file://` path. Give the human that path verbatim and stop; do not work around it by pointing `opener:` in `.witness/config.local.yaml` at something that does not show anything.
73
73
 
74
74
  ## Gate
75
75
 
@@ -15,7 +15,7 @@ tests carry the parent spec's tag in their NAME. See NOTICE.md. -->
15
15
  Resolve the CLI once per session:
16
16
 
17
17
  ```bash
18
- WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.4.1}"
18
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.5.1}"
19
19
  ```
20
20
 
21
21
  - **Never edit `specs/**` or `plans/**`** (the canon dirs — `paths:` in witness.config.yaml may relocate them) — not with an edit tool, not with a write tool, not with Bash redirection. The CLI is the sole writer of state; you author in scratch files under `$(mktemp -d)` and hand them to the CLI. (The canon guard blocks you; the trailer audit catches what it can't.)
@@ -15,7 +15,7 @@ parent's criteria. See NOTICE.md. -->
15
15
  Resolve the CLI once per session:
16
16
 
17
17
  ```bash
18
- WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.4.1}"
18
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.5.1}"
19
19
  ```
20
20
 
21
21
  - **Never edit `specs/**` or `plans/**`** (the canon dirs — `paths:` in witness.config.yaml may relocate them) — not with an edit tool, not with a write tool, not with Bash redirection. The CLI is the sole writer of state; you author in scratch files under `$(mktemp -d)` and hand them to the CLI. (The canon guard blocks you; the trailer audit catches what it can't.)
@@ -15,7 +15,7 @@ model can. See NOTICE.md. -->
15
15
  Resolve the CLI once per session:
16
16
 
17
17
  ```bash
18
- WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.4.1}"
18
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.5.1}"
19
19
  ```
20
20
 
21
21
  - **Never edit `specs/**` or `plans/**`** (the canon dirs — `paths:` in witness.config.yaml may relocate them) — not with an edit tool, not with a write tool, not with Bash redirection. The CLI is the sole writer of state; you author in scratch files under `$(mktemp -d)` and hand them to the CLI. (The canon guard blocks you; the trailer audit catches what it can't.)