@popovych.co/witness 0.19.4 → 0.20.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
@@ -63,12 +63,30 @@ witness is two halves: **six skills**, distributed through the
63
63
  **engine + guard + dashboard**, which no skills installer can place — `witness init
64
64
  --agent <name>` writes those and commits them.
65
65
 
66
- **Claude Code** — one step, the native path:
66
+ **Claude Code** — the native path:
67
67
 
68
68
  ```bash
69
69
  /plugin marketplace add popovych-co/witness # skills, engine, guard and dashboard together
70
70
  ```
71
71
 
72
+ **Claude Code, on either route** (the marketplace plugin above, or `init --agent claude-code`),
73
+ also needs the `witness-context` plugin, installed once per machine — then start a new session:
74
+
75
+ ```bash
76
+ claude plugin marketplace add popovych-co/witness && claude plugin install witness-context@witness
77
+ ```
78
+
79
+ It puts the session's live context reading into every Bash command, which is what lets a
80
+ session hand over at `session.contextLimit` (see Run economics below). The reading comes from
81
+ a hooks module, and only a plugin can carry one. `init --agent claude-code` also declares the
82
+ plugin in `.claude/settings.json`, the way a project names a plugin it needs, but the
83
+ declaration alone does not load it in a headless session (`claude -p`, `witness drive`). A
84
+ session loads the plugin when it starts, so one already running when you install it does not:
85
+ start a new one. The install is a copy: after upgrading witness, `claude plugin update
86
+ witness-context@witness` refreshes it, for the sessions started afterwards. Without the plugin
87
+ witness still runs, on the step budget, and says so; `witness check`, run from a session, reports
88
+ whether that session sees the reading.
89
+
72
90
  **Pi** (and any other agent the installer supports):
73
91
 
74
92
  ```bash
@@ -148,6 +166,8 @@ either.
148
166
  |---|---|
149
167
  | `harness: claude-code \| pi` | **the judge** — which harness runs this repo's gate reviewers, is measured by `calibrate`, and is probed by `check`. Judgment resolves `harness:` → `PI_CODING_AGENT` → `CLAUDECODE` → `claude-code`; session lines (`next`'s handoff, `dispatch`'s relay, `init --agent auto`) resolve detection first, because those name the CLI you are about to type at. Committed, so it binds every teammate's gates; `witness init --agent` writes it once and never re-points it. Undeclared repos are judged by whichever agent's session is open — `witness check` and `witness status` say so |
150
168
  | `gates.reviewerTimeoutMs` | milliseconds per reviewer invocation (default 600000) |
169
+ | `session.contextLimit` | the share of a session's context window, in percent, at which witness hands the work to a fresh session (integer 1–100, default 20). The harness measures the fill; the CLI decides where the journal carries the position — at a step close, after a revise, at a `witness ship` exit and at `next` — and never mid-red. A session whose fill cannot be read says so (`context: unmeasured (<reason>)`): an implement session then hands over by `implement.stepsPerDispatch`, and nothing else hands over (DESIGN.md row 191) |
170
+ | `implement.stepsPerDispatch` | the step budget: steps an implement session closes before it hands over when its fill cannot be read (default 3) — the fallback for `session.contextLimit` |
151
171
  | `drive.sessionTimeoutMs` | milliseconds a `drive`-spawned session may run before SIGTERM (default 3600000) |
152
172
  | `drive.parallel` | sessions `witness drive` runs at once under autonomy, one per target (default 2) |
153
173
  | `drive.parallelSuites` | `true` lets test runs in two worktrees overlap (default false: they take turns, because suites that boot servers or browsers collide) |
@@ -198,7 +218,8 @@ the wrong file refuses. There are no `WITNESS_*` env vars for configuration.
198
218
  | `next` | the one next action across every effort |
199
219
  | `drive [--flow <plan-id>] [--max-spawns <n>] [--manual] [--detach] [--ask "<text>"]` | runs every session itself · under autonomy it needs no terminal and reports when nothing else can move. Up to `drive.parallel` targets at once, one session per target; usage limits are waited out; a target past a cap is parked. `--detach` (autonomy only) runs it in the background into `.witness/drive.log`; `--ask` queues an ask first; `--manual` runs serially and asks you at every stop. One drive per repo. A session drive spawned is refused — agents are what drive spawns, never what spawns drive |
200
220
  | `ship <plan-id> [--manual]` | lanes → ship gate → PR → CI watch → under autonomy: merge on green · `--manual` keeps that run's stop and merge for a human |
201
- | `test-evidence` / `verify-red` | journal red/green criteria evidence from a worktree |
221
+ | `test-evidence <plan-id> --phase red\|green [--step <id>]` / `verify-red <plan-id> [--base <ref>] [--step <id>]` | journal red/green criteria evidence from a worktree; `--step` names the plan step the cycle closes — required before the plan's first implement gate (on a plan with a criteria step), left off for a fix round after it. A green that closes a step prints `context:`, `steps:` and `slice:` |
222
+ | `step-close <plan-id> --step <id>` | close a scaffolding step, which has no red→green to witness; prints `context:`, `steps:` and `slice:` as a closing green does, and closing it again changes nothing |
202
223
  | `adopt <path>` | absolve a finished hand-edit into the journal |
203
224
  | `abandon <plan-id \| effort-slug…>` | wind a plan/effort down; reverts only specs that effort itself wrote — several efforts go as one act, so a split or an amendment they share goes back together |
204
225
  | `dispatch-report <plan-id> --steps-assigned <n> --steps-completed <n>` | journal a session slice's telemetry |
@@ -236,15 +257,41 @@ Releasing is `node scripts/release.mjs <major|minor|patch|x.y.z>` followed by tw
236
257
 
237
258
  ## Run economics (operator notes)
238
259
 
239
- Non-load-bearing knobs for the machine that hosts long implement runs — the design
240
- depends on none of them (DESIGN.md row 79):
260
+ A witness session ends at a configured share of its context window:
261
+ `session.contextLimit`, 20% by default (DESIGN.md row 191). The harness measures the
262
+ fill — the `witness-context` plugin on Claude Code and the witness extension on Pi put the
263
+ live reading into every shell command's environment — and witness counts no tokens of its
264
+ own. The CLI decides only where the journal already carries the position: at a step
265
+ close (`slice: end`), after a revise, at a `witness ship` exit and at `next`, by
266
+ printing a `relay:` line. A session never hands over mid-red. Once per session, at the
267
+ limit, one line follows a command's result, so a long step learns it is over:
268
+
269
+ ```
270
+ witness: context 21% of 1M has reached the 20% limit — finish what you are in (implement: the red→green), then hand over
271
+ ```
272
+
273
+ Where nothing supplies the reading, witness says so —
274
+ `context: unmeasured (no adapter) — step budget 3` — and an implement session hands over
275
+ after `implement.stepsPerDispatch` steps instead; nothing else hands over.
276
+
277
+ Operator notes, non-load-bearing — the design depends on none of them:
241
278
 
279
+ - **Mind the window.** The limit is a share, so one warm-up weighs differently by model:
280
+ 51k tokens is 5% of a 1M window and 25% of a 200k one. A session that opens over the
281
+ limit is warned (`warn: this session opens at …`) and works one step per slice; on a
282
+ small window, raise `session.contextLimit` or accept a warm-up per step. `witness drive`'s
283
+ defaults were set when a slice was three steps: `drive.sessionTimeoutMs` (1 h) and
284
+ `drive.caps.spawns` (24). A measured slice on a large window can run past an hour, and on a
285
+ 200k window every slice is one step, so a plan takes more sessions; raise either if drive
286
+ times out or caps a plan that is still moving.
242
287
  - **Keep the host awake.** A suspend longer than an hour mid-run expires the prompt
243
288
  cache; on wake the full agent context is re-written at cache-write price. On macOS:
244
289
  `witness drive` holds `caffeinate -ims` for its own lifetime (a closed lid on battery
245
290
  still sleeps); for a chat session, `caffeinate -dims`, or plug in and disable sleep.
246
291
  - **`CLAUDE_CODE_AUTO_COMPACT_WINDOW`** (set at CLI launch) lowers the harness's
247
- auto-compaction threshold as belt-and-suspenders under the dispatch budget. It is
248
- documented for main sessions only — whether subagents honor it is unverified.
249
- - The designed mechanisms are the dispatch budget (`implement.stepsPerDispatch`),
250
- the loop-width protocol, and `dispatch-report` telemetry — see DESIGN.md rows 79–81.
292
+ auto-compaction threshold, as belt-and-suspenders under the fill relay. Compaction
293
+ stays the harness's continuity mechanism, never witness's; the variable is documented
294
+ for main sessions only — whether subagents honor it is unverified.
295
+ - The designed mechanisms are the fill relay (`session.contextLimit`, with
296
+ `implement.stepsPerDispatch` for sessions it cannot measure), the loop-width protocol,
297
+ and `dispatch-report` telemetry — see DESIGN.md rows 79–81 and 191.
package/dist/cli.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { configPath } from './config.js';
3
+ import { journalStamp } from './context.js';
3
4
  import { stateFloor } from './floor.js';
4
5
  import { primaryRoot } from './gitio.js';
6
+ import { withJournalCtx } from './journal.js';
5
7
  import { renderRefusal, v } from './refusal.js';
6
8
  import { NPX_LATEST, compareTriple, version } from './version.js';
7
9
  export const EXIT = { OK: 0, FINDINGS: 1, REFUSED: 2, BLOCKED: 3 };
@@ -24,6 +26,8 @@ const VERBS = {
24
26
  'test-evidence': () => import('./verbs/evidence.js'),
25
27
  'verify-red': () => import('./verbs/evidence.js').then((m) => ({ run: m.runVerifyRed })),
26
28
  'dispatch-report': () => import('./verbs/dispatch.js'),
29
+ // Row 191 §4.3. The close of a scaffolding step, the one plan step with no evidence to witness.
30
+ 'step-close': () => import('./verbs/step-close.js'),
27
31
  adopt: () => import('./verbs/adopt.js'),
28
32
  gate: () => import('./verbs/gate.js'),
29
33
  decide: () => import('./verbs/decide.js'),
@@ -77,10 +81,11 @@ const VERB_USAGE = {
77
81
  ship: 'witness ship <plan-id> [--manual]',
78
82
  start: 'witness start <plan-id>',
79
83
  status: 'witness status — flows · blocked docs · reconcile rows · pending gates (bare `witness` is the same screen)',
84
+ 'step-close': 'witness step-close <plan-id> --step <id>',
80
85
  sync: 'witness sync',
81
86
  trust: 'witness trust <artifact-id> [--yes]',
82
- 'test-evidence': 'witness test-evidence <plan-id> --phase red|green',
83
- 'verify-red': 'witness verify-red <plan-id> [--base <ref>]',
87
+ 'test-evidence': 'witness test-evidence <plan-id> --phase red|green [--step <id>]',
88
+ 'verify-red': 'witness verify-red <plan-id> [--base <ref>] [--step <id>]',
84
89
  };
85
90
  // Row 116 moved the body to version.ts, which journal.ts can import without closing a
86
91
  // cycle. Re-exported here because eleven call sites already ask the CLI shell for it and
@@ -132,11 +137,15 @@ export async function main(ctx, argv) {
132
137
  }
133
138
  }
134
139
  }
140
+ // Row 191 §4.8. Every line this command journals carries the session it runs in, read once
141
+ // from this invocation's own env: an in-process `main` (drive's decisions, a test's CLI) is
142
+ // an invocation of its own, with its own env and so its own stamp.
143
+ const stamp = journalStamp(ctx.env);
135
144
  if (!verb || verb === 'help' || verb === '--help') {
136
145
  if (!verb) {
137
146
  const rootRes = primaryRoot(ctx.cwd);
138
147
  if (rootRes.ok && existsSync(configPath(rootRes.value))) {
139
- return (await import('./verbs/dashboard.js')).run(ctx, []);
148
+ return withJournalCtx(stamp, async () => (await import('./verbs/dashboard.js')).run(ctx, []));
140
149
  }
141
150
  }
142
151
  ctx.out(usage());
@@ -153,7 +162,7 @@ export async function main(ctx, argv) {
153
162
  return EXIT.REFUSED;
154
163
  }
155
164
  try {
156
- return await (await load()).run(ctx, rest);
165
+ return await withJournalCtx(stamp, async () => (await load()).run(ctx, rest));
157
166
  }
158
167
  catch (e) {
159
168
  // Row 113. Eight verbs parse argv with node's strict `parseArgs`, which THROWS on a
package/dist/config.js CHANGED
@@ -99,6 +99,25 @@ export function resolveImplement(raw) {
99
99
  }
100
100
  return ok({ stepsPerDispatch: val });
101
101
  }
102
+ // Row 191 §4.2. The default 20 is the row's premise (§1): once a session's context is more
103
+ // than about 20% full, its output gets worse. A repo decision, so its one home is
104
+ // witness.config.yaml, never config.local.yaml (row 90).
105
+ export const DEFAULT_CONTEXT_LIMIT = 20;
106
+ export function resolveSession(raw) {
107
+ const conf = raw.session;
108
+ if (conf === undefined)
109
+ return ok({ contextLimit: DEFAULT_CONTEXT_LIMIT });
110
+ if (typeof conf !== 'object' || conf === null || Array.isArray(conf)) {
111
+ return refuse([v('session', 'invalid', String(conf), 'a map of session settings')]);
112
+ }
113
+ const val = conf.contextLimit;
114
+ if (val === undefined)
115
+ return ok({ contextLimit: DEFAULT_CONTEXT_LIMIT });
116
+ if (typeof val !== 'number' || !Number.isInteger(val) || val < 1 || val > 100) {
117
+ return refuse([v('session.contextLimit', 'invalid', String(val), 'an integer from 1 to 100 — % of the session\'s context window at which witness hands over to a fresh session')]);
118
+ }
119
+ return ok({ contextLimit: val });
120
+ }
102
121
  // D145. One hour. An implement slice is ~25 minutes at the default budget of 3 steps,
103
122
  // and the cost of the two errors is not symmetric: a generous ceiling wastes an hour
104
123
  // once, a starved one kills a working e2e run and hands the human a false `spawn-timeout`
@@ -356,6 +375,9 @@ export function loadConfig(root) {
356
375
  const implement = resolveImplement(obj);
357
376
  if (!implement.ok)
358
377
  return refuse(implement.violations);
378
+ const session = resolveSession(obj);
379
+ if (!session.ok)
380
+ return refuse(session.violations);
359
381
  const gates = resolveGates(obj);
360
382
  if (!gates.ok)
361
383
  return refuse(gates.violations);
@@ -375,6 +397,7 @@ export function loadConfig(root) {
375
397
  paths: paths.value,
376
398
  docs: docs.value,
377
399
  implement: implement.value,
400
+ session: session.value,
378
401
  gates: gates.value,
379
402
  drive: drive.value,
380
403
  autonomy: autonomy.value && !uncommitted,
@@ -0,0 +1,108 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { HARNESSES, loadHarness, resolveDriver } from './harness.js';
3
+ // Row 191 §4.1. Before every shell command the harness adapter puts the session's live fill
4
+ // here, as the harness itself measures it:
5
+ // {"session":"<harness session id>","tokens":31627,"window":1000000,"percent":3}
6
+ // The CLI counts nothing and keeps no table of windows (§2: a second ledger would drift from
7
+ // what the harness shows), and it ignores `percent`, so the two harnesses cannot round apart.
8
+ export const CONTEXT_ENV = 'WITNESS_CONTEXT';
9
+ // The session is a fact about the process running this command, not about the repo, so it
10
+ // comes from the driver ladder without the config rung: detection, else the default harness.
11
+ // The variable is the registry's `session` facet, never spelled here.
12
+ export function sessionIdOf(env) {
13
+ const driver = resolveDriver(env, {});
14
+ if (!driver.ok)
15
+ return undefined;
16
+ const id = env[driver.value.harness.session];
17
+ return id === undefined || id === '' ? undefined : id;
18
+ }
19
+ // Row 191 §4.8: the journal is committed, so it carries this hash and never the id itself.
20
+ export function sessionHash(id) {
21
+ return createHash('sha256').update(id).digest('hex').slice(0, 12);
22
+ }
23
+ const unmeasured = (reason) => ({ measured: false, reason });
24
+ const isInt = (x) => typeof x === 'number' && Number.isInteger(x);
25
+ // Row 191 §4.1. Trusted only when it parses, carries integer counts with tokens ≤ window, and
26
+ // names the session the harness names. That last check is what makes an inherited value
27
+ // harmless: a drive launched from a chat session's shell carries the chat's reading, and its
28
+ // child runs as a session of its own. The order is the reasons' precedence: a broken value is
29
+ // malformed whoever wrote it, and only this session's own value can say it has no reading yet.
30
+ export function readFill(env) {
31
+ const raw = env[CONTEXT_ENV];
32
+ if (raw === undefined || raw === '')
33
+ return unmeasured('no adapter');
34
+ let parsed;
35
+ try {
36
+ parsed = JSON.parse(raw);
37
+ }
38
+ catch {
39
+ return unmeasured('malformed');
40
+ }
41
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
42
+ return unmeasured('malformed');
43
+ const { session, tokens, window } = parsed;
44
+ if (typeof session !== 'string' || session === '')
45
+ return unmeasured('malformed');
46
+ if (!isInt(window) || window < 1)
47
+ return unmeasured('malformed');
48
+ if (tokens !== undefined && tokens !== null && (!isInt(tokens) || tokens < 0 || tokens > window)) {
49
+ return unmeasured('malformed');
50
+ }
51
+ if (session !== sessionIdOf(env))
52
+ return unmeasured('stale');
53
+ // 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
+ return unmeasured('no reading yet');
57
+ return { measured: true, reading: { session, tokens, window } };
58
+ }
59
+ // Row 191 §4.8: what this invocation's journal lines carry. The session whenever the harness
60
+ // names one, even unmeasured, because the step-budget fallback counts a session's steps by
61
+ // it; the reading only when it is trusted. Outside a harness session, nothing.
62
+ export function journalStamp(env) {
63
+ const id = sessionIdOf(env);
64
+ if (id === undefined)
65
+ return undefined;
66
+ const session = sessionHash(id);
67
+ const fill = readFill(env);
68
+ return fill.measured ? { session, tokens: fill.reading.tokens, window: fill.reading.window } : { session };
69
+ }
70
+ // Row 191 §4.9. Drive is a scheduler, not a session. Started from a chat session's shell, it
71
+ // inherits that session's reading and the harness's session variable, and neither is a fact
72
+ // about drive or about the sessions it spawns: each harness names its own session on its own
73
+ // commands (P1, and pi's bash tool). Every harness's variable goes, whichever drive spawns,
74
+ // read from the registry so none is spelled here.
75
+ export function withoutSession(env) {
76
+ const out = { ...env };
77
+ delete out[CONTEXT_ENV];
78
+ for (const name of HARNESSES) {
79
+ const harness = loadHarness(name);
80
+ if (harness.ok)
81
+ delete out[harness.value.session];
82
+ }
83
+ return out;
84
+ }
85
+ // Row 191 §4.5. The decision is `reached`, in integer tokens; the display is floored, so the
86
+ // two agree: a printed 19% never hands over and a printed 20% always does. Rounding would
87
+ // print 20% beside a session that carries on.
88
+ //
89
+ // pctOf and windowLabel have a twin in plugin/hooks/context-core.mjs, for the adapters' warning:
90
+ // the CLI must not import plugin/ at runtime, so tests/context-core.test.ts holds the two equal.
91
+ export function pctOf(tokens, window) {
92
+ return Math.floor(tokens * 100 / window);
93
+ }
94
+ export function reached(tokens, window, limit) {
95
+ return tokens * 100 >= limit * window;
96
+ }
97
+ // The window a fill is a share of, the way people say it: 1M, 200k. Display only: a window that
98
+ // is not round reads approximately (1048576 is 1M), and the percentage still uses the exact one.
99
+ // Thousands are rounded first, so a window that rounds up to a thousand of them reads 1M, never
100
+ // 1000k.
101
+ export function windowLabel(window) {
102
+ if (window < 1_000)
103
+ return String(window);
104
+ const thousands = Math.round(window / 1_000);
105
+ if (thousands < 1_000)
106
+ return `${thousands}k`;
107
+ return `${Math.round(window / 100_000) / 10}M`;
108
+ }
package/dist/drive.js CHANGED
@@ -4,7 +4,8 @@ import { dirname, join } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { EXIT, main } from './cli.js';
6
6
  import { loadConfig, loadLocalConfig } from './config.js';
7
- import { resolveJudge } from './harness.js';
7
+ import { withoutSession } from './context.js';
8
+ import { detectionVariables, resolveJudge } from './harness.js';
8
9
  import { ok, refuse, renderRefusal, v } from './refusal.js';
9
10
  import { findById, loadCanon } from './scan.js';
10
11
  import { resolveSpawn } from './spawn.js';
@@ -16,6 +17,7 @@ import { unmetNeeds } from './needs.js';
16
17
  import { capHit, parkAtCap } from './caps.js';
17
18
  import { effortStreams, readStream } from './journal.js';
18
19
  import { effortOf } from './reviewed.js';
20
+ import { vacuousEvidence } from './steps.js';
19
21
  import { pendingTxn } from './txn.js';
20
22
  import { duration, limitIn, realClock, sessionLimit, SINGLE_WAIT_MAX_MS, TOTAL_WAIT_MAX_MS, waitFor, } from './usage.js';
21
23
  import { keepAwake } from './awake.js';
@@ -145,12 +147,26 @@ export async function spawnSession(step, cfg, ctx, spawnN, opts = {}) {
145
147
  // a scripted fake that performs real CLI acts. Never documented for users — a spawn
146
148
  // binary chosen by ambient env is exactly the identity confusion row 90 removed.
147
149
  const bin = ctx.env.WITNESS_DRIVE_AGENT_BIN ?? cmd;
148
- // Row 164: on Windows the agent CLI may be a `.cmd` shim, which only cmd.exe can start.
149
150
  // Row 180: the marker is the child's target and its recursion guard in one.
150
- const run = resolveSpawn(bin, args, {
151
- ...ctx.env, ...env, WITNESS_BIN: localBin(ctx), [DRIVE_ENV]: step.target ?? TARGETLESS,
151
+ //
152
+ // Row 191 §4.9: a drive started from a chat session's shell inherits that session's reading
153
+ // and session variable, and the child is a session of its own that its harness names itself.
154
+ // The session check already makes a stale reading harmless; leaving both out is the second
155
+ // guard. The verb drops them on entry too; this holds for any other caller.
156
+ //
157
+ // Every harness's detection variable goes too, from the child alone (final review minor 5): its
158
+ // harness sets its own, and an inherited one named the chat's harness to a child of another — a
159
+ // claude child of a drive typed in a Pi chat detected Pi, read its own reading as stale and
160
+ // relayed with /new. Drive keeps them: the judge above, whose worker this is, may be detection's.
161
+ const inherited = withoutSession(ctx.env);
162
+ for (const variable of detectionVariables())
163
+ delete inherited[variable];
164
+ const childEnv = {
165
+ ...inherited, ...env, WITNESS_BIN: localBin(ctx), [DRIVE_ENV]: step.target ?? TARGETLESS,
152
166
  [DRIVE_DEADLINE_ENV]: String(t0 + cfg.drive.sessionTimeoutMs),
153
- });
167
+ };
168
+ // Row 164: on Windows the agent CLI may be a `.cmd` shim, which only cmd.exe can start.
169
+ const run = resolveSpawn(bin, args, childEnv);
154
170
  if (!run.ok) {
155
171
  renderRefusal(run.violations).forEach((l) => ctx.err(l));
156
172
  return end('spawn-failed');
@@ -259,8 +275,10 @@ const STREAM_PREFIX = /^\[\d+ [^\]]*\] /;
259
275
  // What `mergeOwnPr` prints when it leaves the merge to a human (ship.ts), and nothing else.
260
276
  const MERGE_LINE = /^ship: "?merge PR #(\d+) on GitHub when ready/;
261
277
  const streamLines = (root, id) => readStream(root, id).length;
262
- // A refused write is the CLI saying no: it moves nothing (F5).
263
- const moved = (e) => e.t !== 'write-refused';
278
+ // A refused write is the CLI saying no: it moves nothing (F5). Nor does a vacuous red (row 191,
279
+ // final review I3): a session that only re-runs one has done nothing, and counted, it read as
280
+ // progress, so the target was respawned until its spawn cap.
281
+ const moved = (e) => e.t !== 'write-refused' && !vacuousEvidence(e);
264
282
  // Row 181. Did anything happen for THIS target? A whole-journal count is satisfied by any
265
283
  // other lane's progress, so a stalled flow beside a busy one would spawn until the ceiling.
266
284
  // A target's key is its line, its stream, and the entries in its owning effort's stream that
package/dist/evidence.js CHANGED
@@ -86,6 +86,8 @@ export async function runSpecTests(runRoot, ctx, parentId, trustRoot, opts = {})
86
86
  }
87
87
  return ok({ runner: 'full-suite', tests, allOk: tests.every((t) => t.ok) });
88
88
  }
89
+ // Row 191 §4.3. `step` is the plan step this cycle closes; absent on a cycle that is no plan
90
+ // step's (a fix round after a gate).
89
91
  export function journalEvidence(stateRoot, planId, phase, run, extra = {}) {
90
92
  const entry = { t: 'test-evidence', artifact: planId, phase, runner: run.runner, tests: run.tests, ...extra };
91
93
  const marker = {
@@ -169,7 +171,10 @@ export function changedFiles(runRoot, base) {
169
171
  return [...new Set([...tracked, ...untracked])].sort();
170
172
  }
171
173
  const inCommit = (runRoot, ref, rel) => tryGit(runRoot, 'cat-file', '-e', `${ref}:${rel}`).ok;
172
- export async function verifyRed(runRoot, stateRoot, ctx, planId, parentId, base) {
174
+ export async function verifyRed(runRoot, stateRoot, ctx, planId, parentId, base, step) {
175
+ // Row 191 §4.3: a reconstructed cycle closes a plan step as a witnessed one does, so both of
176
+ // its entries name the step.
177
+ const extra = { reconstructed: true, ...(step !== undefined ? { step } : {}) };
173
178
  const files = changedFiles(runRoot, base);
174
179
  if (!files.some(isTestPath)) {
175
180
  return refuse([v('diff', 'no-test-changes', `${files.length} changed files, none are tests`, 'a diff that adds or modifies tagged tests')]);
@@ -210,7 +215,7 @@ export async function verifyRed(runRoot, stateRoot, ctx, planId, parentId, base)
210
215
  else if (existsSync(join(runRoot, rel)))
211
216
  rmSync(join(runRoot, rel));
212
217
  }
213
- red = await recordEvidence(runRoot, stateRoot, ctx, planId, parentId, 'red', { reconstructed: true });
218
+ red = await recordEvidence(runRoot, stateRoot, ctx, planId, parentId, 'red', extra);
214
219
  }
215
220
  finally {
216
221
  for (const rel of trackedNonTest) {
@@ -232,7 +237,14 @@ export async function verifyRed(runRoot, stateRoot, ctx, planId, parentId, base)
232
237
  }
233
238
  if (!red.ok)
234
239
  return red;
235
- const green = await recordEvidence(runRoot, stateRoot, ctx, planId, parentId, 'green', { reconstructed: true });
240
+ // Final review I3. A vacuous red is a finding, not a cycle: the green still runs, since it is
241
+ // what confirms the restore, but journals nothing. Journaled, it read as an act of the session
242
+ // and as drive's progress, so a session that opened over the limit and only re-ran this handed
243
+ // over forever, and drive respawned it to its cap. A step an earlier genuine red opened is
244
+ // closed by the green `next` routes for that open red, never by this reconstruction.
245
+ const green = red.value.allOk
246
+ ? await runSpecTests(runRoot, ctx, parentId, stateRoot)
247
+ : await recordEvidence(runRoot, stateRoot, ctx, planId, parentId, 'green', extra);
236
248
  if (!green.ok)
237
249
  return green;
238
250
  return ok({ redOk: !red.value.allOk, greenOk: green.value.allOk, vacuous: red.value.allOk });
package/dist/gate.js CHANGED
@@ -24,6 +24,7 @@ import { gateSettled, owedRun } from './verbs/next.js';
24
24
  import { prepareStamp, writeStamp } from './stamp.js';
25
25
  import { recommend, renderDecision } from './recommend.js';
26
26
  import { autonomyLines } from './autonomy.js';
27
+ import { reportHandover, slicesLine } from './slice.js';
27
28
  const GATES = new Map();
28
29
  export function registerGate(spec) { GATES.set(spec.gate, spec); }
29
30
  export function gateSpec(name) { return GATES.get(name); }
@@ -114,7 +115,7 @@ export function renderGateRun(ctx, entry, mode, opts = {}) {
114
115
  // nothing-pending and the exits line's single re-gate act is the whole live set (D131).
115
116
  function renderChoices(ctx, gate, target, entries, upstream, stale = false) {
116
117
  // D154. Resolved here rather than threaded through four call sites, on this file's own
117
- // precedent (`printDispatchArithmetic` loads canon itself so the entry-renderers stay
118
+ // precedent (`printSliceShape` loads canon itself so the entry-renderers stay
118
119
  // pure formatters). Best-effort: a root that will not resolve costs the trust variants,
119
120
  // never the block.
120
121
  const rootR = primaryRoot(ctx.cwd);
@@ -127,10 +128,11 @@ function renderChoices(ctx, gate, target, entries, upstream, stale = false) {
127
128
  else
128
129
  ctx.out(cmd('help', liveExits(gate, target, entries, stale, upstream)));
129
130
  }
130
- // Row 79: the human approving a plan sees the run's shape before it starts.
131
- // Display only — no lens, no threshold; loads config/canon itself so the
131
+ // Row 79: the human approving a plan sees the run's shape before it starts. Row 191 §4.10:
132
+ // under a measured harness that shape is the rule that will cut the run into slices, not a
133
+ // count of them. Display only — no lens, no threshold; loads config/canon itself so the
132
134
  // entry-renderers stay pure formatters.
133
- export function printDispatchArithmetic(ctx, root, gateName, target) {
135
+ export function printSliceShape(ctx, root, gateName, target) {
134
136
  if (gateName !== 'plan')
135
137
  return;
136
138
  const cfg = loadConfig(root);
@@ -139,9 +141,7 @@ export function printDispatchArithmetic(ctx, root, gateName, target) {
139
141
  const doc = findById(loadCanon(root), target);
140
142
  if (!doc || doc.meta.type !== 'plan')
141
143
  return;
142
- const budget = cfg.value.implement.stepsPerDispatch;
143
- const steps = (doc.meta.steps ?? []).length;
144
- ctx.out(kv('dispatches', `${steps} step(s) ≈ ${Math.ceil(steps / budget)} dispatch(es) at budget ${budget}`));
144
+ ctx.out(kv('slices', slicesLine(cfg.value)));
145
145
  }
146
146
  // Row 173. The gate, then — under `autonomy: on` and not a `--manual` run — the settling of
147
147
  // the stop it produced or re-showed, in the same invocation, so a chat session and drive
@@ -161,7 +161,14 @@ export async function runGate(ctx, gateName, target, flags) {
161
161
  // A dynamic import: settle.ts reaches decide.ts, whose gates/index.js import would close a
162
162
  // static cycle through this module — each gate's registerGate would run before GATES exists.
163
163
  const { settleStop, settledCode } = await import('./settle.js');
164
- return settledCode(code, await settleStop(ctx, rootR.value, cfgR.value, gateName, target));
164
+ const settled = await settleStop(ctx, rootR.value, cfgR.value, gateName, target);
165
+ // Row 191 §4.4. A revise settled here is handed to the session that ran the gate, so this is
166
+ // where a session at its limit hands it to a fresh one. Ship passes `handover: false`: its run
167
+ // hands over once, at its own exit (Plan's call 7).
168
+ const revised = settled.kind === 'decided' && (settled.decision === 'revise' || settled.decision === 'revise-upstream');
169
+ if (revised && flags.handover !== false)
170
+ reportHandover(ctx, rootR.value, cfgR.value, { guard: false });
171
+ return settledCode(code, settled);
165
172
  }
166
173
  async function judge(ctx, gateName, target, flags) {
167
174
  const rootR = primaryRoot(ctx.cwd);
@@ -340,7 +347,7 @@ async function judge(ctx, gateName, target, flags) {
340
347
  entries, upstream: upstreamId,
341
348
  ...(kind.entry.outcome === 'stopped' && leftToTail(entries) ? { help: false } : {}),
342
349
  });
343
- printDispatchArithmetic(ctx, root, spec.gate, target);
350
+ printSliceShape(ctx, root, spec.gate, target);
344
351
  return kind.entry.outcome === 'passed' ? EXIT.OK : EXIT.FINDINGS;
345
352
  }
346
353
  if (kind.kind === 'changed-nothing') {
@@ -565,7 +572,7 @@ async function judge(ctx, gateName, target, flags) {
565
572
  entries: after, upstream: upstreamId,
566
573
  ...(entry.outcome === 'stopped' && leftToTail(after) ? { help: false } : {}),
567
574
  });
568
- printDispatchArithmetic(ctx, root, spec.gate, target);
575
+ printSliceShape(ctx, root, spec.gate, target);
569
576
  for (const d of discharged)
570
577
  ctx.out(kv('discharged', `${d.id} — no longer reported by this round`));
571
578
  if (input.repin)
@@ -53,7 +53,9 @@ function resolveDesignCaptures(stateRoot, wt, planId) {
53
53
  out.push({ name: c.name, path });
54
54
  }
55
55
  if (out.length === 0) {
56
- return refuse([v('screens', 'screens-matched-nothing', `0 verified captures for the latest green cycle of ${planId}`, 'a UI plan (design-from pinned) must witness ≥1 screenshot — name captures in the browser test, then re-run: witness test-evidence <plan> --phase green')]);
56
+ // Row 191: refusing journals no gate run, so on a plan's first gate the re-run it names is
57
+ // still a step's cycle, and `test-evidence` requires the step there.
58
+ return refuse([v('screens', 'screens-matched-nothing', `0 verified captures for the latest green cycle of ${planId}`, 'a UI plan (design-from pinned) must witness ≥1 screenshot — name captures in the browser test, then re-run: witness test-evidence <plan> --phase green --step <id>')]);
57
59
  }
58
60
  return ok(out);
59
61
  }
package/dist/harness.js CHANGED
@@ -74,6 +74,7 @@ const REGISTRY = {
74
74
  name: 'claude-code',
75
75
  launch: 'claude',
76
76
  relay: '/clear',
77
+ session: 'CLAUDE_CODE_SESSION_ID',
77
78
  settings: '.claude/settings.json',
78
79
  bundled: true,
79
80
  payload: [
@@ -114,13 +115,15 @@ const REGISTRY = {
114
115
  launch: 'pi',
115
116
  defaultProvider: 'anthropic',
116
117
  relay: '/new',
118
+ session: 'PI_SESSION_ID',
117
119
  bundled: false,
118
120
  payload: [
119
121
  { from: 'plugin/commands/witness.md', to: '.pi/prompts/witness.md' },
120
122
  { from: 'plugin/hooks/canon-guard.mjs', to: '.pi/extensions/canon-guard.mjs' },
121
- // Source-tree sibling of canon-guard.mjs, exactly as it is in .pi/extensions/:
122
- // the adapter imports the core with ONE static relative specifier, and a
123
- // specifier that only resolves after install is a trap no unit test can hold.
123
+ { from: 'plugin/hooks/context-core.mjs', to: '.pi/extensions/context-core.mjs' },
124
+ // Source-tree sibling of both cores, exactly as it is in .pi/extensions/: the
125
+ // adapter imports each with ONE static relative specifier, and a specifier that
126
+ // only resolves after install is a trap no unit test can hold.
124
127
  { from: 'plugin/hooks/witness-pi.ts', to: '.pi/extensions/witness.ts' },
125
128
  { from: 'plugin/hooks/session-dashboard.sh', to: '.pi/extensions/session-dashboard.sh' },
126
129
  ],
@@ -166,13 +169,20 @@ export function loadHarness(name) {
166
169
  return ok(harness);
167
170
  }
168
171
  // Detection tests PRESENCE: neither CLAUDECODE=1 nor PI_CODING_AGENT=true is a documented
169
- // value contract.
172
+ // value contract. Each harness sets its variable on the commands it runs; read in this order.
173
+ const DETECTION = [
174
+ { variable: 'PI_CODING_AGENT', name: 'pi' },
175
+ { variable: 'CLAUDECODE', name: 'claude-code' },
176
+ ];
170
177
  function detectedRung(env) {
171
- if (env.PI_CODING_AGENT !== undefined)
172
- return { source: 'detected', name: 'pi' };
173
- if (env.CLAUDECODE !== undefined)
174
- return { source: 'detected', name: 'claude-code' };
175
- return undefined;
178
+ const hit = DETECTION.find((d) => env[d.variable] !== undefined);
179
+ return hit === undefined ? undefined : { source: 'detected', name: hit.name };
180
+ }
181
+ // Row 191 §4.9 (final review minor 5). Every harness's detection variable: what a session drive
182
+ // spawns must not inherit, since its own harness sets its own on the commands it runs, and one
183
+ // inherited from the chat drive was typed in would name that chat's harness to a child of another.
184
+ export function detectionVariables() {
185
+ return DETECTION.map((d) => d.variable);
176
186
  }
177
187
  function declaredRung(raw) {
178
188
  const configured = raw.harness;
package/dist/install.js CHANGED
@@ -156,6 +156,28 @@ export function installAllHomes(root, harness) {
156
156
  function hasCommand(entries, command) {
157
157
  return (entries ?? []).some((e) => (e.hooks ?? []).some((h) => h.command === command));
158
158
  }
159
+ // Row 191 §4.11. The context reading comes from a hooks module, and only a plugin carries one:
160
+ // witness-context, listed in witness's own GitHub marketplace beside the full plugin, which does
161
+ // not carry it (two copies would each latch and warn). P9 (2026-10-22): a headless session
162
+ // skipped a plugin the project's settings declared while its marketplace was not registered on
163
+ // the machine, and loaded one installed at user scope from the registered marketplace, at the
164
+ // root and in a worktree. So every machine installs it once: CONTEXT_INSTALL, which init and
165
+ // check print.
166
+ const WITNESS_REPO = 'popovych-co/witness';
167
+ export const WITNESS_MARKETPLACE = 'witness';
168
+ export const CONTEXT_PLUGIN = `witness-context@${WITNESS_MARKETPLACE}`;
169
+ // No comma: check prints it through toon's kv, which quotes a value holding one.
170
+ export const CONTEXT_INSTALL = `claude plugin marketplace add ${WITNESS_REPO} && claude plugin install ${CONTEXT_PLUGIN}`;
171
+ // The settings map `current` with `name` added, or undefined when it needs nothing: an entry of
172
+ // that name is the human's, and so is a value that is not a map at all — theirs to fix, never
173
+ // ours to replace.
174
+ function withEntry(current, name, value) {
175
+ if (current === undefined)
176
+ return { [name]: value };
177
+ if (typeof current !== 'object' || current === null || Array.isArray(current))
178
+ return undefined;
179
+ return Object.hasOwn(current, name) ? undefined : { ...current, [name]: value };
180
+ }
159
181
  // Merge, never replace: .claude/settings.json is the human's file and may already carry
160
182
  // their own hooks, model pin and permissions. We append only what is missing, matched
161
183
  // by command string, and preserve every other key byte for byte.
@@ -199,10 +221,25 @@ export function mergeSettings(existing) {
199
221
  changed = true;
200
222
  }
201
223
  }
224
+ // Row 191 §4.11. The repo declares the plugin its sessions read the fill from, the way Claude
225
+ // Code lets a project name a plugin it needs: its marketplace, and the plugin enabled. Headless
226
+ // the entry is skipped, harmlessly, until the machine has registered the marketplace (P9);
227
+ // whether a session's trust prompt offers the install from it is unprobed, owed with §6's
228
+ // chat-mode check. Append-what's-missing, as above: an entry the human has under either name
229
+ // is theirs — an explicit `false` turns the plugin off on purpose, and their `witness`
230
+ // marketplace may point at a local checkout.
231
+ const marketplaces = withEntry(doc.extraKnownMarketplaces, WITNESS_MARKETPLACE, { source: { source: 'github', repo: WITNESS_REPO } });
232
+ const plugins = withEntry(doc.enabledPlugins, CONTEXT_PLUGIN, true);
233
+ if (marketplaces !== undefined || plugins !== undefined)
234
+ changed = true;
202
235
  if (!changed)
203
236
  return { text: existing ?? '', changed: false };
204
237
  doc.hooks = { ...hooks, PreToolUse: pre, SessionStart: start };
205
238
  doc.permissions = { ...perms, allow };
239
+ if (marketplaces !== undefined)
240
+ doc.extraKnownMarketplaces = marketplaces;
241
+ if (plugins !== undefined)
242
+ doc.enabledPlugins = plugins;
206
243
  return { text: `${JSON.stringify(doc, null, 2)}\n`, changed: true };
207
244
  }
208
245
  // The config rung of Decision 5's ladder, and rung ONE of Decision 105's judgment ladder.