@popovych.co/witness 0.5.0 → 0.6.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 +2 -1
- package/dist/calibrate.js +7 -0
- package/dist/cli.js +5 -0
- package/dist/evidence.js +82 -6
- package/dist/gate.js +34 -7
- package/dist/gates/implement.js +30 -6
- package/dist/gates/ship.js +24 -14
- package/dist/gitio.js +55 -0
- package/dist/model.js +21 -3
- package/dist/reviewed.js +37 -14
- package/dist/sha.js +10 -0
- package/dist/ship.js +34 -9
- package/dist/verbs/check.js +9 -0
- package/dist/verbs/dashboard.js +6 -14
- package/dist/verbs/decide.js +72 -14
- package/dist/verbs/next.js +131 -23
- package/dist/verbs/write.js +21 -2
- 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
|
@@ -149,7 +149,8 @@ the wrong file refuses. There are no `WITNESS_*` env vars for configuration.
|
|
|
149
149
|
| `sync` | pull --rebase + push state commits |
|
|
150
150
|
| `calibrate <model>` | run the reviewer calibration battery |
|
|
151
151
|
| `recover [--complete\|--rollback]` | resolve a crashed write transaction |
|
|
152
|
-
|
|
|
152
|
+
| `status` | orientation: flows, blocked docs, reconcile rows, pending gates, calibration state |
|
|
153
|
+
| *(no verb)* | the same screen as `status` |
|
|
153
154
|
|
|
154
155
|
## Exit codes
|
|
155
156
|
|
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/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
|
@@ -14,12 +14,13 @@ import { kv, rows } from './toon.js';
|
|
|
14
14
|
import { loadMatrix, resolveModel, SESSION_DEFAULT } from './model.js';
|
|
15
15
|
import { docKeysFor, docsBlock, invokeReviewer, loadLensDocs, parseVerdictText, pinsBlock, promptsSha, resolvePrompt } from './reviewer.js';
|
|
16
16
|
import { anchorMenu, parseVerdict, verdictViolations } from './verdict.js';
|
|
17
|
-
import { ROUND_BOUND, appendKind, boundReached, gateRuns, roundsSinceApprove, } from './rounds.js';
|
|
17
|
+
import { ROUND_BOUND, appendKind, boundReached, gateRuns, lastGateRun, roundsSinceApprove, } 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) {
|
|
@@ -183,15 +190,35 @@ 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}`);
|
|
189
198
|
const key = {
|
|
190
199
|
reviewed_sha: input.reviewedSha, gate: spec.gate,
|
|
191
200
|
prompts_sha: promptsSha(lenses, pinsText === '' ? undefined : pinsText), model: chain[0], witness: version(),
|
|
192
201
|
harness: harness.name,
|
|
193
202
|
};
|
|
203
|
+
// D99: `gateSettled` reads only the last run, so any new run un-settles the gate.
|
|
204
|
+
// Content moving is a legitimate, self-explaining reason. A flag is not — `--fresh`
|
|
205
|
+
// discarded a human decision with nothing printed and nothing journaled about it, and
|
|
206
|
+
// row 94 removed its other job (escaping the changed-nothing deadlock), so it can
|
|
207
|
+
// refuse and send the human through the verb that states a retraction and its reason.
|
|
208
|
+
const settledBefore = gateSettled(entries, spec.gate);
|
|
209
|
+
if (flags.fresh && settledBefore) {
|
|
210
|
+
renderRefusal([v('gate', 'settled-approve', `${spec.gate} ${target} is settled`, `witness decide ${spec.gate} ${target} --revise --note "<why>" — retract the approval, then re-gate`)])
|
|
211
|
+
.forEach((l) => ctx.err(l));
|
|
212
|
+
return EXIT.REFUSED;
|
|
213
|
+
}
|
|
194
214
|
const kind = flags.fresh ? { kind: 'fresh' } : appendKind(entries, spec.gate, key);
|
|
215
|
+
// A key that moved for a NON-content reason — edited prompt, re-pinned model, new
|
|
216
|
+
// witness version — un-settles just as quietly as --fresh did. Content moving explains
|
|
217
|
+
// itself; this does not.
|
|
218
|
+
const lastRun = lastGateRun(entries, spec.gate);
|
|
219
|
+
if (settledBefore && kind.kind === 'fresh' && lastRun && lastRun.reviewed_sha === input.reviewedSha) {
|
|
220
|
+
ctx.err(`warning: reviewer setup changed — this run discards the settled approve on ${spec.gate} ${target}`);
|
|
221
|
+
}
|
|
195
222
|
if (kind.kind === 'resume') {
|
|
196
223
|
renderGateRun(ctx, kind.entry, 'resume');
|
|
197
224
|
printDispatchArithmetic(ctx, root, spec.gate, target);
|
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,10 +7,11 @@ 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';
|
|
14
|
+
import { gateSettled } from '../verbs/next.js';
|
|
14
15
|
import { lastGateRun } from '../rounds.js';
|
|
15
16
|
import { serializeDoc } from '../fm.js';
|
|
16
17
|
async function commandLane(wt, root, ctx, name, cmd) {
|
|
@@ -46,16 +47,21 @@ registerGate({
|
|
|
46
47
|
const checks = [];
|
|
47
48
|
const entries = readStream(root, planId);
|
|
48
49
|
const lastImplement = lastGateRun(entries, 'implement');
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
50
|
+
// One settle predicate, not two. This block used to inline its own copy of
|
|
51
|
+
// gateSettled, which meant `ship` and `next` could answer "is implement settled?"
|
|
52
|
+
// differently for the same journal — the split-brain that reads as a deadlock when
|
|
53
|
+
// two sessions compare notes.
|
|
54
|
+
//
|
|
55
|
+
// The missing sha argument is 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.
|
|
64
|
+
const implementSettled = gateSettled(entries, 'implement');
|
|
59
65
|
checks.push({ name: 'implement-gate', ok: implementSettled,
|
|
60
66
|
detail: lastImplement
|
|
61
67
|
? `last implement round ${lastImplement.round}: ${lastImplement.outcome}`
|
|
@@ -81,7 +87,7 @@ registerGate({
|
|
|
81
87
|
const recap = effort ? latestRecap(root, effort) : undefined;
|
|
82
88
|
return ok({
|
|
83
89
|
class: (recap?.class ?? 'feature'),
|
|
84
|
-
reviewedSha:
|
|
90
|
+
reviewedSha: diffReviewedSha(wt, base),
|
|
85
91
|
artifactSha: canonicalSha(plan.meta, plan.body),
|
|
86
92
|
reviewed: { kind: 'tree', root: wt, files },
|
|
87
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 })}`),
|
|
@@ -91,8 +97,12 @@ registerGate({
|
|
|
91
97
|
repin,
|
|
92
98
|
});
|
|
93
99
|
},
|
|
94
|
-
currentSha(root, _canon,
|
|
100
|
+
currentSha(root, _canon, cfg, planId) {
|
|
95
101
|
const wt = worktreePath(root, planId);
|
|
96
|
-
|
|
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;
|
|
97
107
|
},
|
|
98
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',
|
|
@@ -63,13 +96,35 @@ export function dirtyStatePaths(root) {
|
|
|
63
96
|
.filter((p) => !LOCAL_STATE_FILES.has(p));
|
|
64
97
|
}
|
|
65
98
|
export const TRAILER = 'Witness-State: 1';
|
|
99
|
+
// Row 87's rule: nothing to COMMIT is the same legal silent success as nothing to write.
|
|
100
|
+
// It lives here, at the one choke point every state commit passes through, because git
|
|
101
|
+
// rejects BOTH empty cases fatally and a caller cannot tell them apart in advance:
|
|
102
|
+
// • no paths at all → `commit --only ... --` dies "No paths with --include/--only"
|
|
103
|
+
// (a drift sweep with no live specs journals nothing and stamps nothing);
|
|
104
|
+
// • paths with no diff → `commit --only` exits non-zero (a restamp that restores a
|
|
105
|
+
// file to exactly its HEAD content, or a pathspec the repo gitignores).
|
|
106
|
+
// Either way there is no state to record, so HEAD is already the answer. Implementing
|
|
107
|
+
// this at init.ts's call site alone left every other caller throwing out of gitio and
|
|
108
|
+
// surfacing through bin.ts as `unexpected-error` — "report a bug" on a healthy repo.
|
|
109
|
+
function headSha(root) {
|
|
110
|
+
const head = tryGit(root, 'rev-parse', 'HEAD');
|
|
111
|
+
return head.ok ? head.out : ''; // unborn HEAD: no commit exists to name
|
|
112
|
+
}
|
|
66
113
|
export function commitWithTrailer(root, files, subject) {
|
|
114
|
+
if (files.length === 0)
|
|
115
|
+
return ok({ sha: headSha(root) });
|
|
67
116
|
// tryGit, not git: a path already fully processed by a prior `git mv` (both sides
|
|
68
117
|
// of a rename staged atomically) is absent from working tree AND index, so `add`
|
|
69
118
|
// reports it unmatched — even though the rename itself is already staged. `commit
|
|
70
119
|
// --only` resolves its pathspecs against the index-vs-HEAD diff, where the rename
|
|
71
120
|
// is visible, so it still succeeds and is the real validation here.
|
|
72
121
|
tryGit(root, 'add', '--', ...files);
|
|
122
|
+
// Ask git what actually changed rather than assuming every file we touched is dirty.
|
|
123
|
+
// Runs AFTER the add, so a freshly staged untracked path reads as `A ` and commits;
|
|
124
|
+
// a staged rename reads as `R ` on whichever side the pathspec names.
|
|
125
|
+
if (tryGit(root, 'status', '--porcelain', '--', ...files).out === '') {
|
|
126
|
+
return ok({ sha: headSha(root) });
|
|
127
|
+
}
|
|
73
128
|
// --no-verify: state commits are machine-authored and scope-restricted to non-source
|
|
74
129
|
// paths (isStatePath), so a host lint/test hook has nothing to validate here — while
|
|
75
130
|
// its stash/restore step can destroy the human's unrelated dirty work, and a
|
package/dist/model.js
CHANGED
|
@@ -64,10 +64,28 @@ export function resolveModel(cfg, matrix, gate) {
|
|
|
64
64
|
const head = chain[0];
|
|
65
65
|
const headLabel = head === SESSION_DEFAULT ? '(session default)' : head;
|
|
66
66
|
const matrixEmpty = matrix.shipped.length === 0 && matrix.local.length === 0;
|
|
67
|
-
const
|
|
67
|
+
const warningKind = calibrationOf(head) !== 'none'
|
|
68
68
|
? undefined
|
|
69
|
-
: matrixEmpty
|
|
69
|
+
: matrixEmpty ? 'matrix-empty' : 'below-floor';
|
|
70
|
+
const warning = warningKind === undefined
|
|
71
|
+
? undefined
|
|
72
|
+
: warningKind === 'matrix-empty'
|
|
70
73
|
? `calibration matrix is empty — no calibrated model exists yet; ${headLabel} runs uncalibrated`
|
|
71
74
|
: `reviewer model ${headLabel} is below the model floor — no calibration matrix entry covers it`;
|
|
72
|
-
return ok({ chain, calibrationOf, warning });
|
|
75
|
+
return ok({ chain, calibrationOf, warning, warningKind });
|
|
76
|
+
}
|
|
77
|
+
// One renderer for both orientation surfaces (`status` and `check`). Lives here because
|
|
78
|
+
// the grouping rule — one line per distinct warning, labelled with the gates it covers —
|
|
79
|
+
// is a property of the resolution, not of either screen. Returns `[]` when every gate's
|
|
80
|
+
// model is calibrated.
|
|
81
|
+
export function modelFloorLines(root, cfg, harness) {
|
|
82
|
+
const matrix = loadMatrix(root, harness);
|
|
83
|
+
const byWarning = new Map();
|
|
84
|
+
for (const gate of ['decompose', 'plan', 'implement', 'ship', 'design']) {
|
|
85
|
+
const r = resolveModel(cfg, matrix, gate);
|
|
86
|
+
if (r.ok && r.value.warning) {
|
|
87
|
+
byWarning.set(r.value.warning, [...(byWarning.get(r.value.warning) ?? []), gate]);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return [...byWarning].map(([warning, gates]) => `${gates.join(' · ')}: ${warning}`);
|
|
73
91
|
}
|
package/dist/reviewed.js
CHANGED
|
@@ -1,21 +1,44 @@
|
|
|
1
|
-
import { execFileSync } from 'node:child_process';
|
|
2
1
|
import { createHash } from 'node:crypto';
|
|
3
|
-
import {
|
|
4
|
-
import { tmpdir } from 'node:os';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
5
3
|
import { join } from 'node:path';
|
|
6
|
-
import { canonicalJson, canonicalSha } from './sha.js';
|
|
4
|
+
import { canonicalJson, canonicalSha, planContentSha } from './sha.js';
|
|
5
|
+
import { changedFiles } from './evidence.js';
|
|
6
|
+
import { git } from './gitio.js';
|
|
7
7
|
import { effortAbandoned, effortStreams, latestRecap, readStream } from './journal.js';
|
|
8
8
|
import { findById } from './scan.js';
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
9
|
+
// Row 96a. The reviewed identity is the DIFF the battery read: the base commit plus the
|
|
10
|
+
// content of every path `changedFiles` reports — exactly what `codePromptBody` renders as
|
|
11
|
+
// `### Changed files`, `### Diff vs base <sha>` and the untracked blocks. `worktreeTreeSha`
|
|
12
|
+
// hashed the WHOLE worktree via `add -A`, so witness's own journal entries and stamps —
|
|
13
|
+
// pulled in by ship's pre-battery rebase — moved the identity the verdict was keyed to, and
|
|
14
|
+
// the entry DESCRIBING a verdict invalidated it one hop later.
|
|
15
|
+
//
|
|
16
|
+
// The base term is required, not optional. A rebase can change the diff TEXT while every
|
|
17
|
+
// blob stays byte-identical; drop the base and a cached verdict replays against a diff no
|
|
18
|
+
// reviewer ever read, and implement loses the rebase re-arming it has today.
|
|
19
|
+
//
|
|
20
|
+
// Deleted paths carry an explicit marker: `hash-object` throws on a missing file, and
|
|
21
|
+
// dropping the path silently would make "this file is gone" invisible to the identity.
|
|
22
|
+
const DELETED_BLOB = '(deleted)';
|
|
23
|
+
export function diffReviewedSha(runRoot, base) {
|
|
24
|
+
const pairs = changedFiles(runRoot, base).map((rel) => existsSync(join(runRoot, rel))
|
|
25
|
+
? `${rel}\0${git(runRoot, 'hash-object', '--', rel)}`
|
|
26
|
+
: `${rel}\0${DELETED_BLOB}`);
|
|
27
|
+
return sha256([base, '\n', pairs.sort().join('\n')]);
|
|
28
|
+
}
|
|
29
|
+
// Row 95: implement's identity is the diff AND the plan, because the battery reads both —
|
|
30
|
+
// `codePromptBody` serializes the plan into the prompt — so a re-authored plan re-arms the
|
|
31
|
+
// gate. It lives here rather than in `gates/implement.ts` because there are THREE readers,
|
|
32
|
+
// not two: the gate's `resolve`, the gate's `currentSha`, and `flowAction`, which derives
|
|
33
|
+
// the sha itself instead of calling `currentSha`. Two of the three agreeing is the same
|
|
34
|
+
// split-brain rows 93 and 95 are about — the gate keyed on one sha while `next` checked
|
|
35
|
+
// another, and every settled gate read as lapsed one turn later.
|
|
36
|
+
export function implementReviewedSha(runRoot, base, plan) {
|
|
37
|
+
return createHash('sha256')
|
|
38
|
+
.update(diffReviewedSha(runRoot, base))
|
|
39
|
+
.update('\0')
|
|
40
|
+
.update(planContentSha(plan.meta, plan.body))
|
|
41
|
+
.digest('hex');
|
|
19
42
|
}
|
|
20
43
|
export function effortWrites(root, effort) {
|
|
21
44
|
const out = new Map();
|
package/dist/sha.js
CHANGED
|
@@ -22,6 +22,16 @@ export function canonicalSha(meta, body) {
|
|
|
22
22
|
.update(body.replace(/\s+$/, '') + '\n')
|
|
23
23
|
.digest('hex');
|
|
24
24
|
}
|
|
25
|
+
// Row 95: a re-authored plan re-arms the implement gate, so the plan's content joins that
|
|
26
|
+
// gate's reviewed identity — the battery reads the plan (`codePromptBody` serializes it into
|
|
27
|
+
// the prompt), so the identity should say so. `derives-from` is excluded ON TOP of
|
|
28
|
+
// VOLATILE_FIELDS because ship's own `repin` rewrites it inside the same transaction as the
|
|
29
|
+
// gate run: keep it and the entry invalidates itself the moment it is written, which is
|
|
30
|
+
// row 96's self-invalidation in a second place.
|
|
31
|
+
export function planContentSha(meta, body) {
|
|
32
|
+
const { 'derives-from': _repinned, ...rest } = meta;
|
|
33
|
+
return canonicalSha(rest, body);
|
|
34
|
+
}
|
|
25
35
|
export function short(sha) {
|
|
26
36
|
return sha.slice(0, 7);
|
|
27
37
|
}
|