@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 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
- | *(no verb)* | dashboard |
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
- const suite = await runFullSuite(runRoot, ctx, rc, trustRoot);
67
- if (!suite.ok)
68
- return suite;
69
- const tests = suite.value.tests
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
- const required = diffTags(runRoot, base).map((tag) => {
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
- export const DEFAULT_BATTERIES = {
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
- if (stale)
47
- return `witness gate ${gate} ${target}`;
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 `witness decide ${gate} ${target} --approve --override | --revise --upstream <id> | --stop`;
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
- if (warning)
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);
@@ -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, worktreeTreeSha } from '../reviewed.js';
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
- ? `${report.required.length} tags with red→green pairs`
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: worktreeTreeSha(wt),
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, _canon, _cfg, planId) {
158
+ currentSha(root, canon, cfg, planId) {
141
159
  const wt = worktreePath(root, planId);
142
- return existsSync(wt) ? worktreeTreeSha(wt) : undefined;
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
  });
@@ -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 { effortOf, worktreeTreeSha } from '../reviewed.js';
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
- const implementSettled = (() => {
50
- if (!lastImplement)
51
- return false;
52
- if (lastImplement.outcome === 'passed')
53
- return true;
54
- const after = entries.slice(entries.lastIndexOf(lastImplement) + 1);
55
- return after.some((e) => e.t === 'human-decision' &&
56
- e.gate === 'implement' &&
57
- e.decision === 'approve');
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: worktreeTreeSha(wt),
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, _cfg, planId) {
100
+ currentSha(root, _canon, cfg, planId) {
95
101
  const wt = worktreePath(root, planId);
96
- return existsSync(wt) ? worktreeTreeSha(wt) : undefined;
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 warning = calibrationOf(head) !== 'none'
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 { mkdtempSync, rmSync } from 'node:fs';
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
- export function worktreeTreeSha(root) {
10
- const tmp = mkdtempSync(join(tmpdir(), 'witness-idx-'));
11
- const env = { ...process.env, GIT_INDEX_FILE: join(tmp, 'index') };
12
- try {
13
- execFileSync('git', ['add', '-A'], { cwd: root, env });
14
- return execFileSync('git', ['write-tree'], { cwd: root, env, encoding: 'utf8' }).trim();
15
- }
16
- finally {
17
- rmSync(tmp, { recursive: true, force: true });
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
  }