@popovych.co/witness 0.6.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -5
- package/dist/cli.js +19 -2
- package/dist/drift.js +2 -2
- package/dist/gate.js +91 -43
- package/dist/gates/implement.js +1 -1
- package/dist/harness.js +80 -32
- package/dist/install.js +92 -43
- package/dist/registry.js +39 -0
- package/dist/rounds.js +89 -16
- 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 +96 -46
- package/dist/verbs/dispatch.js +2 -2
- package/dist/verbs/init.js +18 -8
- package/dist/verbs/next.js +49 -15
- package/dist/verbs/sync.js +16 -0
- 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/cli.js
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { configPath } from './config.js';
|
|
4
4
|
import { primaryRoot } from './gitio.js';
|
|
5
|
+
import { renderRefusal, v } from './refusal.js';
|
|
5
6
|
export const EXIT = { OK: 0, FINDINGS: 1, REFUSED: 2, BLOCKED: 3 };
|
|
6
7
|
const VERBS = {
|
|
7
8
|
recover: () => import('./verbs/recover.js'),
|
|
@@ -41,7 +42,7 @@ const VERB_USAGE = {
|
|
|
41
42
|
calibrate: 'witness calibrate <exact-model-id> [--suite all|reviewers|skills] [--only <name>] [--samples <n>] [--publish]',
|
|
42
43
|
check: 'witness check',
|
|
43
44
|
clean: 'witness clean',
|
|
44
|
-
decide: 'witness decide <gate> <target> --approve|--revise|--stop [--override] [--note <t>] [--upstream <artifact|effort>] [--pin <policy>]… [--show]',
|
|
45
|
+
decide: 'witness decide <gate> <target> --approve|--revise|--stop [--override] [--repair] [--note <t>] [--upstream <artifact|effort>] [--pin <policy>]… [--show]',
|
|
45
46
|
design: 'witness design <spec-id> --file <html> | --reconfirm | --open',
|
|
46
47
|
diff: 'witness diff <spec-id>',
|
|
47
48
|
'dispatch-report': 'witness dispatch-report <plan-id> --steps-assigned <n> --steps-completed <n> [--tokens <n>] [--tool-uses <n>] [--duration-ms <n>]',
|
|
@@ -99,5 +100,21 @@ export async function main(ctx, argv) {
|
|
|
99
100
|
ctx.err(usage());
|
|
100
101
|
return EXIT.REFUSED;
|
|
101
102
|
}
|
|
102
|
-
|
|
103
|
+
try {
|
|
104
|
+
return await (await load()).run(ctx, rest);
|
|
105
|
+
}
|
|
106
|
+
catch (e) {
|
|
107
|
+
// Row 113. Eight verbs parse argv with node's strict `parseArgs`, which THROWS on a
|
|
108
|
+
// stray positional or a mistyped flag. bin.ts's crash net then rendered a typo as
|
|
109
|
+
// `unexpected-error … a bug — re-run with the same arguments and report this line`:
|
|
110
|
+
// the tool asked to be reported for a mistake it could answer with its own usage. This
|
|
111
|
+
// is the one throw class that is a user statement rather than a witness fault, and the
|
|
112
|
+
// usage line lives right here. Anything else still reaches bin.ts unchanged.
|
|
113
|
+
const code = e.code;
|
|
114
|
+
if (typeof code === 'string' && code.startsWith('ERR_PARSE_ARGS')) {
|
|
115
|
+
renderRefusal([v(verb, 'bad-arguments', String(e.message).split('.')[0].slice(0, 160), VERB_USAGE[verb] ?? `witness ${verb} --help`)]).forEach((l) => ctx.err(l));
|
|
116
|
+
return EXIT.REFUSED;
|
|
117
|
+
}
|
|
118
|
+
throw e;
|
|
119
|
+
}
|
|
103
120
|
}
|
package/dist/drift.js
CHANGED
|
@@ -8,7 +8,7 @@ import { acquireLock } from './lock.js';
|
|
|
8
8
|
import { appendEntry, entryLine, journalRel, readStream } from './journal.js';
|
|
9
9
|
import { primaryRoot, stateCommit } from './gitio.js';
|
|
10
10
|
import { renderRefusal, v, ok } from './refusal.js';
|
|
11
|
-
import {
|
|
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,14 @@ import { appendEntry, entryLine, journalRel, policyPins, readStream } from './jo
|
|
|
8
8
|
import { primaryRoot, stateCommit } from './gitio.js';
|
|
9
9
|
import { loadCanon, findById } from './scan.js';
|
|
10
10
|
import { newRunId } from './drift.js';
|
|
11
|
-
import {
|
|
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, liveExits, roundBudget, roundsSinceApprove, runsSinceReset, } from './rounds.js';
|
|
18
|
+
export { liveExits } from './rounds.js';
|
|
18
19
|
import { gateSettled } from './verbs/next.js';
|
|
19
20
|
import { prepareStamp, writeStamp } from './stamp.js';
|
|
20
21
|
const GATES = new Map();
|
|
@@ -40,27 +41,16 @@ export function batteryFor(cfg, gate, cls) {
|
|
|
40
41
|
}
|
|
41
42
|
return ok(picked);
|
|
42
43
|
}
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
export function
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
// read — which is the same set decide's stale-verdict refusal names.
|
|
51
|
-
if (boundReached(entries, gate)) {
|
|
52
|
-
return stale
|
|
53
|
-
? `witness decide ${gate} ${target} --revise --upstream <id> | --stop`
|
|
54
|
-
: `witness decide ${gate} ${target} --approve --override | --revise --upstream <id> | --stop`;
|
|
55
|
-
}
|
|
56
|
-
if (stale)
|
|
57
|
-
return `witness gate ${gate} ${target}`;
|
|
58
|
-
return `witness decide ${gate} ${target} --approve | --revise --note "<why>" | --revise --upstream <id> | --stop`;
|
|
59
|
-
}
|
|
60
|
-
export function renderGateRun(ctx, entry, mode) {
|
|
44
|
+
// `entries` is the stream AS OF AFTER this entry: without it the renderer cannot tell the
|
|
45
|
+
// round that spends the budget from any other, and rows 109/110 are both about that round.
|
|
46
|
+
// `help: false` is for callers that render their own `exits:` line (`decide --show`).
|
|
47
|
+
export function renderGateRun(ctx, entry, mode, opts = {}) {
|
|
48
|
+
const entries = opts.entries ?? [];
|
|
49
|
+
const budget = opts.entries ? roundBudget(entries, entry.gate) : ROUND_BOUND;
|
|
50
|
+
const atBound = opts.entries !== undefined && boundReached(entries, entry.gate);
|
|
61
51
|
ctx.out(kv('gate', entry.gate));
|
|
62
52
|
ctx.out(kv('target', entry.artifact));
|
|
63
|
-
ctx.out(kv('round', `${entry.round} of ${
|
|
53
|
+
ctx.out(kv('round', `${entry.round} of ${budget}${mode === 'resume' ? ' (resume — content unchanged)' : ''}`));
|
|
64
54
|
ctx.out(kv('reviewed', entry.reviewed_sha.slice(0, 7)));
|
|
65
55
|
ctx.out(kv('model', `${entry.model} · calibration: ${entry.calibration}${entry.cached ? ' · cached' : ''}`));
|
|
66
56
|
if (entry.skipped?.length)
|
|
@@ -84,11 +74,20 @@ export function renderGateRun(ctx, entry, mode) {
|
|
|
84
74
|
ctx.out(kv('standing-stop', entry.standing));
|
|
85
75
|
ctx.out(kv('outcome', entry.outcome));
|
|
86
76
|
if (entry.outcome !== 'passed') {
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
// the
|
|
91
|
-
|
|
77
|
+
// Row 110. The round that SPENDS the budget is the one that has to say so. It rendered
|
|
78
|
+
// like any other, so the natural next act — fix the finding, re-gate — was advertised
|
|
79
|
+
// by the help line below and then refused twice: the gate short-circuits, and the edit
|
|
80
|
+
// that answered the finding forfeits `--approve` under D75. Nothing warned, and the
|
|
81
|
+
// finding is itself an instruction to edit. Printed above the `help:` suppression, not
|
|
82
|
+
// inside it: `decide --show` renders its own exits and is the surface a human reads
|
|
83
|
+
// while deciding — the one place this warning is most owed.
|
|
84
|
+
if (atBound) {
|
|
85
|
+
ctx.out(kv('last-round', 'the round budget is spent — the gate will not run again, and an edit from here forfeits --approve (the verdict would describe an older tree)'));
|
|
86
|
+
}
|
|
87
|
+
// Without `entries` this stays a pure formatter over the entry it was handed, and the
|
|
88
|
+
// off-bound triple is the right answer; with them it tells the truth at the bound too.
|
|
89
|
+
if (opts.help !== false)
|
|
90
|
+
ctx.out(`help: ${liveExits(entry.gate, entry.artifact, entries, false)}`);
|
|
92
91
|
}
|
|
93
92
|
}
|
|
94
93
|
// Row 79: the human approving a plan sees the run's shape before it starts.
|
|
@@ -139,9 +138,9 @@ export async function runGate(ctx, gateName, target, flags) {
|
|
|
139
138
|
renderRefusal(batteryR.violations).forEach((l) => ctx.err(l));
|
|
140
139
|
return EXIT.REFUSED;
|
|
141
140
|
}
|
|
142
|
-
const skip = new
|
|
141
|
+
const skip = new Map((input.skipLenses ?? []).map((s) => [s.lens, s.why]));
|
|
143
142
|
const active = batteryR.value.filter((n) => !skip.has(n));
|
|
144
|
-
const dropped = batteryR.value.filter((n) => skip.has(n));
|
|
143
|
+
const dropped = batteryR.value.filter((n) => skip.has(n)).map((n) => `${n} — ${skip.get(n)}`);
|
|
145
144
|
const entries = readStream(root, target);
|
|
146
145
|
const pins = policyPins(entries);
|
|
147
146
|
const pinsText = pinsBlock(pins);
|
|
@@ -170,7 +169,7 @@ export async function runGate(ctx, gateName, target, flags) {
|
|
|
170
169
|
lens.docs = docs;
|
|
171
170
|
lenses.push(lens);
|
|
172
171
|
}
|
|
173
|
-
const hxR =
|
|
172
|
+
const hxR = resolveJudge(ctx.env, cfgR.value.raw);
|
|
174
173
|
if (!hxR.ok) {
|
|
175
174
|
renderRefusal(hxR.violations).forEach((l) => ctx.err(l));
|
|
176
175
|
return EXIT.REFUSED;
|
|
@@ -195,9 +194,14 @@ export async function runGate(ctx, gateName, target, flags) {
|
|
|
195
194
|
// (D98a). Only the caller's own pin being below the floor is news at run time.
|
|
196
195
|
if (warning && warningKind === 'below-floor')
|
|
197
196
|
ctx.err(`warning: ${warning}`);
|
|
197
|
+
// Row 106: the key is built BEFORE invoking, so the only model knowable here is the one
|
|
198
|
+
// being asked for. Naming it `pin` is what stops the entry's `model` — what actually
|
|
199
|
+
// answered — from being compared against it by accident, which is how the streak brake
|
|
200
|
+
// below spent three releases never firing.
|
|
201
|
+
const pin = chain[0];
|
|
198
202
|
const key = {
|
|
199
203
|
reviewed_sha: input.reviewedSha, gate: spec.gate,
|
|
200
|
-
prompts_sha: promptsSha(lenses, pinsText === '' ? undefined : pinsText),
|
|
204
|
+
prompts_sha: promptsSha(lenses, pinsText === '' ? undefined : pinsText), pin, witness: version(),
|
|
201
205
|
harness: harness.name,
|
|
202
206
|
};
|
|
203
207
|
// D99: `gateSettled` reads only the last run, so any new run un-settles the gate.
|
|
@@ -211,7 +215,24 @@ export async function runGate(ctx, gateName, target, flags) {
|
|
|
211
215
|
.forEach((l) => ctx.err(l));
|
|
212
216
|
return EXIT.REFUSED;
|
|
213
217
|
}
|
|
214
|
-
|
|
218
|
+
let kind = flags.fresh ? { kind: 'fresh' } : appendKind(entries, spec.gate, key);
|
|
219
|
+
// Row 112. The cache key is the REVIEWED content digest, but the deterministic checks
|
|
220
|
+
// read inputs it cannot see — decompose's `goal-coverage`/`spec-coverage` read journaled
|
|
221
|
+
// `covers`, `amendment-ack` reads a sibling plan's pin, `graph` reads the whole canon. So
|
|
222
|
+
// correcting a goal mapping left the doc bytes untouched, the key unmoved, and `resume`
|
|
223
|
+
// served a blocking finding describing a state that no longer existed; `--fresh` was the
|
|
224
|
+
// only escape, and an operator who did not think of it spent a human decision on it.
|
|
225
|
+
// `resolve` has already recomputed the checks by this point — the fresh answer was in
|
|
226
|
+
// hand and thrown away. A flip is a new judgment, so it appends a real round rather than
|
|
227
|
+
// silently re-rendering; the verdicts are REPLAYED (`cached`), so no battery is spent.
|
|
228
|
+
// Compared on `ok` alone: a detail carries run output on some gates (timings, paths), and
|
|
229
|
+
// keying on it would append a round for every re-run of an unchanged tree.
|
|
230
|
+
const checkOk = (cs) => cs.map((c) => `${c.name}:${c.ok}`).sort().join('|');
|
|
231
|
+
if ((kind.kind === 'resume' || kind.kind === 'changed-nothing') &&
|
|
232
|
+
checkOk(kind.entry.checks) !== checkOk(input.checks) &&
|
|
233
|
+
kind.entry.outcome !== 'malformed' && (kind.entry.verdicts?.length ?? 0) > 0) {
|
|
234
|
+
kind = { kind: 'cached', from: kind.entry };
|
|
235
|
+
}
|
|
215
236
|
// A key that moved for a NON-content reason — edited prompt, re-pinned model, new
|
|
216
237
|
// witness version — un-settles just as quietly as --fresh did. Content moving explains
|
|
217
238
|
// itself; this does not.
|
|
@@ -220,7 +241,7 @@ export async function runGate(ctx, gateName, target, flags) {
|
|
|
220
241
|
ctx.err(`warning: reviewer setup changed — this run discards the settled approve on ${spec.gate} ${target}`);
|
|
221
242
|
}
|
|
222
243
|
if (kind.kind === 'resume') {
|
|
223
|
-
renderGateRun(ctx, kind.entry, 'resume');
|
|
244
|
+
renderGateRun(ctx, kind.entry, 'resume', { entries });
|
|
224
245
|
printDispatchArithmetic(ctx, root, spec.gate, target);
|
|
225
246
|
return kind.entry.outcome === 'passed' ? EXIT.OK : EXIT.FINDINGS;
|
|
226
247
|
}
|
|
@@ -238,11 +259,30 @@ export async function runGate(ctx, gateName, target, flags) {
|
|
|
238
259
|
return EXIT.BLOCKED;
|
|
239
260
|
}
|
|
240
261
|
if (!flags.fresh) {
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
|
|
244
|
-
const
|
|
245
|
-
|
|
262
|
+
// Both brakes guard the SAME window as the round budget (rounds.ts): exempt rounds
|
|
263
|
+
// could otherwise repeat for free, and a run on the far side of an approve was
|
|
264
|
+
// disposed of and can trip nothing.
|
|
265
|
+
const tail = runsSinceReset(entries, spec.gate).slice(-2);
|
|
266
|
+
const samePin = (r) => (r.pin ?? r.model) === key.pin && (r.harness ?? 'claude-code') === key.harness;
|
|
267
|
+
// Row 107. A fallen-back round does not spend the budget, and an exempt round that
|
|
268
|
+
// repeats forever is D67's livelock — the exemption and this trigger are one change.
|
|
269
|
+
// `prompts_sha` is deliberately NOT compared: a lens edit has nothing to do with
|
|
270
|
+
// whether a model answers, and comparing it would let an unrelated edit reset a brake
|
|
271
|
+
// on a dead pin. Checked first because when both hold, "the pinned model is not
|
|
272
|
+
// answering" is the true remedy and "your battery emits invalid verdicts" is not.
|
|
273
|
+
// No --fresh in the want: --fresh bypasses the brake and would spend a battery
|
|
274
|
+
// re-invoking the same dead pin. Fixing the pin moves key.pin, samePin goes false,
|
|
275
|
+
// and the battery runs — which is only reachable BECAUSE these rounds were exempt.
|
|
276
|
+
if (tail.length === 2 && tail.every((r) => fellBack(r) && samePin(r))) {
|
|
277
|
+
renderRefusal([v(`gates.${spec.gate}.model`, 'fallback-streak', `${tail.length} consecutive rounds fell back from ${key.pin}`, 'a reachable gates.<gate>.model — the pinned model is not answering')])
|
|
278
|
+
.forEach((l) => ctx.err(l));
|
|
279
|
+
return EXIT.REFUSED;
|
|
280
|
+
}
|
|
281
|
+
// malformed rounds don't spend the bound either — this brake is what stops an
|
|
282
|
+
// unreliable battery from re-running for free forever instead. Row 106: "same setup"
|
|
283
|
+
// always meant the same PIN, and comparing the answering rung meant two malformed
|
|
284
|
+
// fallen-back rounds never matched, which is the one thing this exists to stop.
|
|
285
|
+
const sameSetup = (r) => r.outcome === 'malformed' && samePin(r) && r.prompts_sha === key.prompts_sha;
|
|
246
286
|
if (tail.length === 2 && tail.every(sameSetup)) {
|
|
247
287
|
renderRefusal([v('reviewers', 'malformed-streak', `${tail.length} consecutive malformed rounds on ${tail[1].model}`, `a changed gates.${spec.gate}.model pin or updated prompts — the battery is emitting invalid verdicts (or force with --fresh)`)]).forEach((l) => ctx.err(l));
|
|
248
288
|
return EXIT.REFUSED;
|
|
@@ -258,7 +298,7 @@ export async function runGate(ctx, gateName, target, flags) {
|
|
|
258
298
|
const fallback = [];
|
|
259
299
|
const rerolled = [];
|
|
260
300
|
let rung = 0;
|
|
261
|
-
let model =
|
|
301
|
+
let model = pin;
|
|
262
302
|
let cached = false;
|
|
263
303
|
if (kind.kind === 'cached') {
|
|
264
304
|
verdicts = kind.from.verdicts ?? [];
|
|
@@ -309,14 +349,22 @@ export async function runGate(ctx, gateName, target, flags) {
|
|
|
309
349
|
}
|
|
310
350
|
}
|
|
311
351
|
}
|
|
312
|
-
if (fallback.length > 0 && chain[0] !== SESSION_DEFAULT && fallback.includes(chain[0])) {
|
|
313
|
-
ctx.err(`warning: head model ${chain[0]} failed to invoke — reviewers ran on ${model}; check gates.${spec.gate}.model`);
|
|
314
|
-
}
|
|
315
352
|
const blocking = verdicts.flatMap((x) => x.findings).filter((f) => f.blocking).length;
|
|
316
353
|
const pinConflicts = verdicts.flatMap((x) => x.findings).filter((f) => f.contradicts_pin !== undefined).length;
|
|
317
354
|
const standing = [
|
|
318
355
|
input.standingStop,
|
|
319
356
|
pinConflicts > 0 ? 'contradicts-pin — a finding disputes a settled policy pin; the human decides which one dies' : undefined,
|
|
357
|
+
// Row 98c, specified by row 107. Composed HERE, after the battery, because whether a
|
|
358
|
+
// fallback happened is not knowable at resolve() time. Dismissed by a plain
|
|
359
|
+
// --approve: --override is reserved for the bound and this is a first-round event.
|
|
360
|
+
// The remedy is deliberately absent — on round one the honest statement is that a
|
|
361
|
+
// human decides whether the verdict counts; the remedy becomes true only once the pin
|
|
362
|
+
// proves persistently dead, which is what fallback-streak above prints. It REPLACES
|
|
363
|
+
// the stderr warning that fired on this same condition: unjournaled and non-blocking,
|
|
364
|
+
// it let a substituted verdict pass behind a line that scrolls by.
|
|
365
|
+
model !== pin
|
|
366
|
+
? `fallback — reviewers ran on ${model}, not the pinned ${pin}; the human decides whether that verdict counts`
|
|
367
|
+
: undefined,
|
|
320
368
|
].filter((s) => s !== undefined).join(' · ') || undefined;
|
|
321
369
|
const checksGreen = input.checks.every((c) => c.ok);
|
|
322
370
|
const outcome = malformed.length > 0 ? 'malformed'
|
|
@@ -349,7 +397,7 @@ export async function runGate(ctx, gateName, target, flags) {
|
|
|
349
397
|
v: 1, t: 'gate-run', gate: spec.gate, artifact: target,
|
|
350
398
|
round: roundsSinceApprove(entriesNow, spec.gate) + 1, run_id: newRunId(),
|
|
351
399
|
reviewed_sha: input.reviewedSha, prompts_sha: key.prompts_sha,
|
|
352
|
-
witness: key.witness, model, harness: harness.name, calibration: calibrationOf(model),
|
|
400
|
+
witness: key.witness, model, pin, harness: harness.name, calibration: calibrationOf(model),
|
|
353
401
|
...(localR.value.reviewerExtensions.length ? { reviewer_extensions: localR.value.reviewerExtensions } : {}),
|
|
354
402
|
...(cached ? { cached: true } : {}),
|
|
355
403
|
...(flags.manual ? { manual: true } : {}),
|
|
@@ -396,7 +444,7 @@ export async function runGate(ctx, gateName, target, flags) {
|
|
|
396
444
|
renderRefusal(txn.violations).forEach((l) => ctx.err(l));
|
|
397
445
|
return EXIT.REFUSED;
|
|
398
446
|
}
|
|
399
|
-
renderGateRun(ctx, entry, 'ran');
|
|
447
|
+
renderGateRun(ctx, entry, 'ran', { entries: [...entriesNow, entry] });
|
|
400
448
|
printDispatchArithmetic(ctx, root, spec.gate, target);
|
|
401
449
|
if (input.repin)
|
|
402
450
|
ctx.out(kv('re-pinned', `derives-from → ${input.repin.sha.slice(0, 7)} (witnessed by the drift lane)`));
|
package/dist/gates/implement.js
CHANGED
|
@@ -104,7 +104,7 @@ registerGate({
|
|
|
104
104
|
}
|
|
105
105
|
else {
|
|
106
106
|
// not UI-work: drop the lens if a battery names it (stages shrink, never skip)
|
|
107
|
-
skipLenses.push('design-reviewer');
|
|
107
|
+
skipLenses.push({ lens: 'design-reviewer', why: 'no design-from pin: not UI work' });
|
|
108
108
|
}
|
|
109
109
|
const checks = [];
|
|
110
110
|
checks.push({ name: 'diff', ok: files.length > 0,
|
package/dist/harness.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { existsSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { CLAUDE_THINKING_BUDGET, parsePin } from './pin.js';
|
|
4
4
|
import { ok, refuse, v } from './refusal.js';
|
|
5
|
+
import { pinIn } from './version.js';
|
|
5
6
|
export const HARNESSES = ['claude-code', 'pi'];
|
|
6
7
|
export const DEFAULT_HARNESS = 'claude-code';
|
|
7
8
|
// The six shipped stage skills, by directory name. `check`'s visibility probe asks
|
|
@@ -150,33 +151,59 @@ export function loadHarness(name) {
|
|
|
150
151
|
}
|
|
151
152
|
return ok(harness);
|
|
152
153
|
}
|
|
153
|
-
//
|
|
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.
|