@popovych.co/witness 0.5.1 → 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 +17 -11
- package/dist/gitio.js +33 -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 +102 -27
- 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,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',
|
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
|
}
|
package/dist/ship.js
CHANGED
|
@@ -4,7 +4,7 @@ import { loadConfig } from './config.js';
|
|
|
4
4
|
import { acquireLock } from './lock.js';
|
|
5
5
|
import { crashPoint, guardTxn, withTxn } from './txn.js';
|
|
6
6
|
import { journalRel, readStream } from './journal.js';
|
|
7
|
-
import { primaryRoot, stateCommit, tryGit } from './gitio.js';
|
|
7
|
+
import { primaryRoot, stateCommit, stateOnlyAdvance, tryGit } from './gitio.js';
|
|
8
8
|
import { findById, loadCanon } from './scan.js';
|
|
9
9
|
import { ok, refuse, renderRefusal, v } from './refusal.js';
|
|
10
10
|
import { kv } from './toon.js';
|
|
@@ -12,8 +12,9 @@ import { runGate } from './gate.js';
|
|
|
12
12
|
import { lastGateRun } from './rounds.js';
|
|
13
13
|
import { prepareStamp, writeStamp } from './stamp.js';
|
|
14
14
|
import { branchName, worktreePath } from './worktree.js';
|
|
15
|
-
import { gateSettled } from './verbs/next.js';
|
|
16
|
-
import {
|
|
15
|
+
import { authoringOwed, gateSettled } from './verbs/next.js';
|
|
16
|
+
import { diffReviewedSha } from './reviewed.js';
|
|
17
|
+
import { diffBase } from './evidence.js';
|
|
17
18
|
export function shipPhase(plan, entries, treeSha, baseMoved = false) {
|
|
18
19
|
// A PR exists, but the watch is only legitimate while the verdict still describes both
|
|
19
20
|
// this tree AND this base. Either one moving re-arms the gate — otherwise a rebase (or
|
|
@@ -116,7 +117,18 @@ export function stampPr(ctx, root, planId, pr) {
|
|
|
116
117
|
lockR.value();
|
|
117
118
|
}
|
|
118
119
|
}
|
|
119
|
-
|
|
120
|
+
// One read-only fetch decides the whole routing. A failed fetch (no remote, offline) leaves
|
|
121
|
+
// this false and the phases behave as they did — the gate phase's own rebaseIfMoved is where
|
|
122
|
+
// that condition turns into a structured refusal.
|
|
123
|
+
export function baseMoved(wt, root, shipBranch, remote = 'origin') {
|
|
124
|
+
if (!tryGit(wt, 'fetch', remote, shipBranch).ok)
|
|
125
|
+
return false;
|
|
126
|
+
const base = `${remote}/${shipBranch}`;
|
|
127
|
+
if (tryGit(wt, 'merge-base', '--is-ancestor', base, 'HEAD').ok)
|
|
128
|
+
return false;
|
|
129
|
+
return !stateOnlyAdvance(wt, root, 'HEAD', base);
|
|
130
|
+
}
|
|
131
|
+
export function rebaseIfMoved(wt, root, shipBranch, remote = 'origin') {
|
|
120
132
|
// Resolve the base from the REMOTE tip, not the local ref: with several flows merging
|
|
121
133
|
// concurrently, local <shipBranch> lags every merge since the human's last pull, so a
|
|
122
134
|
// local ancestry check reports "clean" against a base that is provably behind and the
|
|
@@ -134,6 +146,10 @@ export function rebaseIfMoved(wt, shipBranch, remote = 'origin') {
|
|
|
134
146
|
const base = `${remote}/${shipBranch}`;
|
|
135
147
|
if (tryGit(wt, 'merge-base', '--is-ancestor', base, 'HEAD').ok)
|
|
136
148
|
return ok('clean');
|
|
149
|
+
// Same predicate as baseMoved: an advance made entirely of witness's own state commits is
|
|
150
|
+
// bookkeeping, and rebasing onto it moves the base term of every reviewed sha for nothing.
|
|
151
|
+
if (stateOnlyAdvance(wt, root, 'HEAD', base))
|
|
152
|
+
return ok('clean');
|
|
137
153
|
// --autostash: this runs in the GATE phase, and implement deliberately leaves the
|
|
138
154
|
// worktree uncommitted (ship owns the sole code commit, in the pr phase below), so the
|
|
139
155
|
// tree is normally dirty and a bare rebase refuses with "you have unstaged changes".
|
|
@@ -186,17 +202,26 @@ export async function runShip(ctx, planId) {
|
|
|
186
202
|
// One read-only fetch decides the whole routing. A failed fetch (no remote, offline)
|
|
187
203
|
// leaves baseMoved false and the phases behave as they did — the gate phase's own
|
|
188
204
|
// rebaseIfMoved is where that condition turns into a structured refusal.
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
205
|
+
const moved = baseMoved(wt, root, shipBranch);
|
|
206
|
+
const baseR = diffBase(wt, cfgR.value);
|
|
207
|
+
const reviewedSha = baseR.ok ? diffReviewedSha(wt, baseR.value) : undefined;
|
|
208
|
+
let phase = shipPhase(plan, entries, reviewedSha, moved);
|
|
209
|
+
if (moved)
|
|
193
210
|
ctx.out(kv('ship', `${shipBranch} moved — rebasing and re-reviewing before the watch`));
|
|
194
211
|
if (phase === 'gate') {
|
|
212
|
+
// D94: the gate cannot judge unchanged content twice — it answers changed-nothing
|
|
213
|
+
// and appends nothing, so routing the human back at it burns a turn on a command
|
|
214
|
+
// that has already declined. The owed work is the edit the revise asked for.
|
|
215
|
+
if (authoringOwed(entries, 'ship', reviewedSha)) {
|
|
216
|
+
ctx.out(kv('ship', `${planId} — revise owed`));
|
|
217
|
+
ctx.out(`help: edit the code in ${wt} · then re-run witness ship ${planId}`);
|
|
218
|
+
return EXIT.FINDINGS;
|
|
219
|
+
}
|
|
195
220
|
// Rebase BEFORE the battery, never after: the reviewers must judge the tree that will
|
|
196
221
|
// actually merge. Reviewing first spends a battery — and a human decision — on a tree
|
|
197
222
|
// without any sibling's merged work, which then lapses (gateSettled's reviewed_sha)
|
|
198
223
|
// the moment the rebase lands.
|
|
199
|
-
const rebase = rebaseIfMoved(wt, shipBranch);
|
|
224
|
+
const rebase = rebaseIfMoved(wt, root, shipBranch);
|
|
200
225
|
if (!rebase.ok) {
|
|
201
226
|
renderRefusal(rebase.violations).forEach((l) => ctx.err(l));
|
|
202
227
|
// A textual conflict is a hand-back, not a refusal: the human resolves it in the
|
package/dist/verbs/check.js
CHANGED
|
@@ -11,6 +11,7 @@ import { auditStateCommits, dirtyStatePaths, primaryRoot, tryGit } from '../giti
|
|
|
11
11
|
import { contentAtSha } from '../history.js';
|
|
12
12
|
import { effortStreams, readStream } from '../journal.js';
|
|
13
13
|
import { sourceTags } from '../matcher.js';
|
|
14
|
+
import { modelFloorLines } from '../model.js';
|
|
14
15
|
import { evaluateNeeds } from '../needs.js';
|
|
15
16
|
import { renderRefusal } from '../refusal.js';
|
|
16
17
|
import { pendingDecision } from '../rounds.js';
|
|
@@ -246,6 +247,14 @@ export async function run(ctx, argv = []) {
|
|
|
246
247
|
const errors = findings.filter((x) => x.level === 'error');
|
|
247
248
|
if (findings.length)
|
|
248
249
|
rows('findings', ['level', 'area', 'field', 'rule', 'detail'], findings).forEach(ctx.out);
|
|
250
|
+
// The calibration state reads the same here as on `status` (D98a) — one renderer, so
|
|
251
|
+
// the fact the gate run no longer repeats still reaches both orientation surfaces.
|
|
252
|
+
if (cfg.ok) {
|
|
253
|
+
const hxR = resolveHarness(ctx.env, cfg.value.raw);
|
|
254
|
+
for (const line of modelFloorLines(root, cfg.value, hxR.ok ? hxR.value.harness.name : 'claude-code')) {
|
|
255
|
+
ctx.out(kv('model-floor', line));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
249
258
|
ctx.out(kv('checks', `${canon.docs.length} docs · ${auditStateCommits(root).length} commits audited · ${errors.length} errors`));
|
|
250
259
|
return errors.length ? EXIT.FINDINGS : EXIT.OK;
|
|
251
260
|
}
|
package/dist/verbs/dashboard.js
CHANGED
|
@@ -3,11 +3,10 @@ import { EXIT, version } from '../cli.js';
|
|
|
3
3
|
import { loadConfig } from '../config.js';
|
|
4
4
|
import { designArtifactCurrent, designPending } from '../design.js';
|
|
5
5
|
import { reconcileRows } from '../drift.js';
|
|
6
|
-
import { DEFAULT_BATTERIES } from '../gate.js';
|
|
7
6
|
import { primaryRoot } from '../gitio.js';
|
|
8
7
|
import { resolveHarness } from '../harness.js';
|
|
9
8
|
import { effortAbandoned, effortStreams, latestRecap, readStream } from '../journal.js';
|
|
10
|
-
import {
|
|
9
|
+
import { modelFloorLines } from '../model.js';
|
|
11
10
|
import { computeNext, flowAction } from './next.js';
|
|
12
11
|
import { renderRefusal } from '../refusal.js';
|
|
13
12
|
import { pendingDecision } from '../rounds.js';
|
|
@@ -60,18 +59,11 @@ export async function run(ctx, _argv) {
|
|
|
60
59
|
// Diagnostic surface: a broken harness config must not brick the dashboard —
|
|
61
60
|
// `check` reports that as a finding, so the floor lines fall back to claude-code.
|
|
62
61
|
const hxR = resolveHarness(ctx.env, cfg.value.raw);
|
|
63
|
-
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
const modelR = resolveModel(cfg.value, matrix, gate);
|
|
69
|
-
if (modelR.ok && modelR.value.warning) {
|
|
70
|
-
byWarning.set(modelR.value.warning, [...(byWarning.get(modelR.value.warning) ?? []), gate]);
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
for (const [warning, gates] of byWarning) {
|
|
74
|
-
ctx.out(kv('model-floor', `${gates.join(' · ')}: ${warning}`));
|
|
62
|
+
// One line per distinct warning, labelled with the gates it applies to — per-gate
|
|
63
|
+
// model pins can put each gate in a different calibration state. Shared with
|
|
64
|
+
// `check` (D98a): the calibration fact must read the same on both surfaces.
|
|
65
|
+
for (const line of modelFloorLines(root, cfg.value, hxR.ok ? hxR.value.harness.name : 'claude-code')) {
|
|
66
|
+
ctx.out(kv('model-floor', line));
|
|
75
67
|
}
|
|
76
68
|
}
|
|
77
69
|
const txn = pendingTxn(root);
|
package/dist/verbs/decide.js
CHANGED
|
@@ -9,6 +9,7 @@ import { acquireLock } from '../lock.js';
|
|
|
9
9
|
import { crashPoint, guardTxn, withTxn } from '../txn.js';
|
|
10
10
|
import { appendEntry, entryLine, journalRel, policyPins, readStream, streamExists } from '../journal.js';
|
|
11
11
|
import { primaryRoot, stateCommit } from '../gitio.js';
|
|
12
|
+
import { effortOf } from '../reviewed.js';
|
|
12
13
|
import { findById, loadCanon } from '../scan.js';
|
|
13
14
|
import { newRunId } from '../drift.js';
|
|
14
15
|
import { renderRefusal, v } from '../refusal.js';
|
|
@@ -41,6 +42,16 @@ export async function run(ctx, argv) {
|
|
|
41
42
|
const entries = readStream(root, target);
|
|
42
43
|
const last = lastGateRun(entries, gate);
|
|
43
44
|
const pending = pendingDecision(entries, gate);
|
|
45
|
+
// Hoisted above `--show` (D94): both the anchor rule below and `--show`'s staleness
|
|
46
|
+
// line need `spec.currentSha`, which takes canon and config. A decision verb that
|
|
47
|
+
// cannot read the config cannot honestly report state either, so refusing here — and
|
|
48
|
+
// for `--show` too — is the correct order rather than a regression.
|
|
49
|
+
const cfgR = loadConfig(root);
|
|
50
|
+
if (!cfgR.ok) {
|
|
51
|
+
renderRefusal(cfgR.violations).forEach((l) => ctx.err(l));
|
|
52
|
+
return EXIT.REFUSED;
|
|
53
|
+
}
|
|
54
|
+
const canon = loadCanon(root);
|
|
44
55
|
if (argv.includes('--show')) {
|
|
45
56
|
if (!last) {
|
|
46
57
|
ctx.out(kv('decide', `no gate-runs for ${gate} ${target}`));
|
|
@@ -53,7 +64,14 @@ export async function run(ctx, argv) {
|
|
|
53
64
|
.map((e) => e);
|
|
54
65
|
// A caused_by decision is a REOPEN, not a disposition — it can never settle the run
|
|
55
66
|
// above it, and pairing the two is what presented 15 settled findings as current.
|
|
56
|
-
|
|
67
|
+
// The LAST disposition is the state (D94): `find` returned the first, so a
|
|
68
|
+
// revise→approve reported `decision: revise` on a gate that is settled.
|
|
69
|
+
const disposition = decisionsAfter.filter((d) => d.caused_by === undefined).at(-1);
|
|
70
|
+
// Staleness is a fact about content, not about being reopened. Hardcoding it printed
|
|
71
|
+
// `witness gate …` in the one state where the gate answers changed-nothing. An
|
|
72
|
+
// uncomputable sha is NOT staleness — same doctrine as the approve-time check.
|
|
73
|
+
const shownSha = spec.currentSha?.(root, canon, cfgR.value, target);
|
|
74
|
+
const stale = shownSha !== undefined && shownSha !== last.reviewed_sha;
|
|
57
75
|
if (reopen) {
|
|
58
76
|
ctx.out(kv('gate', gate));
|
|
59
77
|
ctx.out(kv('target', target));
|
|
@@ -62,7 +80,7 @@ export async function run(ctx, argv) {
|
|
|
62
80
|
if (reopen.note)
|
|
63
81
|
ctx.out(kv('note', reopen.note));
|
|
64
82
|
ctx.out(kv('last-run', `round ${last.round} @${short(last.reviewed_sha)} — ${last.outcome}${disposition ? `, ${disposition.decision}` : ''} · witness log ${target}`));
|
|
65
|
-
ctx.out(kv('exits', liveExits(gate, target, entries,
|
|
83
|
+
ctx.out(kv('exits', liveExits(gate, target, entries, stale)));
|
|
66
84
|
return EXIT.OK;
|
|
67
85
|
}
|
|
68
86
|
if (disposition && disposition.decision !== 'revise' && disposition.decision !== 'revise-upstream') {
|
|
@@ -72,6 +90,9 @@ export async function run(ctx, argv) {
|
|
|
72
90
|
if (disposition.note)
|
|
73
91
|
ctx.out(kv('note', disposition.note));
|
|
74
92
|
ctx.out(kv('last-run', `round ${last.round} @${short(last.reviewed_sha)} — ${last.outcome} · witness log ${target}`));
|
|
93
|
+
// no exits: the gate is genuinely terminal here. Point at the one verb that owns
|
|
94
|
+
// what comes next rather than re-deriving routing in a second place (D101).
|
|
95
|
+
ctx.out('help: witness next');
|
|
75
96
|
return EXIT.OK;
|
|
76
97
|
}
|
|
77
98
|
// pending (no disposition) or revise (the author's input): the verdict is actionable
|
|
@@ -84,7 +105,7 @@ export async function run(ctx, argv) {
|
|
|
84
105
|
if (disposition.note)
|
|
85
106
|
ctx.out(kv('note', disposition.note));
|
|
86
107
|
}
|
|
87
|
-
ctx.out(kv('exits', liveExits(gate, target, entries,
|
|
108
|
+
ctx.out(kv('exits', liveExits(gate, target, entries, stale)));
|
|
88
109
|
return EXIT.OK;
|
|
89
110
|
}
|
|
90
111
|
const picked = ['--approve', '--revise', '--stop'].filter((f) => argv.includes(f));
|
|
@@ -113,7 +134,27 @@ export async function run(ctx, argv) {
|
|
|
113
134
|
// run, or the target livelocks (incident c2692b93)
|
|
114
135
|
const boundEndgame = atBound && (decision === 'stop' || (decision === 'approve' && override) ||
|
|
115
136
|
(decision === 'revise' && upstream !== undefined));
|
|
116
|
-
|
|
137
|
+
// D94: a revise is the author's INPUT, not a disposition — it leaves the run
|
|
138
|
+
// undisposed, and with the content unchanged `gate` answers `changed-nothing` and
|
|
139
|
+
// appends nothing, so a pending decision can never reappear. Every exit the human is
|
|
140
|
+
// shown then refuses, and the only escape found in the field was an edit they did not
|
|
141
|
+
// need. The verdict still describes current content, so approving it is a true
|
|
142
|
+
// statement about bytes a battery read.
|
|
143
|
+
//
|
|
144
|
+
// `undefined` means the sha CANNOT be computed (no worktree, missing parent) and must
|
|
145
|
+
// never read as "moved" — the same doctrine the approve-time staleness check states
|
|
146
|
+
// below. That check still runs and still refuses a genuinely stale approve.
|
|
147
|
+
const afterLast = last
|
|
148
|
+
? entries.slice(entries.lastIndexOf(last) + 1)
|
|
149
|
+
.filter((e) => e.t === 'human-decision' && e.gate === gate)
|
|
150
|
+
.map((e) => e)
|
|
151
|
+
: [];
|
|
152
|
+
const onlyRevises = afterLast.length > 0 &&
|
|
153
|
+
afterLast.every((d) => d.decision === 'revise' || d.decision === 'revise-upstream');
|
|
154
|
+
const nowSha = last !== undefined ? spec.currentSha?.(root, canon, cfgR.value, target) : undefined;
|
|
155
|
+
const unchanged = last !== undefined && (nowSha === undefined || nowSha === last.reviewed_sha);
|
|
156
|
+
const revisedAnchor = onlyRevises && unchanged && (decision === 'approve' || decision === 'stop');
|
|
157
|
+
const anchor = pending ?? ((boundEndgame || revisedAnchor) ? last : undefined);
|
|
117
158
|
if (!anchor) {
|
|
118
159
|
// at the bound "run the gate" is a lie — it would only short-circuit back
|
|
119
160
|
// here; name the decisions that actually work instead
|
|
@@ -135,11 +176,6 @@ export async function run(ctx, argv) {
|
|
|
135
176
|
renderRefusal([v('decision', 'override-required', 'approve at the round bound', 'witness decide … --approve --override')]).forEach((l) => ctx.err(l));
|
|
136
177
|
return EXIT.REFUSED;
|
|
137
178
|
}
|
|
138
|
-
const cfgR = loadConfig(root);
|
|
139
|
-
if (!cfgR.ok) {
|
|
140
|
-
renderRefusal(cfgR.violations).forEach((l) => ctx.err(l));
|
|
141
|
-
return EXIT.REFUSED;
|
|
142
|
-
}
|
|
143
179
|
// A standing stop is only human judgment if the human was shown the thing. The gate
|
|
144
180
|
// refuses the same way, but the gate does not run at the round bound (gate.ts:176) —
|
|
145
181
|
// and approve is the act that stamps, so the line has to hold here too. Sha-keyed:
|
|
@@ -152,7 +188,6 @@ export async function run(ctx, argv) {
|
|
|
152
188
|
return EXIT.REFUSED;
|
|
153
189
|
}
|
|
154
190
|
}
|
|
155
|
-
const canon = loadCanon(root);
|
|
156
191
|
// A `human-decision` entry is a RECORD — honest about the sha it judged whatever the
|
|
157
192
|
// tree does afterward, which is why D76 declined a write-time staleness check. A stamp
|
|
158
193
|
// is an ASSERTION about current content (`status: approved`, `design: {sha}`), and D75
|
|
@@ -232,13 +267,33 @@ export async function run(ctx, argv) {
|
|
|
232
267
|
renderRefusal([v('upstream', 'unknown-artifact', upstream, 'a canon doc id')]).forEach((l) => ctx.err(l));
|
|
233
268
|
return EXIT.REFUSED;
|
|
234
269
|
}
|
|
235
|
-
else {
|
|
236
|
-
|
|
237
|
-
entry.upstream = { artifact: upstream, gate: upGate };
|
|
270
|
+
else if (upDoc.meta.type === 'plan') {
|
|
271
|
+
entry.upstream = { artifact: upstream, gate: 'plan' };
|
|
238
272
|
reopen = {
|
|
239
273
|
stream: upstream,
|
|
240
274
|
entry: {
|
|
241
|
-
v: 1, t: 'human-decision', gate:
|
|
275
|
+
v: 1, t: 'human-decision', gate: 'plan', artifact: upstream, round: anchor.round,
|
|
276
|
+
decision: 'revise', caused_by: { artifact: target, gate, round: anchor.round },
|
|
277
|
+
...(note ? { note } : {}),
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
// Row 95: decompose gates are keyed on EFFORTS — `computeNext` asks
|
|
283
|
+
// `openReopen(effortEntries, 'decompose')` and nothing anywhere reads a spec's stream
|
|
284
|
+
// for one — so a decompose reopen written on the spec was unreachable by construction.
|
|
285
|
+
// Resolve the owner, exactly as the design gate's branch above already does.
|
|
286
|
+
const owner = effortOf(root, upstream);
|
|
287
|
+
if (owner === undefined) {
|
|
288
|
+
renderRefusal([v('upstream', 'unknown-owner', upstream, 'a spec some effort wrote — the decompose reopen is booked on the effort stream')])
|
|
289
|
+
.forEach((l) => ctx.err(l));
|
|
290
|
+
return EXIT.REFUSED;
|
|
291
|
+
}
|
|
292
|
+
entry.upstream = { artifact: owner, gate: 'decompose' };
|
|
293
|
+
reopen = {
|
|
294
|
+
stream: owner,
|
|
295
|
+
entry: {
|
|
296
|
+
v: 1, t: 'human-decision', gate: 'decompose', artifact: owner, round: anchor.round,
|
|
242
297
|
decision: 'revise', caused_by: { artifact: target, gate, round: anchor.round },
|
|
243
298
|
...(note ? { note } : {}),
|
|
244
299
|
},
|
|
@@ -300,6 +355,9 @@ export async function run(ctx, argv) {
|
|
|
300
355
|
ctx.out(rows('pins', ['ordinal', 'text'], pins).join('\n'));
|
|
301
356
|
if (note)
|
|
302
357
|
ctx.out(kv('note', note));
|
|
358
|
+
if (upstream !== undefined && entry.upstream && upstream !== entry.upstream.artifact) {
|
|
359
|
+
ctx.out(kv('upstream', `${upstream} is owned by effort ${entry.upstream.artifact} — the decompose reopen is booked there`));
|
|
360
|
+
}
|
|
303
361
|
if (gate === 'decompose')
|
|
304
362
|
ctx.out(`help: scope itself implicated → witness recap --amend ${target}`);
|
|
305
363
|
else if (entry.upstream)
|
package/dist/verbs/next.js
CHANGED
|
@@ -7,8 +7,8 @@ import { primaryRoot } from '../gitio.js';
|
|
|
7
7
|
import { findById, loadCanon } from '../scan.js';
|
|
8
8
|
import { designArtifactCurrent, designPending, designUnseen } from '../design.js';
|
|
9
9
|
import { effortAbandoned, effortStreams, latestRecap, readStream } from '../journal.js';
|
|
10
|
-
import { effortOf, effortReviewedSha, effortSpecs, effortWrites,
|
|
11
|
-
import { changedFiles, diffBase, evidenceForDiff } from '../evidence.js';
|
|
10
|
+
import { effortOf, effortReviewedSha, effortSpecs, effortWrites, implementReviewedSha, planPairSha } from '../reviewed.js';
|
|
11
|
+
import { changedFiles, diffBase, evidenceForDiff, isTestPath } from '../evidence.js';
|
|
12
12
|
import { SESSION_DEFAULT, stagePin } from '../model.js';
|
|
13
13
|
import { handoffLine, relayLine, resolveHarness } from '../harness.js';
|
|
14
14
|
import { worktreeFlow, worktreePath } from '../worktree.js';
|
|
@@ -47,7 +47,7 @@ export function gateSettled(entries, gate, currentSha) {
|
|
|
47
47
|
// immediately decline, and — since nothing is appended — `next` says it again next turn.
|
|
48
48
|
// The work owed is AUTHORING, so route to the stage. The design stage already does this
|
|
49
49
|
// via designArtifactCurrent; this is the same rule for decompose and plan.
|
|
50
|
-
function authoringOwed(entries, gate, currentSha) {
|
|
50
|
+
export function authoringOwed(entries, gate, currentSha) {
|
|
51
51
|
const last = lastGateRun(entries, gate);
|
|
52
52
|
if (!last)
|
|
53
53
|
return false;
|
|
@@ -55,15 +55,19 @@ function authoringOwed(entries, gate, currentSha) {
|
|
|
55
55
|
return false;
|
|
56
56
|
if (openReopen(entries, gate) !== undefined)
|
|
57
57
|
return true;
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
// The LAST decision is the state (D94). `.some()` read by presence, so an approve
|
|
59
|
+
// answering a revise left this still claiming authoring — the same read-by-position
|
|
60
|
+
// defect as decide --show's disposition.
|
|
61
|
+
const recent = entries.slice(entries.lastIndexOf(last) + 1)
|
|
62
|
+
.filter((e) => e.t === 'human-decision' && e.gate === gate)
|
|
63
|
+
.map((e) => e)
|
|
64
|
+
.at(-1);
|
|
65
|
+
return recent !== undefined && ['revise', 'revise-upstream'].includes(recent.decision);
|
|
62
66
|
}
|
|
63
|
-
// D75 re-arms a gate whose reviewed sha has moved
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
+
// D75 re-arms a gate whose reviewed sha has moved — under row 96 that is the diff moving
|
|
68
|
+
// or the base moving, no longer any file in the worktree. Correct either way, but the row
|
|
69
|
+
// it produces is a bare `gate implement`, indistinguishable from a CLI stuck on a stale
|
|
70
|
+
// answer. A human who just watched that gate pass reads it as the latter, and
|
|
67
71
|
// with a second session in the worktree answering `ship` the pair looks like a deadlock
|
|
68
72
|
// with no error anywhere. The lapse is a fact the CLI already knows; say it.
|
|
69
73
|
function lapseNote(entries, gate, currentSha) {
|
|
@@ -75,6 +79,42 @@ function lapseNote(entries, gate, currentSha) {
|
|
|
75
79
|
return undefined;
|
|
76
80
|
return `${gate} approval lapsed — judged @${last.reviewed_sha.slice(0, 7)}, worktree now @${currentSha.slice(0, 7)} — re-gate to judge the current tree`;
|
|
77
81
|
}
|
|
82
|
+
// The owed evidence phase is a DERIVATION (row 85: a placeholder is honest only where
|
|
83
|
+
// the CLI genuinely cannot know the answer). `evidenceForDiff` already computes
|
|
84
|
+
// red/green/vacuous per tag on the way to the gate's own check — this reads that report
|
|
85
|
+
// instead of printing the menu and making the caller resolve it.
|
|
86
|
+
//
|
|
87
|
+
// `verify-red` is preferred where a plain red probe is guaranteed vacuous: with the
|
|
88
|
+
// implementation already written the suite passes as it stands, so the reconstructing
|
|
89
|
+
// verb is the only one that can witness a genuine red.
|
|
90
|
+
function evidenceRow(id, parentTag, files, report, seat) {
|
|
91
|
+
const base = { stage: 'implement', target: id, ...seat };
|
|
92
|
+
if (report === undefined) {
|
|
93
|
+
return {
|
|
94
|
+
line: `witness test-evidence ${id} --phase red`, ...base,
|
|
95
|
+
note: 'nothing changed yet — write the failing test first',
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
const owed = report.required
|
|
99
|
+
.filter((r) => !(r.red && r.green && !r.vacuous))
|
|
100
|
+
.map((r) => `${r.tag} ${!r.red || r.vacuous ? 'red' : 'green'}`)
|
|
101
|
+
.join(' · ');
|
|
102
|
+
const parent = report.required.find((r) => r.tag === parentTag);
|
|
103
|
+
const liveRed = parent !== undefined && parent.red && !parent.vacuous;
|
|
104
|
+
const line = !liveRed && files.some((f) => !isTestPath(f))
|
|
105
|
+
? `witness verify-red ${id}`
|
|
106
|
+
: `witness test-evidence ${id} --phase ${liveRed ? 'green' : 'red'}`;
|
|
107
|
+
return { line, ...base, ...noteOf(owed === '' ? undefined : `evidence owed: ${owed}`) };
|
|
108
|
+
}
|
|
109
|
+
// One list for both readers. `flowAction` needs it to name an effort that can carry a plan
|
|
110
|
+
// write, and `computeNext` has always built exactly this — a second derivation here is the
|
|
111
|
+
// mistake rows 93 and 95 are both about.
|
|
112
|
+
function liveEfforts(root) {
|
|
113
|
+
return effortStreams(root)
|
|
114
|
+
.map((slug) => ({ slug, entries: readStream(root, slug) }))
|
|
115
|
+
.filter((e) => latestRecap(root, e.slug) !== undefined && !effortAbandoned(e.entries))
|
|
116
|
+
.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
117
|
+
}
|
|
78
118
|
// The action for ONE flow. A flow is a plan with status `in-progress`: it begins at
|
|
79
119
|
// `witness start` and ends at the merge stamp. Anything else returns undefined and is
|
|
80
120
|
// not a flow. Exported because `--flow` and the dashboard must answer with the SAME
|
|
@@ -94,22 +134,53 @@ export function flowAction(root, cfg, plan) {
|
|
|
94
134
|
return { line: `witness start ${id}`, target: id, note: 'worktree missing — start recreates it' };
|
|
95
135
|
if (plan.meta.pr !== undefined)
|
|
96
136
|
return { line: `witness ship ${id}`, stage: 'ship', target: id, ...atRoot };
|
|
137
|
+
// Row 95: a reopen on this plan's OWN plan gate is routable motion — the plan is what
|
|
138
|
+
// needs re-authoring — and every reader was blind to it. Above the implement settle check
|
|
139
|
+
// on purpose: an implement gate settled against an earlier plan version must not carry
|
|
140
|
+
// the flow past a reopen saying the plan itself is wrong. Below the `pr` row on purpose
|
|
141
|
+
// too: a flow already at ship is answered by ship. No `home:` — plan authoring writes
|
|
142
|
+
// canon at the root, and this matches what computeNext's plans-first loop prints.
|
|
143
|
+
if (openReopen(entries, 'plan') !== undefined) {
|
|
144
|
+
const act = planWriteAction(root, liveEfforts(root), id, String(plan.meta.parent), id);
|
|
145
|
+
return { ...act, ...noteOf(act.note, 'plan gate reopened — re-author, then re-gate the plan') };
|
|
146
|
+
}
|
|
97
147
|
const baseR = diffBase(wt, cfg);
|
|
98
148
|
const files = baseR.ok ? changedFiles(wt, baseR.value) : [];
|
|
149
|
+
// Rows 95 + 96: the identity is the diff plus the plan's content, never the worktree, and
|
|
150
|
+
// it comes from the SAME derivation the implement gate keys on — deriving it here a second
|
|
151
|
+
// way is what made every settled gate read as lapsed. undefined means the base cannot be
|
|
152
|
+
// resolved — "cannot compute", never "moved", so a settled gate stays settled.
|
|
153
|
+
const diffSha = baseR.ok ? implementReviewedSha(wt, baseR.value, plan) : undefined;
|
|
154
|
+
// D93: the gate owns its deterministic checks; the router reads the verdict and never
|
|
155
|
+
// re-derives the predicate. Testing `evidence` in front of this is what made a human
|
|
156
|
+
// `--approve` invisible to the one verb the driving loop calls every turn — the
|
|
157
|
+
// journal said settled, `next` said test-evidence, and no error printed anywhere.
|
|
158
|
+
// Sha-sensitivity is load-bearing here: emptying the worktree after an approval moves
|
|
159
|
+
// the sha and re-arms the gate, which is why this keeps `diffSha` while
|
|
160
|
+
// `gates/ship.ts` deliberately omits it (row 92).
|
|
161
|
+
if (gateSettled(entries, 'implement', diffSha)) {
|
|
162
|
+
return { line: `witness ship ${id}`, stage: 'ship', target: id, ...atRoot };
|
|
163
|
+
}
|
|
99
164
|
// evidenceForDiff is vacuously "satisfied" when nothing has changed yet (an empty
|
|
100
165
|
// required-tags list trivially passes .every()) — a fresh worktree needs the
|
|
101
166
|
// implement-stage hint too, not a premature jump to "gate implement".
|
|
102
|
-
const
|
|
103
|
-
if (!satisfied)
|
|
104
|
-
return
|
|
105
|
-
|
|
106
|
-
|
|
167
|
+
const report = baseR.ok && files.length > 0 ? evidenceForDiff(wt, root, plan, baseR.value) : undefined;
|
|
168
|
+
if (report === undefined || !report.satisfied) {
|
|
169
|
+
return evidenceRow(id, String(plan.meta.parent), files, report, inWorktree);
|
|
170
|
+
}
|
|
171
|
+
// D94: with the content unchanged the gate answers `changed-nothing` and appends
|
|
172
|
+
// nothing, so routing there returns this same line every turn. The revise asked for an
|
|
173
|
+
// edit — say that, and seat the session where the edit happens.
|
|
174
|
+
if (authoringOwed(entries, 'implement', diffSha)) {
|
|
107
175
|
return {
|
|
108
|
-
line: `witness
|
|
109
|
-
|
|
176
|
+
line: `witness test-evidence ${id} --phase green`, stage: 'implement', target: id, ...inWorktree,
|
|
177
|
+
note: 'revise owed — edit the code in the worktree · re-run the evidence cycle · then re-gate',
|
|
110
178
|
};
|
|
111
179
|
}
|
|
112
|
-
return {
|
|
180
|
+
return {
|
|
181
|
+
line: `witness gate implement ${id}`, target: id, ...inWorktree,
|
|
182
|
+
...noteOf(lapseNote(entries, 'implement', diffSha)),
|
|
183
|
+
};
|
|
113
184
|
}
|
|
114
185
|
// How far along a flow is — the drain order when several are actionable. Most-advanced
|
|
115
186
|
// first is WIP-limiting, not cosmetic: it pushes flows toward merge instead of fanning
|
|
@@ -128,9 +199,16 @@ function flowRank(a) {
|
|
|
128
199
|
// created). Such a flow is NOT tier-1 motion — offering it would loop the driving loop
|
|
129
200
|
// on an action that only re-reports "awaiting decision", and would starve tier 2 of the
|
|
130
201
|
// very decision that unblocks it.
|
|
131
|
-
function flowBlocked(entries) {
|
|
132
|
-
|
|
133
|
-
(boundReached(entries, gate) && !gateSettled(entries, gate)))
|
|
202
|
+
function flowBlocked(root, plan, entries) {
|
|
203
|
+
if (['plan', 'implement', 'ship'].some((gate) => pendingDecision(entries, gate) !== undefined ||
|
|
204
|
+
(boundReached(entries, gate) && !gateSettled(entries, gate))))
|
|
205
|
+
return true;
|
|
206
|
+
// Row 95's split: a reopen on the plan's own plan gate is NOT a block — flowAction routes
|
|
207
|
+
// it — but a reopen on the PARENT's decompose is, because that work belongs to the effort
|
|
208
|
+
// and tier 3 is where it surfaces. Keyed on the effort that owns the PARENT SPEC, which is
|
|
209
|
+
// where `decide --revise --upstream <spec>` books it.
|
|
210
|
+
const effort = effortOf(root, String(plan.meta.parent));
|
|
211
|
+
return effort !== undefined && openReopen(readStream(root, effort), 'decompose') !== undefined;
|
|
134
212
|
}
|
|
135
213
|
// Every human-owed decision in the repo, in the order tier 2 would surface them. One
|
|
136
214
|
// source for both tiers: tier 2 takes the first, tier 1 rides them all in `note:`.
|
|
@@ -198,10 +276,7 @@ export function computeNext(root, ctx, canon, cfg) {
|
|
|
198
276
|
if (canon.errors.length > 0 || canon.docs.some((d) => d.violations.length > 0)) {
|
|
199
277
|
return { line: 'witness check' };
|
|
200
278
|
}
|
|
201
|
-
const efforts =
|
|
202
|
-
.map((slug) => ({ slug, entries: readStream(root, slug) }))
|
|
203
|
-
.filter((e) => latestRecap(root, e.slug) !== undefined && !effortAbandoned(e.entries))
|
|
204
|
-
.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
279
|
+
const efforts = liveEfforts(root);
|
|
205
280
|
// Hoisted above tier 1: both doc lists are read by every tier below, and they used to
|
|
206
281
|
// be declared interleaved between the pending-decision scans.
|
|
207
282
|
const plans = canon.docs.filter((d) => d.meta.type === 'plan')
|
|
@@ -212,7 +287,7 @@ export function computeNext(root, ctx, canon, cfg) {
|
|
|
212
287
|
// on a human decision belonging to a DIFFERENT flow: ship always stops (gates/ship.ts),
|
|
213
288
|
// so a global decision-halt freezes every other flow at the first ship gate.
|
|
214
289
|
const flows = plans
|
|
215
|
-
.filter((plan) => !flowBlocked(readStream(root, String(plan.meta.id))))
|
|
290
|
+
.filter((plan) => !flowBlocked(root, plan, readStream(root, String(plan.meta.id))))
|
|
216
291
|
.map((plan) => flowAction(root, cfg, plan))
|
|
217
292
|
.filter((a) => a !== undefined)
|
|
218
293
|
.sort((a, b) => flowRank(b) - flowRank(a) || String(a.target).localeCompare(String(b.target)));
|
package/dist/verbs/write.js
CHANGED
|
@@ -225,7 +225,7 @@ export async function run(ctx, argv) {
|
|
|
225
225
|
ctx.out(kv('written', id));
|
|
226
226
|
ctx.out(kv('path', rel));
|
|
227
227
|
ctx.out(kv('sha', short(sha)));
|
|
228
|
-
ctx.out(kv('status',
|
|
228
|
+
ctx.out(kv('status', String(built.meta.status)));
|
|
229
229
|
ctx.out('next: witness check · witness index');
|
|
230
230
|
return EXIT.OK;
|
|
231
231
|
}
|
|
@@ -288,10 +288,29 @@ function buildPlanMeta(root, id, manifest, recap, canon, existing, body) {
|
|
|
288
288
|
designFrom = stamp.sha;
|
|
289
289
|
}
|
|
290
290
|
}
|
|
291
|
+
// Row 95: a plan's lifecycle status is not re-derived by re-authoring it. `flowAction`
|
|
292
|
+
// now routes an in-flight plan's plan-gate reopen HERE, and a hardcoded `draft` would
|
|
293
|
+
// demote a plan that holds a `pr` and a live worktree out of flow-hood — `flowAction`,
|
|
294
|
+
// `flowBlocked`, `dashboard` and `--flow` would all lie about it for a window. Stated
|
|
295
|
+
// rather than hidden: `in-progress` no longer implies a settled plan gate. Plan-only —
|
|
296
|
+
// a re-authored SPEC still returns to draft.
|
|
297
|
+
//
|
|
298
|
+
// The rule is PRESERVE AN IN-FLIGHT STATUS, and the two terminal statuses part ways
|
|
299
|
+
// because only one of them is history. `done` refuses: that plan merged, and re-authoring
|
|
300
|
+
// it would rewrite what shipped. `abandoned` returns to `draft`, because row 85's recovery
|
|
301
|
+
// IS re-authoring an abandoned plan under a live effort — `abandon <effort>` stamps the
|
|
302
|
+
// effort's plans abandoned, `next` then names a live effort to carry the write, and
|
|
303
|
+
// refusing here would strand the very loop row 85 unstranded. A revived plan is new work,
|
|
304
|
+
// so it re-enters the ladder at draft rather than keeping a status it no longer has.
|
|
305
|
+
const existingStatus = existing ? String(existing.meta.status) : 'draft';
|
|
306
|
+
if (existingStatus === 'done') {
|
|
307
|
+
violations.push(v('status', 'terminal-status', existingStatus, 'a plan that has not merged — a done plan is superseded by a new plan, never re-authored'));
|
|
308
|
+
}
|
|
309
|
+
const priorStatus = existingStatus === 'abandoned' ? 'draft' : existingStatus;
|
|
291
310
|
const meta = {
|
|
292
311
|
id,
|
|
293
312
|
type: 'plan',
|
|
294
|
-
status:
|
|
313
|
+
status: priorStatus,
|
|
295
314
|
parent: parentId,
|
|
296
315
|
'derives-from': computed,
|
|
297
316
|
...(designFrom !== undefined ? { 'design-from': designFrom } : {}),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@popovych.co/witness",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Evidence-gated spec-driven development pipeline for AI coding agents: brainstorm to PR in six stages, where specs are state, plans are motion, and no transition passes without evidence. Drives from Claude Code or Pi.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"spec-driven-development",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "witness",
|
|
3
3
|
"description": "Spec-driven development with evidence gates: brainstorm → PR through six stages and five gates. Skills author, the pinned witness CLI writes state and witnesses evidence; gates stop where judgment is owed.",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.6.0",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Kostiantyn Popovych"
|
|
7
7
|
},
|
|
@@ -10,7 +10,7 @@ The CLI decides, you act. If `$ARGUMENTS` contains `--manual`, append `--manual`
|
|
|
10
10
|
Resolve the CLI once:
|
|
11
11
|
|
|
12
12
|
```bash
|
|
13
|
-
WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.
|
|
13
|
+
WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.6.0}"
|
|
14
14
|
```
|
|
15
15
|
|
|
16
16
|
If the repo has no `witness.config.yaml`, run `$WITNESS init` first (one bootstrap commit), then proceed. If init or any verb refuses on a dirty tree, the human commits or cleans it — **never `git commit` yourself, anywhere, at any stage**: the CLI makes its own state commits, and the ship phase makes the sole code commit.
|
|
@@ -43,4 +43,4 @@ Every turn is `witness next`, read, act on the first matching row, repeat:
|
|
|
43
43
|
- An exit 2/3 you cannot mechanically satisfy — `needs-unmet` (show the human `witness satisfy <id> --need <text>`), `slug-reuse`, lock contention, calibration floor stops under `--manual`.
|
|
44
44
|
- A skill ended its turn for a gate stop — that stop is yours too; do not restart the loop until the human decides.
|
|
45
45
|
|
|
46
|
-
**Never merge a PR** — merging is the human's act on GitHub; the lazy stamp finishes the lifecycle on the next scan. **Never edit `specs/` or `plans/`** (or wherever the repo's `paths:` config relocates them) — that is what the skills' write path is for.
|
|
46
|
+
**Never merge a PR** — merging is the human's act on GitHub; the lazy stamp finishes the lifecycle on the next scan. **Never edit `specs/` or `plans/`** (or wherever the repo's `paths:` config relocates them) — that is what the skills' write path is for. Run `$WITNESS status` — the orientation screen (flows, blocked docs, reconcile rows, pending gates) — when you need position rather than the next action; bare `$WITNESS` prints the same screen. If it reports the reviewer model is uncalibrated, surface that; under `--manual` treat it as a stop.
|
|
@@ -3,4 +3,4 @@
|
|
|
3
3
|
# context). Silent everywhere that isn't a witness repo; failures swallowed:
|
|
4
4
|
# session start must never hang or die on this.
|
|
5
5
|
[ -f witness.config.yaml ] || exit 0
|
|
6
|
-
${WITNESS_BIN:-npx -y @popovych.co/witness@0.
|
|
6
|
+
${WITNESS_BIN:-npx -y @popovych.co/witness@0.6.0} 2>/dev/null || true
|
|
@@ -15,7 +15,7 @@ witness: ends in a confirmed scope recap, writes no document. See NOTICE.md. -->
|
|
|
15
15
|
Resolve the CLI once per session:
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
|
-
WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.
|
|
18
|
+
WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.6.0}"
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
- **Never edit `specs/**` or `plans/**`** (the canon dirs — `paths:` in witness.config.yaml may relocate them) — not with an edit tool, not with a write tool, not with Bash redirection. The CLI is the sole writer of state; you author in scratch files under `$(mktemp -d)` and hand them to the CLI. (The canon guard blocks you; the trailer audit catches what it can't.)
|
|
@@ -10,7 +10,7 @@ description: Slice a witness effort's confirmed recap into vertical spec slices,
|
|
|
10
10
|
Resolve the CLI once per session:
|
|
11
11
|
|
|
12
12
|
```bash
|
|
13
|
-
WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.
|
|
13
|
+
WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.6.0}"
|
|
14
14
|
```
|
|
15
15
|
|
|
16
16
|
- **Never edit `specs/**` or `plans/**`** (the canon dirs — `paths:` in witness.config.yaml may relocate them) — not with an edit tool, not with a write tool, not with Bash redirection. The CLI is the sole writer of state; you author in scratch files under `$(mktemp -d)` and hand them to the CLI. (The canon guard blocks you; the trailer audit catches what it can't.)
|
|
@@ -16,7 +16,7 @@ No text copied verbatim; the design-critic lens is new material. -->
|
|
|
16
16
|
Resolve the CLI once per session:
|
|
17
17
|
|
|
18
18
|
```bash
|
|
19
|
-
WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.
|
|
19
|
+
WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.6.0}"
|
|
20
20
|
```
|
|
21
21
|
|
|
22
22
|
- **Never edit `specs/**`, `plans/**`, or `designs/**`** — the CLI is the sole writer of state. Author HTML in `$(mktemp -d)` and hand it to `witness design`. (The canon guard blocks direct edits; the trailer audit catches end-runs.)
|
|
@@ -15,7 +15,7 @@ tests carry the parent spec's tag in their NAME. See NOTICE.md. -->
|
|
|
15
15
|
Resolve the CLI once per session:
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
|
-
WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.
|
|
18
|
+
WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.6.0}"
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
- **Never edit `specs/**` or `plans/**`** (the canon dirs — `paths:` in witness.config.yaml may relocate them) — not with an edit tool, not with a write tool, not with Bash redirection. The CLI is the sole writer of state; you author in scratch files under `$(mktemp -d)` and hand them to the CLI. (The canon guard blocks you; the trailer audit catches what it can't.)
|
|
@@ -48,6 +48,8 @@ Protocol per step, in order — red/green/refactor with witnessed evidence:
|
|
|
48
48
|
6. Early exit: if you exceed **~15 inner-loop iterations** inside one step, or you have left the step's scope to fight infrastructure, the economy is to relay — finish the red→green you are in (never stop mid-red), report, and relay so a fresh context continues. Exceeding your slice is never the right economy.
|
|
49
49
|
7. Fat artifacts (the living designs/<spec>.html, long docs) are read **by section** — grep for the anchors/ids you need, then read those offsets — never whole into your context; you would re-pay their tokens on every subsequent request.
|
|
50
50
|
|
|
51
|
+
**Tests you did not come to change.** When a step makes you edit a test file tagged for a spec other than your parent — a shared fixture, a helper, a suite-wide rename — you owe that spec **green now**, never a red→green pair: `red` means you observed the behavior missing before you built it, and you are not building that spec. The gate's `regression` check runs those specs' tagged tests and fails if any is red. Fix them in the worktree; never journal evidence under a tag this plan does not own, and never weaken the foreign test to pass.
|
|
52
|
+
|
|
51
53
|
UI work TDDs at the browser, end-to-end: when a step changes what the browser renders or how the user interacts with it (markup, styles, routes, client-side behavior), the failing test in step 1 is a **Puppeteer** test driving the real UI **through the whole slice** — headless, tagged in its NAME like any test, living in the repo's regular suite so the criteria runner reaches it (Puppeteer is a library, not a runner — a test the runner can't reach has unwitnessable evidence). Everything the repo owns runs real: the test owns the slice's lifecycle — build, boot the backend and its store, serve the frontend; never assume a running dev server (evidence runs in a clean worktree) — but inside your own inner loop, keep the app server alive across iterations where the harness allows; the per-run floor is boot + compile, not test time. Fake only what the repo does not own (third-party services). Stubbing the slice's own backend — request interception, mocked fetch, canned API fixtures — turns the test UI-only, and the implement gate's pr-test lens flags it like any substitution. Unit/component tests may accompany a browser test, never replace it.
|
|
52
54
|
|
|
53
55
|
**Capture the screens you build.** When `$WITNESS_SCREENS_DIR` is set (the CLI exports it during `test-evidence`), the browser test writes a named PNG per screen-level moment the step produces — `$WITNESS_SCREENS_DIR/<name>.png` via the driver's screenshot call. Name captures for the moments a reviewer must see, not every frame: `initial` (screen as it opens), `filled` (primary inputs populated), `error` (a validation/error state the slice can reach), `success` (the post-action confirmation). One capture per meaningful state; skip states the step doesn't touch. Guard the writes on the env var so the same test screenshots nothing during the gate's own drift lane. These captures are the design-reviewer's evidence — `test-evidence` witnesses a sha per PNG, and at the gate a **vision `design-reviewer`** (feature and fix batteries, only when the plan pins a `design-from` design) judges them against the repo's `docs.design` canon and the approved `designs/<spec>.html` look. A UI plan that witnesses zero captures is refused `screens-matched-nothing` before any reviewer runs — a screen with no screenshot has unwitnessable design evidence, exactly as a browser test outside the runner has unwitnessable behavior evidence.
|
|
@@ -15,7 +15,7 @@ parent's criteria. See NOTICE.md. -->
|
|
|
15
15
|
Resolve the CLI once per session:
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
|
-
WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.
|
|
18
|
+
WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.6.0}"
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
- **Never edit `specs/**` or `plans/**`** (the canon dirs — `paths:` in witness.config.yaml may relocate them) — not with an edit tool, not with a write tool, not with Bash redirection. The CLI is the sole writer of state; you author in scratch files under `$(mktemp -d)` and hand them to the CLI. (The canon guard blocks you; the trailer audit catches what it can't.)
|
|
@@ -15,7 +15,7 @@ model can. See NOTICE.md. -->
|
|
|
15
15
|
Resolve the CLI once per session:
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
|
-
WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.
|
|
18
|
+
WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.6.0}"
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
- **Never edit `specs/**` or `plans/**`** (the canon dirs — `paths:` in witness.config.yaml may relocate them) — not with an edit tool, not with a write tool, not with Bash redirection. The CLI is the sole writer of state; you author in scratch files under `$(mktemp -d)` and hand them to the CLI. (The canon guard blocks you; the trailer audit catches what it can't.)
|