@popovych.co/witness 0.21.0 → 0.21.1

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/dist/config.js CHANGED
@@ -17,8 +17,8 @@ export const DEFAULT_PATHS = {
17
17
  export const DOC_KEYS = ['conventions', 'design'];
18
18
  export const DEFAULT_STEPS_PER_DISPATCH = listDefault('implement.stepsPerDispatch');
19
19
  // Row 191 §4.2. The default 20 is the row's premise (§1): once a session's context is more
20
- // than about 20% full, its output gets worse. A repo decision, so its one home is
21
- // witness.config.yaml, never config.local.yaml (row 90).
20
+ // than about 20% full, its output gets worse. A repo decision, so its home is
21
+ // witness.config.yaml; row 191 deferred a machine-local override (config.local.yaml, row 90).
22
22
  export const DEFAULT_CONTEXT_LIMIT = listDefault('session.contextLimit');
23
23
  // D145. One hour. An implement slice is ~25 minutes at the default budget of 3 steps,
24
24
  // and the cost of the two errors is not symmetric: a generous ceiling wastes an hour
package/dist/context.js CHANGED
@@ -27,6 +27,8 @@ const isInt = (x) => typeof x === 'number' && Number.isInteger(x);
27
27
  // harmless: a drive launched from a chat session's shell carries the chat's reading, and its
28
28
  // child runs as a session of its own. The order is the reasons' precedence: a broken value is
29
29
  // malformed whoever wrote it, and only this session's own value can say it has no reading yet.
30
+ // A value with no window is not a broken one (D192): Pi's adapter writes the session alone when
31
+ // the harness gives no usage at all.
30
32
  export function readFill(env) {
31
33
  const raw = env[CONTEXT_ENV];
32
34
  if (raw === undefined || raw === '')
@@ -43,16 +45,17 @@ export function readFill(env) {
43
45
  const { session, tokens, window } = parsed;
44
46
  if (typeof session !== 'string' || session === '')
45
47
  return unmeasured('malformed');
46
- if (!isInt(window) || window < 1)
48
+ if (window !== undefined && (!isInt(window) || window < 1))
47
49
  return unmeasured('malformed');
48
- if (tokens !== undefined && tokens !== null && (!isInt(tokens) || tokens < 0 || tokens > window)) {
50
+ if (tokens !== undefined && tokens !== null && (!isInt(tokens) || tokens < 0 || (isInt(window) && tokens > window))) {
49
51
  return unmeasured('malformed');
50
52
  }
51
53
  if (session !== sessionIdOf(env))
52
54
  return unmeasured('stale');
53
55
  // P7: Claude Code sends no `tokens` before the session's first response, Pi sends 0 then,
54
- // and Pi sends null right after a compaction. Each is a reading not taken yet.
55
- if (!isInt(tokens) || tokens === 0)
56
+ // and Pi sends null right after a compaction. Each is a reading not taken yet, and so is Pi's
57
+ // session alone, with no window.
58
+ if (!isInt(window) || !isInt(tokens) || tokens === 0)
56
59
  return unmeasured('no reading yet');
57
60
  return { measured: true, reading: { session, tokens, window } };
58
61
  }
@@ -1,21 +1,16 @@
1
- import { existsSync, readdirSync } from 'node:fs';
2
- import { basename, join } from 'node:path';
1
+ import { basename } from 'node:path';
3
2
  import { handoffLine, resolveDriver } from './harness.js';
4
- import { readStream } from './journal.js';
3
+ import { readStream, streamIds } from './journal.js';
5
4
  import { unmetNeeds } from './needs.js';
6
5
  import { findById, loadCanon } from './scan.js';
7
6
  import { pendingTxn } from './txn.js';
8
7
  import { owedDecisions, parkedGates } from './verbs/next.js';
9
8
  import { recoverChoice } from './verbs/recover.js';
10
- const journalDir = (root) => join(root, '.witness', 'journal');
11
- const streams = (root) => existsSync(journalDir(root))
12
- ? readdirSync(journalDir(root)).filter((f) => f.endsWith('.jsonl')).map((f) => f.slice(0, -'.jsonl'.length)).sort()
13
- : [];
14
9
  export function runStart(root) {
15
- return { lines: new Map(streams(root).map((s) => [s, readStream(root, s).length])) };
10
+ return { lines: new Map(streamIds(root).map((s) => [s, readStream(root, s).length])) };
16
11
  }
17
12
  export function entriesSince(root, start) {
18
- return streams(root).flatMap((stream) => readStream(root, stream).slice(start.lines.get(stream) ?? 0).map((entry) => ({ stream, entry })));
13
+ return streamIds(root).flatMap((stream) => readStream(root, stream).slice(start.lines.get(stream) ?? 0).map((entry) => ({ stream, entry })));
19
14
  }
20
15
  const prOf = (line) => /PR #(\d+)/.exec(line)?.[1];
21
16
  // Every `run:` and `then:` line is raw: a command is never kv-escaped (D120).
package/dist/journal.js CHANGED
@@ -73,15 +73,18 @@ export function latestRecap(root, effort) {
73
73
  const recaps = readStream(root, effort).filter((e) => e.t === 'recap');
74
74
  return recaps.at(-1);
75
75
  }
76
- export function effortStreams(root) {
76
+ // Every stream the journal holds, by id, sorted.
77
+ export function streamIds(root) {
77
78
  if (!existsSync(dir(root)))
78
79
  return [];
79
80
  return readdirSync(dir(root))
80
81
  .filter((f) => f.endsWith('.jsonl'))
81
82
  .map((f) => f.slice(0, -'.jsonl'.length))
82
- .filter((id) => readStream(root, id)[0]?.t === 'recap')
83
83
  .sort();
84
84
  }
85
+ export function effortStreams(root) {
86
+ return streamIds(root).filter((id) => readStream(root, id)[0]?.t === 'recap');
87
+ }
85
88
  export function effortAbandoned(entries) {
86
89
  return entries.some((e) => e.t === 'human-decision' && e.decision === 'abandon-effort');
87
90
  }
package/dist/ship.js CHANGED
@@ -18,7 +18,7 @@ import { branchName, worktreePath } from './worktree.js';
18
18
  import { authoringOwed, gateSettled } from './verbs/next.js';
19
19
  import { diffReviewedSha } from './reviewed.js';
20
20
  import { diffBase } from './evidence.js';
21
- import { reportHandover } from './slice.js';
21
+ import { handOverIfDue } from './slice.js';
22
22
  // Static, unlike gate.ts and next.ts: this module lies outside gate.ts's static import graph,
23
23
  // so reaching decide.ts from here closes no registration cycle.
24
24
  import { settleStop, settledCode } from './settle.js';
@@ -364,10 +364,7 @@ export async function runShip(ctx, planId, opts = {}) {
364
364
  const code = await shipPass(ctx, planId, opts);
365
365
  if (code !== EXIT.OK && code !== EXIT.FINDINGS)
366
366
  return code;
367
- const rootR = primaryRoot(ctx.cwd);
368
- const cfgR = rootR.ok ? loadConfig(rootR.value) : undefined;
369
- if (rootR.ok && cfgR?.ok)
370
- reportHandover(ctx, rootR.value, cfgR.value, { guard: true });
367
+ handOverIfDue(ctx, { guard: true });
371
368
  return code;
372
369
  }
373
370
  async function shipPass(ctx, planId, opts) {
package/dist/slice.js CHANGED
@@ -1,9 +1,8 @@
1
- import { existsSync, readdirSync } from 'node:fs';
2
- import { join } from 'node:path';
3
1
  import { loadConfig } from './config.js';
4
2
  import { pctOf, reached, readFill, sessionHash, sessionIdOf, windowLabel } from './context.js';
3
+ import { primaryRoot } from './gitio.js';
5
4
  import { relayLine, resolveDriver } from './harness.js';
6
- import { readStream } from './journal.js';
5
+ import { readStream, streamIds } from './journal.js';
7
6
  import { implementGated, redInFlight, stepProgress, stepsLine, vacuousEvidence } from './steps.js';
8
7
  import { kv } from './toon.js';
9
8
  // Plan's call 8 (row 191 §4.8). An act is the session's own stage work. What the CLI writes on a
@@ -21,11 +20,7 @@ const stampOf = (e) => typeof e.ctx === 'object' && e.ctx !== null ? e.ctx : {};
21
20
  // review I3): it observed the behavior already there, so a session opened over the limit that only
22
21
  // re-ran one would hand over, and so would every fresh session after it.
23
22
  export function sessionActed(root, hash) {
24
- const dir = join(root, '.witness', 'journal');
25
- if (!existsSync(dir))
26
- return false;
27
- return readdirSync(dir).filter((f) => f.endsWith('.jsonl')).some((f) => readStream(root, f.slice(0, -'.jsonl'.length))
28
- .some((e) => ACT_KINDS.has(e.t) && !vacuousEvidence(e) && stampOf(e).session === hash));
23
+ return streamIds(root).some((id) => readStream(root, id).some((e) => ACT_KINDS.has(e.t) && !vacuousEvidence(e) && stampOf(e).session === hash));
29
24
  }
30
25
  // The entries that closed a step, in stream order. A legacy plan's closes are not the CLI's to
31
26
  // see, so it has none.
@@ -61,10 +56,10 @@ export function closedThisSession(entries, p, hash) {
61
56
  // session's closes against the step budget where it can see both; where it cannot, the skill
62
57
  // keeps its own count, as before row 191.
63
58
  //
64
- // `redOpen` (final review I1): a red in flight (steps.ts `redInFlight`: the latest red that
65
- // observed a failure, with no passing green since, whatever step it names or none) is work the
66
- // session is in the middle of, and a session never relays mid-red, so the slice goes on until its
67
- // green. A closing green clears it, so this answers a close made while a red stands: a `step-close`.
59
+ // `redOpen` (final review I1, D192): a red in flight (steps.ts `redInFlight`: the latest non-vacuous
60
+ // run saw a failure, whatever step it names or none) is work the session is in the middle
61
+ // of, and a session never relays mid-red, so the slice goes on until its green. A closing green
62
+ // clears it, so this answers a close made while a red stands: a `step-close`.
68
63
  // It outranks rule 1 too: a red re-opened once every step has closed would send the session to a
69
64
  // gate whose evidence check fails on it, spending a round on a tree mid-red.
70
65
  export function stepCloseVerdict(i) {
@@ -89,13 +84,17 @@ export function stepCloseVerdict(i) {
89
84
  }
90
85
  return { kind: 'continue' };
91
86
  }
92
- // Row 191 §4.6 and §4.7. The fill is floored, as `reached` decides (Plan's call 2). The next step
93
- // rounds up, so a step that grew the context at all never reads +0%.
87
+ // Row 191 §4.6 and §4.7. The fill is floored, as `reached` decides (Plan's call 2). The next step is
88
+ // the difference of the two floored shares, so it adds up to the projection the verdict prints
89
+ // beside it (D192: rounded up on its own, 30,001 tokens read +4% beside 17% + next step ≈ 20%), and
90
+ // a step that grew the context at all never reads +0%.
94
91
  export function contextLine(fill, limit, budget, growth) {
95
92
  if (!fill.measured)
96
93
  return `unmeasured (${fill.reason}) — step budget ${budget}`;
97
94
  const { tokens, window } = fill.reading;
98
- const next = growth === undefined ? '' : ` · next step ≈ +${Math.ceil(growth * 100 / window)}%`;
95
+ const next = growth === undefined
96
+ ? ''
97
+ : ` · next step ≈ +${Math.max(pctOf(tokens + growth, window) - pctOf(tokens, window), 1)}%`;
99
98
  return `${pctOf(tokens, window)}% of ${windowLabel(window)} · limit ${limit}%${next}`;
100
99
  }
101
100
  export function sliceLine(v) {
@@ -111,16 +110,20 @@ export function sliceLine(v) {
111
110
  // green that closes no step (a re-record, a fix round) only reports the fill. A legacy plan's
112
111
  // closes are not the CLI's to see (§4.3), so until its implement gate every passing green
113
112
  // decides. The close is journaled before this runs, so a config that will not load costs the
114
- // report and never the verb's exit: dispatch-report's relay line keeps the same doctrine.
113
+ // report and never the verb's exit: dispatch-report's relay line keeps the same doctrine. It says
114
+ // so on stderr (D192), so the missing report is never silent. `entries` is the stream as the
115
+ // caller read it to name the step closed, so the report decides from that same read (D192).
115
116
  export function reportStepClose(ctx, root, plan, opts) {
116
117
  if (opts.closed === undefined && !opts.passingGreen)
117
118
  return;
118
119
  const cfg = loadConfig(root);
119
- if (!cfg.ok)
120
+ if (!cfg.ok) {
121
+ ctx.err(`warn: no context line — witness.config.yaml did not load (${cfg.violations[0].rule})`);
120
122
  return;
123
+ }
121
124
  const limit = cfg.value.session.contextLimit;
122
125
  const budget = cfg.value.implement.stepsPerDispatch;
123
- const entries = readStream(root, String(plan.meta.id));
126
+ const entries = opts.entries ?? readStream(root, String(plan.meta.id));
124
127
  const progress = stepProgress(plan, entries);
125
128
  const fill = readFill(ctx.env);
126
129
  // §4.5's `g` is the size of the next step, so once every step is closed there is none to project.
@@ -169,6 +172,16 @@ export function reportHandover(ctx, root, cfg, opts) {
169
172
  ctx.out(kv('relay', relayLine(driver.value.harness)));
170
173
  return true;
171
174
  }
175
+ // Row 191 §4.4. `reportHandover` at the exit of a verb that holds no root or config there (a
176
+ // `decide --revise`, a ship run): both are read again from the cwd. One that will not resolve
177
+ // hands nothing over, and the verb's own exit stands.
178
+ export function handOverIfDue(ctx, opts) {
179
+ const rootR = primaryRoot(ctx.cwd);
180
+ if (!rootR.ok)
181
+ return false;
182
+ const cfgR = loadConfig(rootR.value);
183
+ return cfgR.ok && reportHandover(ctx, rootR.value, cfgR.value, opts);
184
+ }
172
185
  // Row 191 §4.5 "Before any work". A session already at the limit when it opens cannot hand over
173
186
  // (it has done nothing yet), and with no first-step exemption at a step close each of its slices
174
187
  // will be one step, so the CLI says so where the session begins. Deterministic, so a fresh
package/dist/steps.js CHANGED
@@ -2,10 +2,11 @@ import { v } from './refusal.js';
2
2
  const manifest = (plan) => (Array.isArray(plan.meta.steps) ? plan.meta.steps : [])
3
3
  .filter((s) => typeof s === 'object' && s !== null)
4
4
  .map((s) => ({ id: String(s.id), kind: s.scaffolding === true ? 'scaffolding' : 'criteria' }));
5
- // A cycle can be a plan step's only where the plan has a criteria step for it to name. A plan
6
- // whose spec's criteria an earlier plan realized has none (an empty criteria delta), so none of
7
- // its evidence is a step's: it owes no `--step`, and its untagged evidence hides no step's close.
8
- const hasCriteriaStep = (plan) => manifest(plan).some((s) => s.kind === 'criteria');
5
+ // A cycle can be a plan step's only where the plan has a criteria step for it to name. Every chore
6
+ // plan has none, since its parent, the principles, carries no criteria; nor does a plan whose
7
+ // spec's criteria an earlier plan realized (an empty criteria delta). None of such a plan's evidence
8
+ // is a step's: it owes no `--step`, and its untagged evidence hides no step's close.
9
+ export const hasCriteriaStep = (plan) => manifest(plan).some((s) => s.kind === 'criteria');
9
10
  const outcomes = (e) => (Array.isArray(e.tests) ? e.tests : []);
10
11
  // A red that passed observed nothing missing: the behavior it asserts was already there. It opens
11
12
  // no cycle (Plan's call 3), it is no act of the session's, and drive counts it as no progress
@@ -68,40 +69,62 @@ export function stepProgress(plan, entries) {
68
69
  export function closedAt(p, index) {
69
70
  return p.known ? p.steps.find((s) => s.closedAt === index)?.id : undefined;
70
71
  }
71
- // Row 191 §1–§2. The red in flight: the plan's latest red that observed a failure, while no passing
72
- // green has followed it. Every evidence run executes the parent tag's whole suite, so a passing
73
- // green means no red is open any more, whatever step it names or none, and a failing green or a
74
- // vacuous red changes nothing. Read per step, the red the skill tags on a legacy plan, whose routed
75
- // green names no step, stayed open for good and switched the plan's slice cut off (re-review). One
76
- // red is open at a time, so its step, when it names one, is the open step. Which steps are closed is
72
+ // Whether an evidence run counts toward the red in flight. Every run executes the parent tag's
73
+ // whole suite, so one that counts is the suite's state at that run, whatever step it names or none.
74
+ // A vacuous red does not count, though a plain one ran this tree's whole suite and saw it pass (only
75
+ // verify-red's ran against the base): a vacuous red is no cycle (Plan's call 3), so the genuine red
76
+ // before it stays open, and the green routed for it closes the step. An entry with no outcome saw
77
+ // nothing at all.
78
+ const countsInFlight = (e) => e.t === 'test-evidence' && e.vacuous !== true && outcomes(e).length > 0;
79
+ // Row 191 §1–§2, D192. The red in flight, the one reading the step row, the evidence row, `next`'s
80
+ // hand-over and the verdict at a step close all share: the plan's latest run that counts (a
81
+ // non-vacuous one) saw a failure, a red with a failing test or a green that did not pass. A passing
82
+ // green ends it whatever step it names, or none: read per step, the red the skill tags on a legacy
83
+ // plan, whose routed green names no step, stayed open for good (D191's re-review). A green that
84
+ // fails after a passing one re-opens it: read from the latest red alone, `next` routed that green
85
+ // while the verdict let the slice end (D192). Whether a red is open, whatever step it names or none
86
+ // (a fix round's, a legacy cycle's): what keeps `next` from handing over, and a step close from
87
+ // ending the slice, mid-red.
88
+ export function redInFlight(entries) {
89
+ const last = entries.filter(countsInFlight).at(-1);
90
+ return last !== undefined && outcomes(last).some((t) => !t.ok);
91
+ }
92
+ // The latest genuine red and where it stands in the stream.
93
+ function latestRed(entries) {
94
+ let at = -1;
95
+ entries.forEach((e, i) => { if (opensCycle(e))
96
+ at = i; });
97
+ const step = entries[at]?.step;
98
+ return at === -1 ? undefined : { at, ...(typeof step === 'string' ? { step } : {}) };
99
+ }
100
+ // The step of the red in flight: the latest genuine red's, when it names one. One red is open at a
101
+ // time, so its step is the open step, the one the green owed names. Which steps are closed is
77
102
  // another question (closingIndex): a criteria step closes only at a green tagged with it after its
78
103
  // own red.
79
- function redInFlightEntry(entries) {
80
- let open;
81
- for (const e of entries) {
82
- if (opensCycle(e))
83
- open = e;
84
- else if (closesCycle(e))
85
- open = undefined;
86
- }
87
- return open;
104
+ function stepInFlight(entries) {
105
+ return redInFlight(entries) ? latestRed(entries)?.step : undefined;
88
106
  }
89
- // Whether a red is open, whatever step it names, or none (a fix round's, a legacy cycle's): what
90
- // keeps `next` from handing over, and a step close from ending the slice, mid-red.
91
- export function redInFlight(entries) {
92
- return redInFlightEntry(entries) !== undefined;
107
+ // Whether the green owed names this step: it is the red in flight's. That is not whether the step
108
+ // holds an open red of its own (D192): once its cycle closed, a later green that failed still owes
109
+ // the green here (redCycleClosed).
110
+ export function greenOwedOn(entries, stepId) {
111
+ return stepInFlight(entries) === stepId;
93
112
  }
94
- // A cycle is open on the step while the red in flight is its own.
95
- export function openRed(entries, stepId) {
96
- return redInFlightEntry(entries)?.step === stepId;
113
+ // D192. Whether the latest genuine red's cycle has closed: a green tagged with its step passed after
114
+ // it. While a red is in flight that means a later run failed (a green naming another step, say):
115
+ // the green owed still names that red's step, but the step holds no open red of its own.
116
+ export function redCycleClosed(entries) {
117
+ const red = latestRed(entries);
118
+ return red?.step !== undefined && closingIndex({ id: red.step, kind: 'criteria' }, entries.slice(red.at)) !== undefined;
97
119
  }
98
- // Row 191 §1–§2 (final review I1). The step of the red in flight, when it names a criteria step of
99
- // the plan: recorded out of order ahead of an unclosed step, or re-opened on a closed one, its green
100
- // is owed before anything else. Read whether or not the plan's progress is known. A criteria step
101
- // alone, because `test-evidence` refuses any other a green: a red on a step an amend removed or
102
- // turned into scaffolding routes nothing.
103
- export function redOpenStep(plan, entries) {
104
- const step = redInFlightEntry(entries)?.step;
120
+ // Row 191 §1–§2 (final review I1), D192. Which step the green owed names: the red in flight's, when
121
+ // it is a criteria step of the plan. That green comes before anything else, wherever the red stands:
122
+ // recorded out of order ahead of an unclosed step, re-opened on a closed one, or on a step whose own
123
+ // cycle has since closed and a later green failed (D192). Read whether or not the plan's progress is
124
+ // known. A criteria step alone, because `test-evidence` refuses a green naming any other: after a red
125
+ // on a step an amend removed or turned into scaffolding, the green owed names none.
126
+ export function greenOwedStep(plan, entries) {
127
+ const step = stepInFlight(entries);
105
128
  return manifest(plan).find((s) => s.kind === 'criteria' && s.id === step)?.id;
106
129
  }
107
130
  // The step's latest red passed: the behavior it asserts already exists, as when the
@@ -23,7 +23,7 @@ import { short } from '../sha.js';
23
23
  import { cmd, kv, rows } from '../toon.js';
24
24
  import { ROUND_BOUND, anchorRecurrence, boundReached, lastGateRun, openReopen, pendingDecision, repairGranted, roundsSinceApprove, } from '../rounds.js';
25
25
  import { prepareStamp, writeStamp } from '../stamp.js';
26
- import { reportHandover } from '../slice.js';
26
+ import { handOverIfDue } from '../slice.js';
27
27
  // D154. The untrusted `cmd:` list for a block, resolved from the ctx alone — `renderBound`
28
28
  // and `--show` both render without a `root` in scope, and gate.ts's renderChoices took the
29
29
  // same route for the same reason. Best-effort: a root that will not resolve costs the trust
@@ -128,10 +128,7 @@ export async function run(ctx, argv) {
128
128
  // `next` settle through that door too, and each hands over at its own point.
129
129
  if (code !== EXIT.OK || act.value.decision !== 'revise')
130
130
  return code;
131
- const rootR = primaryRoot(ctx.cwd);
132
- const cfgR = rootR.ok ? loadConfig(rootR.value) : undefined;
133
- if (rootR.ok && cfgR?.ok)
134
- reportHandover(ctx, rootR.value, cfgR.value, { guard: false });
131
+ handOverIfDue(ctx, { guard: false });
135
132
  return code;
136
133
  }
137
134
  function show(ctx, spec, gate, target) {
@@ -15,25 +15,29 @@ const exitOf = (violations) => (violations.some((x) => x.rule === 'locked') ? EX
15
15
  const EVIDENCE_USAGE = 'witness test-evidence <plan-id> --phase red|green [--step <id>]';
16
16
  const VERIFY_USAGE = 'witness verify-red <plan-id> [--base <ref>] [--step <id>]';
17
17
  // The plan id is the first token that is neither a flag nor a flag's value, so `--step s1`
18
- // written before the id never reads as the plan.
18
+ // written before the id never reads as the plan. `step-close` reads its id here too (D192).
19
19
  const VALUED = new Set(['--phase', '--step', '--base']);
20
- const planIdOf = (argv) => argv.find((a, i) => !a.startsWith('--') && !VALUED.has(argv[i - 1] ?? ''));
21
- // Row 191 §4.3. `--step` named with no value is refused rather than read as no step: an
22
- // untagged cycle is another claim (a fix round), not a typo's default. A flag where the value
23
- // belongs is no value.
24
- function stepArg(argv, usage) {
20
+ export const planIdOf = (argv) => argv.find((a, i) => !a.startsWith('--') && !VALUED.has(argv[i - 1] ?? ''));
21
+ // Row 191 §4.3. The step `--step` names, if it names one: a flag where the value belongs is no
22
+ // value. Each verb says what no step means, so `step-close` reads it here too (D192).
23
+ export function stepOf(argv) {
25
24
  const at = argv.indexOf('--step');
26
- if (at === -1)
27
- return ok(undefined);
28
- const value = argv[at + 1];
29
- return value === undefined || value.startsWith('--') ? refuse([v('--step', 'required', 'absent', usage)]) : ok(value);
25
+ const value = at === -1 ? undefined : argv[at + 1];
26
+ return value === undefined || value.startsWith('--') ? undefined : value;
27
+ }
28
+ // `--step` named with no value is refused rather than read as no step: an untagged cycle is
29
+ // another claim (a fix round), not a typo's default.
30
+ function stepArg(argv, usage) {
31
+ const step = stepOf(argv);
32
+ return argv.includes('--step') && step === undefined ? refuse([v('--step', 'required', 'absent', usage)]) : ok(step);
30
33
  }
31
34
  // Row 191 §4.4. The green just journaled is the stream's last line, and whether it closed a step
32
35
  // is the plan's progress to say: a step's first passing green after its red does, a re-record or
33
- // a fix round does not.
36
+ // a fix round does not. The report reads the same entries (D192): one read names the step closed
37
+ // and decides the slice.
34
38
  function reportGreen(ctx, stateRoot, plan, passingGreen) {
35
39
  const entries = readStream(stateRoot, String(plan.meta.id));
36
- reportStepClose(ctx, stateRoot, plan, { closed: closedAt(stepProgress(plan, entries), entries.length - 1), passingGreen });
40
+ reportStepClose(ctx, stateRoot, plan, { closed: closedAt(stepProgress(plan, entries), entries.length - 1), passingGreen, entries });
37
41
  }
38
42
  export async function run(ctx, argv) {
39
43
  const planId = planIdOf(argv);
package/dist/verbs/log.js CHANGED
@@ -1,11 +1,9 @@
1
- import { existsSync, readdirSync } from 'node:fs';
2
- import { join } from 'node:path';
3
1
  import { parseArgs } from 'node:util';
4
2
  import { EXIT } from '../cli.js';
5
3
  import { askText } from '../ask.js';
6
4
  import { git, primaryRoot } from '../gitio.js';
7
5
  import { canonExit } from '../departure.js';
8
- import { journalActs, readStream, streamExists } from '../journal.js';
6
+ import { journalActs, readStream, streamExists, streamIds } from '../journal.js';
9
7
  import { renderRefusal, v } from '../refusal.js';
10
8
  import { findById, loadCanon } from '../scan.js';
11
9
  import { short } from '../sha.js';
@@ -63,15 +61,6 @@ function detail(e) {
63
61
  }
64
62
  }
65
63
  }
66
- const streamIds = (root) => {
67
- const dir = join(root, '.witness', 'journal');
68
- if (!existsSync(dir))
69
- return [];
70
- return readdirSync(dir)
71
- .filter((f) => f.endsWith('.jsonl'))
72
- .map((f) => f.slice(0, -'.jsonl'.length))
73
- .sort();
74
- };
75
64
  // D166. Read through `canonExit`, the one reading of a departure: a re-slice that was taken
76
65
  // back is no longer a lineage step.
77
66
  function successorOf(root, id) {
@@ -13,7 +13,7 @@ import { diffReviewedSha, effortOf, effortReviewedSha, effortSpecs, effortWrites
13
13
  import { baseForSpec } from '../history.js';
14
14
  import { canonicalSha } from '../sha.js';
15
15
  import { changedFiles, diffBase, evidenceForDiff, isTestPath } from '../evidence.js';
16
- import { implementGated, lastRedVacuous, openRed, redInFlight, redOpenStep, stepProgress, stepsLine } from '../steps.js';
16
+ import { greenOwedOn, greenOwedStep, hasCriteriaStep, implementGated, lastRedVacuous, redCycleClosed, redInFlight, stepProgress, stepsLine, } from '../steps.js';
17
17
  import { SESSION_DEFAULT, stagePin } from '../model.js';
18
18
  import { handoffLine, relayLine, resolveDriver, resolveJudge } from '../harness.js';
19
19
  import { atHome, worktreeFlow, worktreePath } from '../worktree.js';
@@ -165,49 +165,59 @@ function judgeNote(entries, gate, judge) {
165
165
  //
166
166
  // `step` is set where `test-evidence` refuses a cycle that names none (row 191): before the
167
167
  // first implement gate, on a plan whose progress is known and that has a criteria step.
168
- function evidenceRow(id, parentTag, files, report, seat, step) {
168
+ //
169
+ // D192. With anything changed, the phase is the one reading of a red in flight (steps.ts
170
+ // `redInFlight`, `midRed` here): its green while one is, a red otherwise. An empty diff routes a red
171
+ // whatever is in flight. Read from the parent tag's latest red, as it was, a vacuous red after a
172
+ // genuine one routed a second red here while the step row routed the green. The gate's own
173
+ // semantics stay `evidenceForDiff`'s, so the owed note can still name a red the gate wants after
174
+ // that green.
175
+ //
176
+ // D192. Every step of a plan with no criteria step (every chore plan, and one whose spec's criteria
177
+ // an earlier plan realized) is scaffolding, so with nothing changed yet its work is the change
178
+ // itself, never a failing test first. The note claims no more (final review): such a plan need not
179
+ // be a chore, and once tagged tests land in its diff the gate can still want their red.
180
+ function evidenceRow(id, files, report, seat, opts) {
169
181
  const base = { stage: 'implement', target: id, ...seat };
170
- const stepFlag = step === undefined ? '' : ` --step ${step}`;
182
+ const stepFlag = opts.step === undefined ? '' : ` --step ${opts.step}`;
171
183
  if (report === undefined) {
172
184
  return {
173
185
  line: `witness test-evidence ${id} --phase red${stepFlag}`, ...base,
174
- note: 'nothing changed yet — write the failing test first',
186
+ note: opts.criteria
187
+ ? 'nothing changed yet — write the failing test first'
188
+ : 'nothing changed yet — make the change · every step of this plan is scaffolding',
175
189
  };
176
190
  }
177
191
  const owed = report.required
178
192
  .filter((r) => !(r.red && r.green && !r.vacuous))
179
193
  .map((r) => `${r.tag} ${!r.red || r.vacuous ? 'red' : 'green'}`)
180
194
  .join(' · ');
181
- const parent = report.required.find((r) => r.tag === parentTag);
182
- const liveRed = parent !== undefined && parent.red && !parent.vacuous;
183
- const line = !liveRed && files.some((f) => !isTestPath(f))
195
+ const line = !opts.midRed && files.some((f) => !isTestPath(f))
184
196
  ? `witness verify-red ${id}${stepFlag}`
185
- : `witness test-evidence ${id} --phase ${liveRed ? 'green' : 'red'}${stepFlag}`;
186
- // Unsatisfied with a live red, the parent tag's red has no green after it: this green is
187
- // routed mid-red.
188
- return {
189
- line, ...base, ...noteOf(owed === '' ? undefined : `evidence owed: ${owed}`),
190
- ...(liveRed ? { midRed: true } : {}),
191
- };
197
+ : `witness test-evidence ${id} --phase ${opts.midRed ? 'green' : 'red'}${stepFlag}`;
198
+ return { line, ...base, ...noteOf(owed === '' ? undefined : `evidence owed: ${owed}`) };
192
199
  }
193
200
  // The run that works a plan's next unfinished step. A step whose latest red came back vacuous
194
201
  // gets `verify-red`, the one run that can witness its red: a plain red would come back vacuous
195
- // again and `next` would answer with the same line twice. An open cycle outranks that, since
196
- // its red was genuine and the green is what is owed.
202
+ // again and `next` would answer with the same line twice. A green owed on the step (`greenOwedOn`)
203
+ // outranks that: a green, not another red, is what the step is owed.
197
204
  function stepLine(id, s, entries) {
198
205
  if (s.kind === 'scaffolding')
199
206
  return { line: `witness step-close ${id} --step ${s.id}` };
200
- if (openRed(entries, s.id))
207
+ if (greenOwedOn(entries, s.id))
201
208
  return { line: `witness test-evidence ${id} --phase green --step ${s.id}` };
202
209
  if (lastRedVacuous(entries, s.id)) {
203
210
  return { line: `witness verify-red ${id} --step ${s.id}`, why: 'its red was vacuous — verify-red reconstructs it against the base' };
204
211
  }
205
212
  return { line: `witness test-evidence ${id} --phase red --step ${s.id}` };
206
213
  }
207
- // The step an evidence row names once every step is closed: that of the latest red naming a
208
- // criteria step of the plan as it stands. Never a scaffolding step or one an amend removed,
209
- // both of which `test-evidence` refuses. One exists whenever the plan has a criteria step,
210
- // since none closes without a red tagged with it, and a plan with none is owed no step.
214
+ // The step an evidence row names once every step is closed, unless the green owed names one (D192:
215
+ // that step comes first): that of the latest red naming a criteria step of the plan as it stands.
216
+ // So it answers when no red is in flight, and also while one is that names no criteria step of the
217
+ // plan: a failing green with no genuine red before it, or a red on a step an amend removed or turned
218
+ // into scaffolding. Never a scaffolding step or one an amend removed, both of which `test-evidence`
219
+ // refuses. One exists whenever the plan has a criteria step, since none closes without a red tagged
220
+ // with it, and a plan with none is owed no step.
211
221
  function evidenceStep(steps, entries) {
212
222
  const criteria = new Set(steps.filter((s) => s.kind === 'criteria').map((s) => s.id));
213
223
  const red = entries
@@ -277,18 +287,27 @@ export function flowAction(root, cfg, plan, judge) {
277
287
  // step is the work. Legacy plans and fix rounds after a gate keep the rows below.
278
288
  const progress = stepProgress(plan, entries);
279
289
  const stepped = progress.known && !implementGated(entries) ? progress : undefined;
280
- // Final review I1 and its re-review. A red in flight (steps.ts: the latest red that observed a
281
- // failure, with no passing green since) is one whatever step it names, or none: recorded out of
282
- // order, ahead of a scaffolding step left unclosed, re-opened on a closed step, or a fix round's.
283
- // Whatever row follows, the session is mid-red.
284
- const inFlight = redInFlight(entries) ? { midRed: true } : {};
290
+ // Final review I1, its re-review and D192. A red in flight (steps.ts: the latest non-vacuous run
291
+ // saw a failure) is one whatever step it names, or none: recorded out of order, ahead of a
292
+ // scaffolding step left unclosed, re-opened on a closed step, or a fix round's. Whatever row
293
+ // follows, the session is mid-red, and the evidence row's phase reads the same answer unless the
294
+ // diff is empty.
295
+ const midRed = redInFlight(entries);
296
+ const inFlight = midRed ? { midRed: true } : {};
285
297
  if (stepped?.next !== undefined) {
286
- // Its green comes before the first unclosed step: routing that step's red or its close would
287
- // leave the open red's green unrouted.
288
- const redOpen = redOpenStep(plan, entries);
289
- const open = stepped.steps.find((s) => s.id === redOpen);
290
- const { line, why } = stepLine(id, open ?? stepped.next, entries);
291
- const ahead = open !== undefined && open.id !== stepped.next.id ? `${open.id} has an open red — its green comes first` : why;
298
+ // The green owed comes before the first unclosed step: routing that step's red or its close
299
+ // would leave the green owed unrouted. D192: once the latest genuine red's cycle has closed
300
+ // (`redCycleClosed`), what failed is a later green (a failing red would be the latest genuine red,
301
+ // its cycle still open), and the note says so rather than naming a red its step no longer holds.
302
+ // Not "the last run": a vacuous red after that green may have passed.
303
+ const owed = greenOwedStep(plan, entries);
304
+ const owedStep = stepped.steps.find((s) => s.id === owed);
305
+ const { line, why } = stepLine(id, owedStep ?? stepped.next, entries);
306
+ const ahead = owedStep === undefined || owedStep.id === stepped.next.id
307
+ ? why
308
+ : redCycleClosed(entries)
309
+ ? 'the last green failed — a passing green comes first'
310
+ : `${owedStep.id} has an open red — its green comes first`;
292
311
  return {
293
312
  line, stage: 'implement', target: id, ...inWorktree, ...noteOf(`steps ${stepsLine(stepped)}`, ahead), ...inFlight,
294
313
  };
@@ -299,7 +318,12 @@ export function flowAction(root, cfg, plan, judge) {
299
318
  const report = baseR.ok && files.length > 0 ? evidenceForDiff(wt, root, plan, baseR.value) : undefined;
300
319
  if (report === undefined || !report.satisfied) {
301
320
  return {
302
- ...evidenceRow(id, String(plan.meta.parent), files, report, inWorktree, stepped !== undefined ? evidenceStep(stepped.steps, entries) : undefined),
321
+ ...evidenceRow(id, files, report, inWorktree, {
322
+ midRed, criteria: hasCriteriaStep(plan),
323
+ // D192. While a red is in flight its green is owed, so the row names the red in flight's step,
324
+ // as the step row does. Read from the latest red of any kind, it named a vacuous red's step.
325
+ step: stepped !== undefined ? greenOwedStep(plan, entries) ?? evidenceStep(stepped.steps, entries) : undefined,
326
+ }),
303
327
  ...inFlight,
304
328
  };
305
329
  }
@@ -8,15 +8,14 @@ import { reportStepClose } from '../slice.js';
8
8
  import { checkStep, closingIndex } from '../steps.js';
9
9
  import { renderRefusal, v } from '../refusal.js';
10
10
  import { kv } from '../toon.js';
11
+ import { planIdOf, stepOf } from './evidence.js';
11
12
  // Row 191 §4.3. A scaffolding step has no red→green to witness, so its close is journaled on
12
13
  // its own and the entry is the whole act. Like `test-evidence` it checks no plan status, and it
13
14
  // runs from the primary root or the plan's worktree alike: the journal lives at the primary root.
14
15
  export async function run(ctx, argv) {
15
- // A flag where the value belongs is no value; `checkStep` refuses the close that names none.
16
- const at = argv.indexOf('--step');
17
- const value = at === -1 ? undefined : argv[at + 1];
18
- const step = value === undefined || value.startsWith('--') ? undefined : value;
19
- const planId = argv.find((a, i) => !a.startsWith('--') && argv[i - 1] !== '--step');
16
+ // `--step` with no value names no step, and `checkStep` refuses the close that names none.
17
+ const step = stepOf(argv);
18
+ const planId = planIdOf(argv);
20
19
  if (!planId) {
21
20
  renderRefusal([v('plan-id', 'required', 'absent', 'witness step-close <plan-id> --step <id>')]).forEach(ctx.err);
22
21
  return EXIT.REFUSED;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@popovych.co/witness",
3
- "version": "0.21.0",
3
+ "version": "0.21.1",
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.21.0",
4
+ "version": "0.21.1",
5
5
  "author": {
6
6
  "name": "Kostiantyn Popovych"
7
7
  },
@@ -12,7 +12,7 @@ Any other words in `$ARGUMENTS` are the human's ask: run `$WITNESS ask "<those w
12
12
  Resolve the CLI once:
13
13
 
14
14
  ```bash
15
- WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.21.0}"
15
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.21.1}"
16
16
  ```
17
17
 
18
18
  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.
@@ -39,6 +39,11 @@ const SESSION = /^(["']?)session\1[ \t]*:(?=\s|$)/;
39
39
  // A plain key needs whitespace after its colon; a quoted one may take its value straight after,
40
40
  // the JSON-like flow form (`{"contextLimit":15}`).
41
41
  const LIMIT = /^(?:(["'])contextLimit\1\s*:|contextLimit\s*:(?=\s|$))\s*([\s\S]*)$/;
42
+ // Only in a flow map, though (D192). In a block map a value straight after the colon is a YAML
43
+ // error for a quoted key. A plain one is no key there at all: with no `key: value` entry beside it,
44
+ // the whole of `session:` is one string, and beside one, before or after it, it is a YAML error.
45
+ // The CLI refuses the config either way.
46
+ const ADJACENT = /^(["']?)contextLimit\1\s*:(?=\S)/;
42
47
 
43
48
  // A comment opens at a `#` that starts the line or follows whitespace; inside a word it is text.
44
49
  const uncomment = (line) => line.replace(/(^|\s)#.*$/, '');
@@ -78,13 +83,16 @@ function flowEntries(text) {
78
83
  return undefined;
79
84
  }
80
85
 
81
- // The `session:` map's own entries, one `key: value` text each. Undefined when `session:` holds
82
- // no map — nothing under it is null, and a scalar or a sequence is not a map — all of which the
83
- // CLI refuses. A block map's entries are its lines at the first child's indentation, so a
84
- // `contextLimit` nested deeper is some other key's.
86
+ // The `session:` map's own entries, one `key: value` text each, and whether the map is a flow
87
+ // map. Undefined when `session:` holds no map — nothing under it is null, and a scalar or a
88
+ // sequence is not a map — all of which the CLI refuses. A block map's entries are its lines at the
89
+ // first child's indentation, so a `contextLimit` nested deeper is some other key's.
85
90
  function sessionEntries(lines, at) {
86
91
  const inline = uncomment(lines[at].replace(SESSION, '')).trim();
87
- if (inline.startsWith('{')) return flowEntries([inline, ...lines.slice(at + 1).map(uncomment)].join('\n'));
92
+ if (inline.startsWith('{')) {
93
+ const entries = flowEntries([inline, ...lines.slice(at + 1).map(uncomment)].join('\n'));
94
+ return entries === undefined ? undefined : { flow: true, entries };
95
+ }
88
96
  if (inline !== '') return undefined;
89
97
  const entries = [];
90
98
  let indent;
@@ -97,7 +105,7 @@ function sessionEntries(lines, at) {
97
105
  if (lead === indent) entries.push(text);
98
106
  }
99
107
  if (indent === undefined || /^-(\s|$)/.test(entries[0])) return undefined;
100
- return entries;
108
+ return { flow: false, entries };
101
109
  }
102
110
 
103
111
  /**
@@ -109,10 +117,12 @@ export function contextLimitOf(text) {
109
117
  const lines = text.replace(/^\uFEFF/, '').split(/\r?\n/);
110
118
  const at = lines.findIndex((line) => SESSION.test(line));
111
119
  if (at === -1) return DEFAULT_LIMIT;
112
- const entries = sessionEntries(lines, at);
113
- if (entries === undefined) return undefined;
114
- for (const entry of entries) {
115
- const m = LIMIT.exec(entry.trim());
120
+ const session = sessionEntries(lines, at);
121
+ if (session === undefined) return undefined;
122
+ for (const entry of session.entries) {
123
+ const text = entry.trim();
124
+ if (!session.flow && ADJACENT.test(text)) return undefined;
125
+ const m = LIMIT.exec(text);
116
126
  if (!m) continue;
117
127
  const n = numberOf((m[2] ?? '').trim());
118
128
  return Number.isInteger(n) && n >= 1 && n <= 100 ? n : undefined;
@@ -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.21.0} 2>/dev/null || true
6
+ ${WITNESS_BIN:-npx -y @popovych.co/witness@0.21.1} 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.21.0}"
18
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.21.1}"
19
19
  ```
20
20
 
21
21
  - **Render the CLI's decision output verbatim and in full — every line, unmodified.** Never print a command set you remember; never recompose, reformat, summarise or reorder what the CLI emitted. Which decisions are live, how they rank, and what each costs are the CLI's answers, and they change with the round, the bound, the repair grant and the content sha — a remembered set is wrong in more states than it is right.
@@ -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.21.0}"
13
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.21.1}"
14
14
  ```
15
15
 
16
16
  - **Render the CLI's decision output verbatim and in full — every line, unmodified.** Never print a command set you remember; never recompose, reformat, summarise or reorder what the CLI emitted. Which decisions are live, how they rank, and what each costs are the CLI's answers, and they change with the round, the bound, the repair grant and the content sha — a remembered set is wrong in more states than it is right.
@@ -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.21.0}"
19
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.21.1}"
20
20
  ```
21
21
 
22
22
  - **Render the CLI's decision output verbatim and in full — every line, unmodified.** Never print a command set you remember; never recompose, reformat, summarise or reorder what the CLI emitted. Which decisions are live, how they rank, and what each costs are the CLI's answers, and they change with the round, the bound, the repair grant and the content sha — a remembered set is wrong in more states than it is right.
@@ -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.21.0}"
18
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.21.1}"
19
19
  ```
20
20
 
21
21
  - **Render the CLI's decision output verbatim and in full — every line, unmodified.** Never print a command set you remember; never recompose, reformat, summarise or reorder what the CLI emitted. Which decisions are live, how they rank, and what each costs are the CLI's answers, and they change with the round, the bound, the repair grant and the content sha — a remembered set is wrong in more states than it is right.
@@ -74,11 +74,11 @@ slice: end — 17% + next step ≈ 22% passes the 20% limit
74
74
  - `slice: continue` → take the next step.
75
75
  - `slice: end — <why>` → the slice is over: `dispatch-report`, then the relay (below).
76
76
  - `slice: gate — no steps remain` → skip the relay and go to the gate.
77
- - `slice: own count — … end after 3 step(s) by your own count` → the CLI cannot count this session's steps, so you do, from this session's own closes. Once this session has closed that many, the slice is over, as at `slice: end`.
77
+ - `slice: own count — the CLI cannot count this session's steps · end after 3 step(s) by your own count` → so you count them, from this session's own closes. Once this session has closed that many, the slice is over, as at `slice: end`.
78
78
 
79
79
  `steps: unknown (evidence predates step tags)` marks a plan begun before step tags: the CLI cannot see which of its steps are done. Keep your own count from `$WITNESS log <plan-id>` (steps with a green `test-evidence` are done), still name the step in every cycle, still obey `slice: end`, and go to the gate when your count says no steps remain — the CLI cannot print `slice: gate` for this plan.
80
80
 
81
- Every passing green prints `context:`, and so does every `step-close` that closes its step; a passing green that closes no step — a re-record, a fix round's cycle — prints it alone. A vacuous `verify-red` prints none: its green only confirms the restore and journals nothing. When a passing green that closes a step, or a `step-close` that closes one, prints no `context:` line at all, the CLI could not read the config: say so, show the human `witness check`, and end your turn — the same stop as a `dispatch-report` that prints no `relay:` (below).
81
+ Every passing green prints `context:`, and so does every `step-close` that closes its step; on a known plan, a passing green that closes no step — a re-record, a fix round's cycle — prints it alone. A vacuous `verify-red` prints none: its green only confirms the restore and journals nothing. When a passing green that closes a step, or a `step-close` that closes one, prints no `context:` line at all, the CLI could not read the config, and says so on stderr: `warn: no context line — witness.config.yaml did not load (<rule>)`. Say so, show the human `witness check`, and end your turn — the same stop as a `dispatch-report` that prints no `relay:` (below).
82
82
 
83
83
  **Act on the context warning.** Once per session, when the fill has reached the limit, a `witness: context …` line follows a command's result: `witness: context 21% of 1M has reached the 20% limit — finish what you are in (implement: the red→green), then hand over`. It is addressed to you — never background to read past. Finish the red→green you are in — the step you are working, through its green; never stop mid-red — then run `dispatch-report` and relay, even if an earlier `slice:` said continue. Between steps, relay at once. Two things outrank it: a `slice: gate` still goes to the gate, and a session that has closed no step yet works its first one through its close. A fix round after a gate has no step to close: finish the round and re-gate — the gate is where it hands over.
84
84
 
@@ -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.21.0}"
18
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.21.1}"
19
19
  ```
20
20
 
21
21
  - **Render the CLI's decision output verbatim and in full — every line, unmodified.** Never print a command set you remember; never recompose, reformat, summarise or reorder what the CLI emitted. Which decisions are live, how they rank, and what each costs are the CLI's answers, and they change with the round, the bound, the repair grant and the content sha — a remembered set is wrong in more states than it is right.
@@ -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.21.0}"
18
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.21.1}"
19
19
  ```
20
20
 
21
21
  - **Render the CLI's decision output verbatim and in full — every line, unmodified.** Never print a command set you remember; never recompose, reformat, summarise or reorder what the CLI emitted. Which decisions are live, how they rank, and what each costs are the CLI's answers, and they change with the round, the bound, the repair grant and the content sha — a remembered set is wrong in more states than it is right.