@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 +35 -5
- package/dist/drift.js +2 -2
- package/dist/gate.js +46 -14
- package/dist/harness.js +80 -32
- package/dist/install.js +92 -43
- package/dist/registry.js +39 -0
- package/dist/rounds.js +45 -15
- package/dist/verbs/calibrate.js +2 -2
- package/dist/verbs/check.js +102 -46
- package/dist/verbs/dashboard.js +18 -6
- package/dist/verbs/decide.js +26 -9
- package/dist/verbs/dispatch.js +2 -2
- package/dist/verbs/init.js +18 -8
- package/dist/verbs/next.js +46 -12
- package/dist/version.js +38 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/commands/witness.md +1 -1
- package/plugin/hooks/session-dashboard.sh +1 -1
- package/plugin/skills/witness-brainstorm/SKILL.md +1 -1
- package/plugin/skills/witness-decompose/SKILL.md +1 -1
- package/plugin/skills/witness-design/SKILL.md +1 -1
- package/plugin/skills/witness-implement/SKILL.md +1 -1
- package/plugin/skills/witness-plan/SKILL.md +1 -1
- package/plugin/skills/witness-ship/SKILL.md +1 -1
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
|
|
85
|
-
|
|
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` |
|
|
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
|
|
108
|
-
mode for every reviewer (
|
|
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 {
|
|
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 =
|
|
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 {
|
|
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,
|
|
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 =
|
|
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),
|
|
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
|
-
//
|
|
242
|
-
//
|
|
243
|
-
|
|
244
|
-
const
|
|
245
|
-
|
|
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 =
|
|
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
|
-
//
|
|
154
|
-
//
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|
179
|
-
|
|
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
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
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
|
|
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
|
|
30
|
-
return
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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.
|
|
44
|
-
// to live in the user's repo
|
|
45
|
-
// which CLI
|
|
46
|
-
// to
|
|
47
|
-
//
|
|
48
|
-
//
|
|
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: [],
|
|
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
|
-
|
|
68
|
-
if (installed === shipped)
|
|
110
|
+
if (readFileSync(dst, 'utf8') === shipped)
|
|
69
111
|
continue;
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
117
|
-
// first payload set; a later `--agent` for a
|
|
118
|
-
//
|
|
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
|
-
|
|
121
|
-
|
|
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 {
|
|
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);
|
package/dist/registry.js
ADDED
|
@@ -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
|
+
}
|