@popovych.co/witness 0.5.1 → 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 +37 -6
- package/dist/calibrate.js +7 -0
- package/dist/cli.js +5 -0
- package/dist/drift.js +2 -2
- package/dist/evidence.js +82 -6
- package/dist/gate.js +79 -20
- package/dist/gates/implement.js +30 -6
- package/dist/gates/ship.js +17 -11
- package/dist/gitio.js +33 -0
- package/dist/harness.js +80 -32
- package/dist/install.js +92 -43
- package/dist/model.js +21 -3
- package/dist/registry.js +39 -0
- package/dist/reviewed.js +37 -14
- package/dist/rounds.js +45 -15
- package/dist/sha.js +10 -0
- package/dist/ship.js +34 -9
- package/dist/verbs/calibrate.js +2 -2
- package/dist/verbs/check.js +110 -45
- package/dist/verbs/dashboard.js +23 -19
- package/dist/verbs/decide.js +98 -23
- package/dist/verbs/dispatch.js +2 -2
- package/dist/verbs/init.js +18 -8
- package/dist/verbs/next.js +147 -38
- package/dist/verbs/write.js +21 -2
- package/dist/version.js +38 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/commands/witness.md +2 -2
- 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 +3 -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 —
|
|
@@ -149,7 +179,8 @@ the wrong file refuses. There are no `WITNESS_*` env vars for configuration.
|
|
|
149
179
|
| `sync` | pull --rebase + push state commits |
|
|
150
180
|
| `calibrate <model>` | run the reviewer calibration battery |
|
|
151
181
|
| `recover [--complete\|--rollback]` | resolve a crashed write transaction |
|
|
152
|
-
|
|
|
182
|
+
| `status` | orientation: flows, blocked docs, reconcile rows, pending gates, calibration state |
|
|
183
|
+
| *(no verb)* | the same screen as `status` |
|
|
153
184
|
|
|
154
185
|
## Exit codes
|
|
155
186
|
|
package/dist/calibrate.js
CHANGED
|
@@ -547,6 +547,13 @@ export async function runImplementSeed(ctx, harness, seed, agent) {
|
|
|
547
547
|
if (!planDoc)
|
|
548
548
|
return refuse([v('plan', 'seed-plan-missing', planId, 'the plan just written by the seed')]);
|
|
549
549
|
const report = evidenceForDiff(wt.value.path, root, planDoc, base.value);
|
|
550
|
+
// Row 97 narrowed `required` to the parent tag, so an empty list no longer means "nothing
|
|
551
|
+
// was owed" — it means the agent never wrote a test tagged for the spec it was given, and
|
|
552
|
+
// `.every()` would pass that vacuously. The seed's whole question is whether the parent's
|
|
553
|
+
// red→green pair got witnessed.
|
|
554
|
+
if (report.required.length === 0) {
|
|
555
|
+
return ok({ ok: false, why: 'no test tagged for the parent spec — nothing was witnessed' });
|
|
556
|
+
}
|
|
550
557
|
return ok({ ok: report.satisfied, why: report.satisfied ? 'evidence satisfied' : JSON.stringify(report.required) });
|
|
551
558
|
}
|
|
552
559
|
export async function runSkillSuites(ctx, harness, model, samples, opts = {}) {
|
package/dist/cli.js
CHANGED
|
@@ -21,6 +21,10 @@ const VERBS = {
|
|
|
21
21
|
decide: () => import('./verbs/decide.js'),
|
|
22
22
|
design: () => import('./verbs/design.js'),
|
|
23
23
|
start: () => import('./verbs/start.js'),
|
|
24
|
+
// The orientation screen has a name (D101). Bare `witness` stays as its alias, but
|
|
25
|
+
// an unnamed command cannot be referenced — commands/witness.md instructs the
|
|
26
|
+
// operator to read "the dashboard" and nothing said which command prints it.
|
|
27
|
+
status: () => import('./verbs/dashboard.js'),
|
|
24
28
|
clean: () => import('./verbs/clean.js'),
|
|
25
29
|
ship: () => import('./verbs/ship.js'),
|
|
26
30
|
next: () => import('./verbs/next.js'),
|
|
@@ -52,6 +56,7 @@ const VERB_USAGE = {
|
|
|
52
56
|
satisfy: 'witness satisfy <doc-id> --need <text | index>',
|
|
53
57
|
ship: 'witness ship <plan-id>',
|
|
54
58
|
start: 'witness start <plan-id>',
|
|
59
|
+
status: 'witness status — flows · blocked docs · reconcile rows · pending gates (bare `witness` is the same screen)',
|
|
55
60
|
sync: 'witness sync',
|
|
56
61
|
'test-evidence': 'witness test-evidence <plan-id> --phase red|green',
|
|
57
62
|
'verify-red': 'witness verify-red <plan-id> [--base <ref>]',
|
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/evidence.js
CHANGED
|
@@ -32,7 +32,7 @@ export function checkoutRoot(cwd) {
|
|
|
32
32
|
return refuse([v('cwd', 'not-a-repo', cwd, 'run inside a git checkout')]);
|
|
33
33
|
return ok(res.out.trim());
|
|
34
34
|
}
|
|
35
|
-
export async function runSpecTests(runRoot, ctx, parentId, trustRoot) {
|
|
35
|
+
export async function runSpecTests(runRoot, ctx, parentId, trustRoot, opts = {}) {
|
|
36
36
|
const cfg = loadConfig(runRoot);
|
|
37
37
|
if (!cfg.ok)
|
|
38
38
|
return cfg;
|
|
@@ -63,10 +63,20 @@ export async function runSpecTests(runRoot, ctx, parentId, trustRoot) {
|
|
|
63
63
|
}
|
|
64
64
|
return ok({ runner: 'filtered', tests, allOk: tests.every((t) => t.ok) });
|
|
65
65
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
66
|
+
// A caller checking several specs at once runs the suite ONCE and hands it here. Without
|
|
67
|
+
// this the regression lane executes the whole suite per spec, which is the redundancy
|
|
68
|
+
// `runCriteria` already pays per CRITERION.
|
|
69
|
+
let all;
|
|
70
|
+
if (opts.suite !== undefined) {
|
|
71
|
+
all = opts.suite;
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
const suite = await runFullSuite(runRoot, ctx, rc, trustRoot);
|
|
75
|
+
if (!suite.ok)
|
|
76
|
+
return suite;
|
|
77
|
+
all = suite.value.tests;
|
|
78
|
+
}
|
|
79
|
+
const tests = all
|
|
70
80
|
.filter((t) => matchesTag(t.name, parentId))
|
|
71
81
|
.map((t) => ({ name: t.name, ok: t.status === 'passed' }));
|
|
72
82
|
if (tests.length === 0) {
|
|
@@ -183,11 +193,77 @@ export function diffTags(runRoot, base) {
|
|
|
183
193
|
}
|
|
184
194
|
return [...tags].sort();
|
|
185
195
|
}
|
|
196
|
+
// Row 97. Editing someone else's tests creates a REGRESSION obligation — those tests still
|
|
197
|
+
// pass — never a red→green one: `red` means "I observed this behaviour missing before I
|
|
198
|
+
// built it", and producing a red on a spec you do not own means deliberately breaking it and
|
|
199
|
+
// journaling that as evidence. Detection reads the CURRENT CONTENT of every changed test
|
|
200
|
+
// file rather than added lines, because the case this exists for (a shared fixture grown
|
|
201
|
+
// from 3 to 24 respondents breaking a `@spec:report-view` e2e) touches no tagged line at all.
|
|
202
|
+
export function changedTestSpecs(runRoot, base, parentId) {
|
|
203
|
+
const tags = new Set();
|
|
204
|
+
for (const rel of changedFiles(runRoot, base).filter(isTestPath)) {
|
|
205
|
+
const abs = join(runRoot, rel);
|
|
206
|
+
if (!existsSync(abs))
|
|
207
|
+
continue; // deleted: no content left to owe anything
|
|
208
|
+
for (const tag of extractCanonicalTags(readFileSync(abs, 'utf8')))
|
|
209
|
+
tags.add(tag);
|
|
210
|
+
}
|
|
211
|
+
tags.delete(parentId); // the parent's obligation is red→green
|
|
212
|
+
return [...tags].sort();
|
|
213
|
+
}
|
|
214
|
+
// Never a `Result` refusal: this runs inside a gate's resolve(), and a refusal there aborts
|
|
215
|
+
// the whole gate — a fresh unescapable dead end, the exact class row 97 removes. Every
|
|
216
|
+
// runner problem (filter-matched-nothing included) degrades to a failed check carrying its
|
|
217
|
+
// detail, the shape `runCriteria` has always had.
|
|
218
|
+
//
|
|
219
|
+
// Nothing here journals. `journalEvidence`/`recordEvidence` are what write `test-evidence`
|
|
220
|
+
// entries and neither is on this path — an entry under a tag this plan does not own is
|
|
221
|
+
// exactly the false claim row 97 refuses to let anyone make.
|
|
222
|
+
export async function runRegression(runRoot, ctx, trustRoot, specIds, known) {
|
|
223
|
+
const out = [];
|
|
224
|
+
const memo = new Map();
|
|
225
|
+
const runnable = specIds.filter(known);
|
|
226
|
+
let suite;
|
|
227
|
+
const cfg = loadConfig(runRoot);
|
|
228
|
+
const rc = cfg.ok ? runnerConfig(cfg.value) : undefined;
|
|
229
|
+
if (rc?.ok && rc.value.mode === 'full-suite' && runnable.length > 0) {
|
|
230
|
+
const first = await runFullSuite(runRoot, ctx, rc.value, trustRoot);
|
|
231
|
+
if (first.ok)
|
|
232
|
+
suite = first.value.tests; // one suite run for every spec below
|
|
233
|
+
}
|
|
234
|
+
for (const id of specIds) {
|
|
235
|
+
if (!known(id)) {
|
|
236
|
+
out.push({ spec: id, state: 'unknown', detail: 'no such spec in canon — reported, not run' });
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
let run = memo.get(id);
|
|
240
|
+
if (run === undefined) {
|
|
241
|
+
run = await runSpecTests(runRoot, ctx, id, trustRoot, { suite });
|
|
242
|
+
memo.set(id, run);
|
|
243
|
+
}
|
|
244
|
+
if (!run.ok) {
|
|
245
|
+
out.push({ spec: id, state: 'unrunnable', detail: run.violations.map((x) => x.rule).join(' · ') });
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
const failed = run.value.tests.filter((t) => !t.ok).length;
|
|
249
|
+
out.push({
|
|
250
|
+
spec: id, state: run.value.allOk ? 'green' : 'red',
|
|
251
|
+
detail: `${run.value.tests.length} tagged · ${failed} failed`,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
return out;
|
|
255
|
+
}
|
|
186
256
|
export function evidenceForDiff(runRoot, stateRoot, plan, base) {
|
|
187
257
|
const planId = String(plan.meta.id);
|
|
258
|
+
const parentId = String(plan.meta.parent);
|
|
188
259
|
const entries = readStream(stateRoot, planId).filter((e) => e.t === 'test-evidence');
|
|
189
260
|
const matching = (e, tag) => (Array.isArray(e.tests) ? e.tests : []).filter((t) => matchesTag(t.name, tag));
|
|
190
|
-
|
|
261
|
+
// Row 97: red→green is a claim about the NEW BEHAVIOUR THIS PLAN BUILDS, so the only tag
|
|
262
|
+
// it can be made for is the plan's own parent — `test-evidence` interpolates
|
|
263
|
+
// `plan.meta.parent` and nothing else, which is why a foreign tag here was unsatisfiable
|
|
264
|
+
// AND (before row 93) unwaivable. Every other spec the diff's tests touched is a
|
|
265
|
+
// regression obligation, checked by `runRegression` at the gate.
|
|
266
|
+
const required = diffTags(runRoot, base).filter((tag) => tag === parentId).map((tag) => {
|
|
191
267
|
// latest-cycle semantics: the newest red matching the tag is the verdict on
|
|
192
268
|
// "was failure observed"; a green only counts if it post-dates that red.
|
|
193
269
|
// One early vacuous red must not poison the tag forever (append-only journal).
|
package/dist/gate.js
CHANGED
|
@@ -8,18 +8,19 @@ 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
|
+
import { gateSettled } from './verbs/next.js';
|
|
18
19
|
import { prepareStamp, writeStamp } from './stamp.js';
|
|
19
20
|
const GATES = new Map();
|
|
20
21
|
export function registerGate(spec) { GATES.set(spec.gate, spec); }
|
|
21
22
|
export function gateSpec(name) { return GATES.get(name); }
|
|
22
|
-
|
|
23
|
+
const DEFAULT_BATTERIES = {
|
|
23
24
|
decompose: ['slicing-critic'],
|
|
24
25
|
plan: ['plan-critic'],
|
|
25
26
|
implement: {
|
|
@@ -43,11 +44,17 @@ export function batteryFor(cfg, gate, cls) {
|
|
|
43
44
|
// where the state is. Skills used to recite a fixed triple, which is wrong at the bound
|
|
44
45
|
// (D67's endgame set) — and now wrong in three more states.
|
|
45
46
|
export function liveExits(gate, target, entries, stale) {
|
|
46
|
-
|
|
47
|
-
|
|
47
|
+
// The bound outranks staleness: at the bound the gate short-circuits and will not run
|
|
48
|
+
// again (:227), so "re-gate" is the D67 lie whatever the sha says. Stale content only
|
|
49
|
+
// removes APPROVE from the endgame — a human cannot honestly stamp bytes no battery
|
|
50
|
+
// read — which is the same set decide's stale-verdict refusal names.
|
|
48
51
|
if (boundReached(entries, gate)) {
|
|
49
|
-
return
|
|
52
|
+
return stale
|
|
53
|
+
? `witness decide ${gate} ${target} --revise --upstream <id> | --stop`
|
|
54
|
+
: `witness decide ${gate} ${target} --approve --override | --revise --upstream <id> | --stop`;
|
|
50
55
|
}
|
|
56
|
+
if (stale)
|
|
57
|
+
return `witness gate ${gate} ${target}`;
|
|
51
58
|
return `witness decide ${gate} ${target} --approve | --revise --note "<why>" | --revise --upstream <id> | --stop`;
|
|
52
59
|
}
|
|
53
60
|
export function renderGateRun(ctx, entry, mode) {
|
|
@@ -163,7 +170,7 @@ export async function runGate(ctx, gateName, target, flags) {
|
|
|
163
170
|
lens.docs = docs;
|
|
164
171
|
lenses.push(lens);
|
|
165
172
|
}
|
|
166
|
-
const hxR =
|
|
173
|
+
const hxR = resolveJudge(ctx.env, cfgR.value.raw);
|
|
167
174
|
if (!hxR.ok) {
|
|
168
175
|
renderRefusal(hxR.violations).forEach((l) => ctx.err(l));
|
|
169
176
|
return EXIT.REFUSED;
|
|
@@ -183,15 +190,40 @@ export async function runGate(ctx, gateName, target, flags) {
|
|
|
183
190
|
renderRefusal(modelR.violations).forEach((l) => ctx.err(l));
|
|
184
191
|
return EXIT.REFUSED;
|
|
185
192
|
}
|
|
186
|
-
const { chain, calibrationOf, warning } = modelR.value;
|
|
187
|
-
|
|
193
|
+
const { chain, calibrationOf, warning, warningKind } = modelR.value;
|
|
194
|
+
// matrix-empty is a fact about this witness build, reported once by `status`/`check`
|
|
195
|
+
// (D98a). Only the caller's own pin being below the floor is news at run time.
|
|
196
|
+
if (warning && warningKind === 'below-floor')
|
|
188
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];
|
|
189
203
|
const key = {
|
|
190
204
|
reviewed_sha: input.reviewedSha, gate: spec.gate,
|
|
191
|
-
prompts_sha: promptsSha(lenses, pinsText === '' ? undefined : pinsText),
|
|
205
|
+
prompts_sha: promptsSha(lenses, pinsText === '' ? undefined : pinsText), pin, witness: version(),
|
|
192
206
|
harness: harness.name,
|
|
193
207
|
};
|
|
208
|
+
// D99: `gateSettled` reads only the last run, so any new run un-settles the gate.
|
|
209
|
+
// Content moving is a legitimate, self-explaining reason. A flag is not — `--fresh`
|
|
210
|
+
// discarded a human decision with nothing printed and nothing journaled about it, and
|
|
211
|
+
// row 94 removed its other job (escaping the changed-nothing deadlock), so it can
|
|
212
|
+
// refuse and send the human through the verb that states a retraction and its reason.
|
|
213
|
+
const settledBefore = gateSettled(entries, spec.gate);
|
|
214
|
+
if (flags.fresh && settledBefore) {
|
|
215
|
+
renderRefusal([v('gate', 'settled-approve', `${spec.gate} ${target} is settled`, `witness decide ${spec.gate} ${target} --revise --note "<why>" — retract the approval, then re-gate`)])
|
|
216
|
+
.forEach((l) => ctx.err(l));
|
|
217
|
+
return EXIT.REFUSED;
|
|
218
|
+
}
|
|
194
219
|
const kind = flags.fresh ? { kind: 'fresh' } : appendKind(entries, spec.gate, key);
|
|
220
|
+
// A key that moved for a NON-content reason — edited prompt, re-pinned model, new
|
|
221
|
+
// witness version — un-settles just as quietly as --fresh did. Content moving explains
|
|
222
|
+
// itself; this does not.
|
|
223
|
+
const lastRun = lastGateRun(entries, spec.gate);
|
|
224
|
+
if (settledBefore && kind.kind === 'fresh' && lastRun && lastRun.reviewed_sha === input.reviewedSha) {
|
|
225
|
+
ctx.err(`warning: reviewer setup changed — this run discards the settled approve on ${spec.gate} ${target}`);
|
|
226
|
+
}
|
|
195
227
|
if (kind.kind === 'resume') {
|
|
196
228
|
renderGateRun(ctx, kind.entry, 'resume');
|
|
197
229
|
printDispatchArithmetic(ctx, root, spec.gate, target);
|
|
@@ -211,11 +243,30 @@ export async function runGate(ctx, gateName, target, flags) {
|
|
|
211
243
|
return EXIT.BLOCKED;
|
|
212
244
|
}
|
|
213
245
|
if (!flags.fresh) {
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
|
|
217
|
-
const
|
|
218
|
-
|
|
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;
|
|
219
270
|
if (tail.length === 2 && tail.every(sameSetup)) {
|
|
220
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));
|
|
221
272
|
return EXIT.REFUSED;
|
|
@@ -231,7 +282,7 @@ export async function runGate(ctx, gateName, target, flags) {
|
|
|
231
282
|
const fallback = [];
|
|
232
283
|
const rerolled = [];
|
|
233
284
|
let rung = 0;
|
|
234
|
-
let model =
|
|
285
|
+
let model = pin;
|
|
235
286
|
let cached = false;
|
|
236
287
|
if (kind.kind === 'cached') {
|
|
237
288
|
verdicts = kind.from.verdicts ?? [];
|
|
@@ -282,14 +333,22 @@ export async function runGate(ctx, gateName, target, flags) {
|
|
|
282
333
|
}
|
|
283
334
|
}
|
|
284
335
|
}
|
|
285
|
-
if (fallback.length > 0 && chain[0] !== SESSION_DEFAULT && fallback.includes(chain[0])) {
|
|
286
|
-
ctx.err(`warning: head model ${chain[0]} failed to invoke — reviewers ran on ${model}; check gates.${spec.gate}.model`);
|
|
287
|
-
}
|
|
288
336
|
const blocking = verdicts.flatMap((x) => x.findings).filter((f) => f.blocking).length;
|
|
289
337
|
const pinConflicts = verdicts.flatMap((x) => x.findings).filter((f) => f.contradicts_pin !== undefined).length;
|
|
290
338
|
const standing = [
|
|
291
339
|
input.standingStop,
|
|
292
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,
|
|
293
352
|
].filter((s) => s !== undefined).join(' · ') || undefined;
|
|
294
353
|
const checksGreen = input.checks.every((c) => c.ok);
|
|
295
354
|
const outcome = malformed.length > 0 ? 'malformed'
|
|
@@ -322,7 +381,7 @@ export async function runGate(ctx, gateName, target, flags) {
|
|
|
322
381
|
v: 1, t: 'gate-run', gate: spec.gate, artifact: target,
|
|
323
382
|
round: roundsSinceApprove(entriesNow, spec.gate) + 1, run_id: newRunId(),
|
|
324
383
|
reviewed_sha: input.reviewedSha, prompts_sha: key.prompts_sha,
|
|
325
|
-
witness: key.witness, model, harness: harness.name, calibration: calibrationOf(model),
|
|
384
|
+
witness: key.witness, model, pin, harness: harness.name, calibration: calibrationOf(model),
|
|
326
385
|
...(localR.value.reviewerExtensions.length ? { reviewer_extensions: localR.value.reviewerExtensions } : {}),
|
|
327
386
|
...(cached ? { cached: true } : {}),
|
|
328
387
|
...(flags.manual ? { manual: true } : {}),
|
package/dist/gates/implement.js
CHANGED
|
@@ -8,9 +8,9 @@ import { designRel } from '../design.js';
|
|
|
8
8
|
import { git } from '../gitio.js';
|
|
9
9
|
import { latestRecap, readStream } from '../journal.js';
|
|
10
10
|
import { findById } from '../scan.js';
|
|
11
|
-
import { changedFiles, diffBase, evidenceForDiff, screensDir } from '../evidence.js';
|
|
11
|
+
import { changedFiles, changedTestSpecs, diffBase, evidenceForDiff, runRegression, screensDir } from '../evidence.js';
|
|
12
12
|
import { runCriteria } from '../criteria.js';
|
|
13
|
-
import { effortOf,
|
|
13
|
+
import { effortOf, implementReviewedSha } from '../reviewed.js';
|
|
14
14
|
import { worktreePath } from '../worktree.js';
|
|
15
15
|
import { registerGate } from '../gate.js';
|
|
16
16
|
export function codePromptBody(wt, base, files, header) {
|
|
@@ -113,8 +113,26 @@ registerGate({
|
|
|
113
113
|
const missing = report.required.filter((r) => !(r.red && r.green && !r.vacuous));
|
|
114
114
|
checks.push({ name: 'evidence', ok: report.satisfied,
|
|
115
115
|
detail: report.satisfied
|
|
116
|
-
?
|
|
116
|
+
? (report.required.length > 0
|
|
117
|
+
? `${String(plan.meta.parent)}: red→green`
|
|
118
|
+
: `no ${String(plan.meta.parent)} tests in the diff — nothing to witness`)
|
|
117
119
|
: missing.map((m) => `${m.tag}: red=${m.red} green=${m.green}${m.vacuous ? ' vacuous' : ''}`).join(' · ') });
|
|
120
|
+
// Row 97: the obligation created by editing someone's tests is that those tests still
|
|
121
|
+
// pass — not that their whole spec is satisfied, which is the out-of-band drift sweep's
|
|
122
|
+
// job. `runCriteria` is deliberately NOT used here: it executes the foreign spec's `cmd`
|
|
123
|
+
// criteria, arbitrary trust-gated commands, inside an unattended gate.
|
|
124
|
+
const foreign = changedTestSpecs(wt, base, String(plan.meta.parent));
|
|
125
|
+
const regression = await runRegression(wt, ctx, root, foreign, (id) => findById(canon, id) !== undefined);
|
|
126
|
+
checks.push({
|
|
127
|
+
name: 'regression',
|
|
128
|
+
// `unknown` does not fail: witness cannot judge an obligation for a spec it does not
|
|
129
|
+
// have, and failing it re-creates the unsatisfiable check this row deletes. It is
|
|
130
|
+
// named in the detail, which is where a typo'd tag surfaces.
|
|
131
|
+
ok: regression.every((r) => r.state === 'green' || r.state === 'unknown'),
|
|
132
|
+
detail: regression.length === 0
|
|
133
|
+
? 'no foreign spec tests touched'
|
|
134
|
+
: regression.map((r) => `${r.spec}:${r.state}`).join(' · '),
|
|
135
|
+
});
|
|
118
136
|
const lane = await runCriteria(wt, ctx, parent, { trustRoot: root });
|
|
119
137
|
checks.push({
|
|
120
138
|
name: 'drift-lane',
|
|
@@ -127,7 +145,7 @@ registerGate({
|
|
|
127
145
|
const recap = effort ? latestRecap(root, effort) : undefined;
|
|
128
146
|
return ok({
|
|
129
147
|
class: (recap?.class ?? 'feature'),
|
|
130
|
-
reviewedSha:
|
|
148
|
+
reviewedSha: implementReviewedSha(wt, base, plan),
|
|
131
149
|
artifactSha: canonicalSha(plan.meta, plan.body),
|
|
132
150
|
reviewed: { kind: 'tree', root: wt, files },
|
|
133
151
|
promptBody: codePromptBody(wt, base, files, `### Plan under implementation: ${planId}\n${serializeDoc({ meta: plan.meta, body: plan.body })}`),
|
|
@@ -137,8 +155,14 @@ registerGate({
|
|
|
137
155
|
skipLenses,
|
|
138
156
|
});
|
|
139
157
|
},
|
|
140
|
-
currentSha(root,
|
|
158
|
+
currentSha(root, canon, cfg, planId) {
|
|
141
159
|
const wt = worktreePath(root, planId);
|
|
142
|
-
|
|
160
|
+
const plan = findById(canon, planId);
|
|
161
|
+
if (!existsSync(wt) || !plan)
|
|
162
|
+
return undefined;
|
|
163
|
+
const baseR = diffBase(wt, cfg);
|
|
164
|
+
// undefined is "cannot compute", never "moved": an unresolvable base must not
|
|
165
|
+
// un-settle a gate or invert `decide --show`'s staleness line.
|
|
166
|
+
return baseR.ok ? implementReviewedSha(wt, baseR.value, plan) : undefined;
|
|
143
167
|
},
|
|
144
168
|
});
|
package/dist/gates/ship.js
CHANGED
|
@@ -7,7 +7,7 @@ import { changedFiles, diffBase } from '../evidence.js';
|
|
|
7
7
|
import { runCriteria } from '../criteria.js';
|
|
8
8
|
import { execCommand } from '../runner.js';
|
|
9
9
|
import { ensureTrusted } from '../allowlist.js';
|
|
10
|
-
import {
|
|
10
|
+
import { diffReviewedSha, effortOf } from '../reviewed.js';
|
|
11
11
|
import { worktreePath } from '../worktree.js';
|
|
12
12
|
import { codePromptBody } from './implement.js';
|
|
13
13
|
import { registerGate } from '../gate.js';
|
|
@@ -52,13 +52,15 @@ registerGate({
|
|
|
52
52
|
// differently for the same journal — the split-brain that reads as a deadlock when
|
|
53
53
|
// two sessions compare notes.
|
|
54
54
|
//
|
|
55
|
-
// The missing sha argument is DELIBERATE
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
55
|
+
// The missing sha argument is still DELIBERATE, but row 96 killed one of its two
|
|
56
|
+
// reasons and the surviving one is now the whole reason. It used to rest on
|
|
57
|
+
// `worktreeTreeSha` hashing the entire worktree, so ship's own `pr #N` stamp — pulled
|
|
58
|
+
// in by the watch-phase rebase — moved the tree and lapsed the very gate ship was
|
|
59
|
+
// checking: approve → pr → lapse → gate, the D75/D77 livelock. Under the diff identity
|
|
60
|
+
// that reason is DEAD: the worktree never edits `plans/<id>.md`, so the stamp is always
|
|
61
|
+
// equal to base and can never enter the reviewed set. What remains: `next` re-arms on
|
|
62
|
+
// movement because authoring is what it routes to, while ship asks only whether
|
|
63
|
+
// implement was EVER settled. Passing a sha here re-opens the livelock — do not add one.
|
|
62
64
|
const implementSettled = gateSettled(entries, 'implement');
|
|
63
65
|
checks.push({ name: 'implement-gate', ok: implementSettled,
|
|
64
66
|
detail: lastImplement
|
|
@@ -85,7 +87,7 @@ registerGate({
|
|
|
85
87
|
const recap = effort ? latestRecap(root, effort) : undefined;
|
|
86
88
|
return ok({
|
|
87
89
|
class: (recap?.class ?? 'feature'),
|
|
88
|
-
reviewedSha:
|
|
90
|
+
reviewedSha: diffReviewedSha(wt, base),
|
|
89
91
|
artifactSha: canonicalSha(plan.meta, plan.body),
|
|
90
92
|
reviewed: { kind: 'tree', root: wt, files },
|
|
91
93
|
promptBody: codePromptBody(wt, base, files, `### Parent spec (from main): ${String(parent.meta.id)}\n${serializeDoc({ meta: parent.meta, body: parent.body })}\n\n### Plan: ${planId}\n${serializeDoc({ meta: plan.meta, body: plan.body })}`),
|
|
@@ -95,8 +97,12 @@ registerGate({
|
|
|
95
97
|
repin,
|
|
96
98
|
});
|
|
97
99
|
},
|
|
98
|
-
currentSha(root, _canon,
|
|
100
|
+
currentSha(root, _canon, cfg, planId) {
|
|
99
101
|
const wt = worktreePath(root, planId);
|
|
100
|
-
|
|
102
|
+
if (!existsSync(wt))
|
|
103
|
+
return undefined;
|
|
104
|
+
const baseR = diffBase(wt, cfg);
|
|
105
|
+
// undefined is "cannot compute", never "moved" — same doctrine as the implement gate.
|
|
106
|
+
return baseR.ok ? diffReviewedSha(wt, baseR.value) : undefined;
|
|
101
107
|
},
|
|
102
108
|
});
|
package/dist/gitio.js
CHANGED
|
@@ -39,6 +39,39 @@ export function stateDirs(root) {
|
|
|
39
39
|
export function isStatePath(root, rel) {
|
|
40
40
|
return stateDirs(root).some((d) => rel === d || rel.startsWith(d + '/'));
|
|
41
41
|
}
|
|
42
|
+
// Row 96b. Witness's own state commits reach `origin/main` on every `sync`, and `shipPhase`
|
|
43
|
+
// re-gates on ANY base advance — so the pipeline manufactured its own base movement (7 ship
|
|
44
|
+
// runs for 3 rounds on the audited plan). An advance whose every commit touches only state
|
|
45
|
+
// paths is bookkeeping, not base movement, and this is the ONE predicate both `baseMoved`
|
|
46
|
+
// and `rebaseIfMoved` ask; two copies would let ship rebase in the phase that was just told
|
|
47
|
+
// the base had not moved.
|
|
48
|
+
//
|
|
49
|
+
// Paths are primary and the trailer is secondary, and the conjunction is what keeps both
|
|
50
|
+
// forgeries conservative: a forged trailer on a source commit still reads as real movement
|
|
51
|
+
// (paths decide), and a hand-made commit that happens to touch only state paths reads as
|
|
52
|
+
// real movement too (no trailer). A needless rebase costs a round; a skipped one merges a
|
|
53
|
+
// tree no battery read.
|
|
54
|
+
export function stateOnlyAdvance(wt, root, from, to) {
|
|
55
|
+
const range = tryGit(wt, 'log', '--format=%H', `${from}..${to}`);
|
|
56
|
+
if (!range.ok)
|
|
57
|
+
return false;
|
|
58
|
+
const shas = range.out.split('\n').filter(Boolean);
|
|
59
|
+
if (shas.length === 0)
|
|
60
|
+
return false;
|
|
61
|
+
return shas.every((sha) => {
|
|
62
|
+
const trailer = tryGit(wt, 'show', '--no-patch', '--format=%(trailers:key=Witness-State,valueonly=true)', sha);
|
|
63
|
+
if (!trailer.ok || trailer.out.trim() !== '1')
|
|
64
|
+
return false;
|
|
65
|
+
// Per COMMIT, never the range's union diff: a commit that adds and deletes a source
|
|
66
|
+
// file inside the range cancels out in a union and would read as state-only. An empty
|
|
67
|
+
// name list (a merge commit) is unjudgeable here and therefore not state-only.
|
|
68
|
+
const touched = tryGit(wt, 'show', '--pretty=format:', '--name-only', sha);
|
|
69
|
+
if (!touched.ok)
|
|
70
|
+
return false;
|
|
71
|
+
const paths = touched.out.split('\n').filter(Boolean);
|
|
72
|
+
return paths.length > 0 && paths.every((p) => isStatePath(root, p));
|
|
73
|
+
});
|
|
74
|
+
}
|
|
42
75
|
const LOCAL_STATE_FILES = new Set([
|
|
43
76
|
'.witness/lock',
|
|
44
77
|
'.witness/txn.json',
|