@popovych.co/witness 0.13.0 → 0.14.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.
Files changed (43) hide show
  1. package/README.md +2 -0
  2. package/dist/abandon.js +11 -5
  3. package/dist/allowlist.js +32 -2
  4. package/dist/cli.js +7 -1
  5. package/dist/config.js +24 -0
  6. package/dist/criteria.js +12 -2
  7. package/dist/drive.js +439 -0
  8. package/dist/evidence.js +30 -3
  9. package/dist/gate.js +12 -3
  10. package/dist/gates/implement.js +5 -4
  11. package/dist/gates/plan.js +3 -5
  12. package/dist/gates/ship.js +4 -2
  13. package/dist/gitio.js +36 -0
  14. package/dist/harness.js +33 -11
  15. package/dist/install.js +15 -0
  16. package/dist/recommend.js +44 -1
  17. package/dist/refusal.js +7 -3
  18. package/dist/runner.js +4 -1
  19. package/dist/scan.js +19 -0
  20. package/dist/ship.js +5 -2
  21. package/dist/stamp.js +17 -4
  22. package/dist/verbs/check.js +12 -1
  23. package/dist/verbs/clean.js +16 -8
  24. package/dist/verbs/dashboard.js +49 -4
  25. package/dist/verbs/decide.js +55 -4
  26. package/dist/verbs/drive.js +42 -0
  27. package/dist/verbs/next.js +57 -6
  28. package/dist/verbs/start.js +24 -3
  29. package/dist/verbs/sync.js +129 -36
  30. package/dist/verbs/trust.js +90 -0
  31. package/dist/verdict.js +12 -4
  32. package/dist/worktree.js +20 -2
  33. package/package.json +1 -1
  34. package/plugin/.claude-plugin/plugin.json +1 -1
  35. package/plugin/commands/witness.md +2 -2
  36. package/plugin/hooks/canon-guard.mjs +26 -4
  37. package/plugin/hooks/session-dashboard.sh +1 -1
  38. package/plugin/skills/witness-brainstorm/SKILL.md +5 -4
  39. package/plugin/skills/witness-decompose/SKILL.md +4 -3
  40. package/plugin/skills/witness-design/SKILL.md +3 -2
  41. package/plugin/skills/witness-implement/SKILL.md +4 -3
  42. package/plugin/skills/witness-plan/SKILL.md +4 -3
  43. package/plugin/skills/witness-ship/SKILL.md +8 -4
package/README.md CHANGED
@@ -148,6 +148,7 @@ either.
148
148
  |---|---|
149
149
  | `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
150
  | `gates.reviewerTimeoutMs` | milliseconds per reviewer invocation (default 600000) |
151
+ | `drive.sessionTimeoutMs` | milliseconds a `drive`-spawned session may run before SIGTERM (default 3600000) |
151
152
 
152
153
  There is no `provider:` key. `witness gate` spawns the DECLARED harness's headless
153
154
  mode for every reviewer (Decisions 88, 105): claude-code renders bare Anthropic ids, pi
@@ -185,6 +186,7 @@ the wrong file refuses. There are no `WITNESS_*` env vars for configuration.
185
186
  | `design <spec-id> --file <html>\|--open\|--reconfirm` | register/show a `ui` spec's approved look |
186
187
  | `start <plan-id>` | create/re-attach the plan's worktree (`.witness/worktrees/<id>`) — code only: the canon dirs are excluded from it, so a plan cannot go stale in there |
187
188
  | `next` | the one next action across every effort |
189
+ | `drive [--flow <plan-id>] [--max-spawns <n>]` | schedule green-path work through headless sessions; you appear only at judgment stops. TTY-only — agents are what drive spawns, never what spawns drive |
188
190
  | `ship <plan-id>` | lanes → ship gate → PR → CI watch |
189
191
  | `test-evidence` / `verify-red` | journal red/green criteria evidence from a worktree |
190
192
  | `adopt <path>` | absolve a finished hand-edit into the journal |
package/dist/abandon.js CHANGED
@@ -10,7 +10,8 @@ import { findById } from './scan.js';
10
10
  import { effortOf, effortWrites } from './reviewed.js';
11
11
  import { ok, refuse, v } from './refusal.js';
12
12
  import { prepareStamp, writeStamp } from './stamp.js';
13
- import { removeWorktree } from './worktree.js';
13
+ import { kv } from './toon.js';
14
+ import { removeWorktree, worktreePath } from './worktree.js';
14
15
  export function pinCommit(root, rel, pin) {
15
16
  const log = tryGit(root, 'log', '--format=%H', '--', rel);
16
17
  if (!log.ok)
@@ -115,10 +116,15 @@ export function executeAbandon(root, ctx, items, effortEntry, subject) {
115
116
  }
116
117
  crashPoint(ctx.env, 'abandon-commit');
117
118
  const r = stateCommit(root, [...files], subject);
118
- if (r.ok)
119
- for (const item of items)
120
- if (item.planId)
121
- removeWorktree(root, item.planId);
119
+ // D141. `abandon` runs from wherever the human typed it, which may be the very
120
+ // worktree it is discarding; the removal re-runs harmlessly from anywhere else.
121
+ if (r.ok) {
122
+ for (const item of items) {
123
+ if (item.planId && !removeWorktree(root, item.planId, ctx.cwd)) {
124
+ ctx.out(kv('note', `worktree ${worktreePath(root, item.planId)} kept — this session stands in it; leave the directory and re-run witness clean`));
125
+ }
126
+ }
127
+ }
122
128
  return r;
123
129
  });
124
130
  }
package/dist/allowlist.js CHANGED
@@ -12,6 +12,37 @@ function load(root) {
12
12
  return [];
13
13
  }
14
14
  }
15
+ // D154. The trust list, read. One home for the fact "is this command trusted here", so the
16
+ // headless block, the decision surface and the `trust` verb cannot disagree about it.
17
+ export function trustedCommands(root) {
18
+ return load(root);
19
+ }
20
+ // D154. Append-dedupe. Trust is granted where a human decides — at the decision block via
21
+ // `--approve --trust-cmds`, or by the `trust` verb where no stop exists — and both routes
22
+ // land here rather than each writing the file their own way.
23
+ export function grantCommands(root, cmds) {
24
+ const have = load(root);
25
+ const merged = [...have];
26
+ for (const c of cmds)
27
+ if (!merged.includes(c))
28
+ merged.push(c);
29
+ if (merged.length === have.length)
30
+ return;
31
+ mkdirSync(join(root, '.witness'), { recursive: true });
32
+ writeFileSync(allowPath(root), JSON.stringify({ commands: merged }, null, 2));
33
+ }
34
+ // D154. The `cmd:` criteria an artifact carries that this repo does not trust yet. The
35
+ // artifact is the SPEC that owns the criteria: a plan gate judges a plan, but criteria live
36
+ // on the spec it derives from, and a block that listed nothing there would hide the very
37
+ // commands its approve is about to leave blocked.
38
+ export function untrustedCmdsFor(root, doc) {
39
+ if (!doc)
40
+ return [];
41
+ const list = Array.isArray(doc.meta.criteria) ? doc.meta.criteria : [];
42
+ const cmds = list.map((c) => c.cmd).filter((c) => typeof c === 'string');
43
+ const trusted = new Set(load(root));
44
+ return [...new Set(cmds.filter((c) => !trusted.has(c)))];
45
+ }
15
46
  export async function ensureTrusted(root, ctx, cmd) {
16
47
  if (ctx.env.WITNESS_TRUST_CMDS === '1')
17
48
  return 'trusted';
@@ -23,7 +54,6 @@ export async function ensureTrusted(root, ctx, cmd) {
23
54
  const answer = await ctx.ask(`witness wants to run: ${cmd}\ntrust this command? saves to .witness/allow.json (gitignored) [y/N]`);
24
55
  if (!answer.trim().toLowerCase().startsWith('y'))
25
56
  return 'declined';
26
- mkdirSync(join(root, '.witness'), { recursive: true });
27
- writeFileSync(allowPath(root), JSON.stringify({ commands: [...commands, cmd] }, null, 2));
57
+ grantCommands(root, [cmd]);
28
58
  return 'trusted';
29
59
  }
package/dist/cli.js CHANGED
@@ -34,9 +34,13 @@ const VERBS = {
34
34
  clean: () => import('./verbs/clean.js'),
35
35
  ship: () => import('./verbs/ship.js'),
36
36
  next: () => import('./verbs/next.js'),
37
+ // D145. The scheduler. `next` names the action; `drive` runs the sessions that perform
38
+ // it, stopping only where a human's judgment is the input.
39
+ drive: () => import('./verbs/drive.js'),
37
40
  abandon: () => import('./verbs/abandon.js'),
38
41
  rename: () => import('./verbs/rename.js'),
39
42
  sync: () => import('./verbs/sync.js'),
43
+ trust: () => import('./verbs/trust.js'),
40
44
  calibrate: () => import('./verbs/calibrate.js'),
41
45
  // Row 116's valve. Exempt from the floor gate in `main`, because a repository that
42
46
  // refuses every verb must still be able to run the one that unrefuses it.
@@ -50,10 +54,11 @@ const VERB_USAGE = {
50
54
  calibrate: 'witness calibrate <exact-model-id> [--suite all|reviewers|skills] [--only <name>] [--samples <n>] [--publish]',
51
55
  check: 'witness check',
52
56
  clean: 'witness clean',
53
- decide: 'witness decide <gate> <target> --approve|--revise|--stop [--override] [--repair] [--note <t>] [--upstream <artifact|effort>] [--pin <policy>]… [--show]',
57
+ decide: 'witness decide <gate> <target> --approve|--revise|--stop [--override] [--repair] [--note <t>] [--upstream <artifact|effort>] [--pin <policy>]… [--via affirmation] [--show]',
54
58
  floor: 'witness floor --show | --set <triple> --note <why>',
55
59
  design: 'witness design <spec-id> --file <html> | --reconfirm | --open',
56
60
  diff: 'witness diff <spec-id>',
61
+ drive: 'witness drive [--flow <plan-id>] [--max-spawns <n>] — schedule green-path work through headless sessions; you appear at judgment stops',
57
62
  'dispatch-report': 'witness dispatch-report <plan-id> --steps-assigned <n> --steps-completed <n> [--tokens <n>] [--tool-uses <n>] [--duration-ms <n>]',
58
63
  gate: 'witness gate <decompose|plan|implement|ship|design> <target> [--fresh] [--manual]',
59
64
  index: 'witness index',
@@ -70,6 +75,7 @@ const VERB_USAGE = {
70
75
  start: 'witness start <plan-id>',
71
76
  status: 'witness status — flows · blocked docs · reconcile rows · pending gates (bare `witness` is the same screen)',
72
77
  sync: 'witness sync',
78
+ trust: 'witness trust <artifact-id> [--yes]',
73
79
  'test-evidence': 'witness test-evidence <plan-id> --phase red|green',
74
80
  'verify-red': 'witness verify-red <plan-id> [--base <ref>]',
75
81
  };
package/dist/config.js CHANGED
@@ -98,6 +98,26 @@ export function resolveImplement(raw) {
98
98
  }
99
99
  return ok({ stepsPerDispatch: val });
100
100
  }
101
+ // D145. One hour. An implement slice is ~25 minutes at the default budget of 3 steps,
102
+ // and the cost of the two errors is not symmetric: a generous ceiling wastes an hour
103
+ // once, a starved one kills a working e2e run and hands the human a false `spawn-timeout`
104
+ // to debug. The ceiling exists to break a hung child, not to pace a healthy one.
105
+ export const DEFAULT_SESSION_TIMEOUT_MS = 3_600_000;
106
+ export function resolveDrive(raw) {
107
+ const conf = raw.drive;
108
+ if (conf === undefined)
109
+ return ok({ sessionTimeoutMs: DEFAULT_SESSION_TIMEOUT_MS });
110
+ if (typeof conf !== 'object' || conf === null || Array.isArray(conf)) {
111
+ return refuse([v('drive', 'invalid', String(conf), 'a map of drive settings')]);
112
+ }
113
+ const val = conf.sessionTimeoutMs;
114
+ if (val === undefined)
115
+ return ok({ sessionTimeoutMs: DEFAULT_SESSION_TIMEOUT_MS });
116
+ if (typeof val !== 'number' || !Number.isInteger(val) || val < 1) {
117
+ return refuse([v('drive.sessionTimeoutMs', 'invalid', String(val), 'an integer >= 1 — milliseconds each spawned session may take before SIGTERM')]);
118
+ }
119
+ return ok({ sessionTimeoutMs: val });
120
+ }
101
121
  export const DEFAULT_REVIEWER_TIMEOUT_MS = 600_000;
102
122
  // gates.* is otherwise read loosely at its consumers (stagePin); the timeout is
103
123
  // resolved here, typed, because a typo'd key silently defaulting to ten minutes is
@@ -212,6 +232,9 @@ export function loadConfig(root) {
212
232
  const gates = resolveGates(obj);
213
233
  if (!gates.ok)
214
234
  return refuse(gates.violations);
235
+ const drive = resolveDrive(obj);
236
+ if (!drive.ok)
237
+ return refuse(drive.violations);
215
238
  return ok({
216
239
  schema,
217
240
  raw: obj,
@@ -219,6 +242,7 @@ export function loadConfig(root) {
219
242
  docs: docs.value,
220
243
  implement: implement.value,
221
244
  gates: gates.value,
245
+ drive: drive.value,
222
246
  warning: schema < SCHEMA_VERSION ? `schema ${schema} < ${SCHEMA_VERSION} — run witness migrate (reserved)` : undefined,
223
247
  });
224
248
  }
package/dist/criteria.js CHANGED
@@ -6,7 +6,11 @@ import { criteriaExcludes, execCommand, runFiltered, runFullSuite, runnerConfig
6
6
  import { canonicalSha } from './sha.js';
7
7
  export async function runCriteria(runRoot, ctx, doc, opts = {}) {
8
8
  const trustRoot = opts.trustRoot ?? runRoot;
9
- const cfg = loadConfig(runRoot);
9
+ // D154 root unification. The trust list and the runner config are two halves of one
10
+ // question — "what may this repository run" — and answering them from two checkouts is
11
+ // the asymmetry behind the Aug 1 false negative. Both resolve at the primary root now
12
+ // (D132's doctrine: a branch checkout cannot re-point what the repo trusts or runs).
13
+ const cfg = loadConfig(trustRoot);
10
14
  if (!cfg.ok)
11
15
  return cfg;
12
16
  const rcRes = runnerConfig(cfg.value);
@@ -64,7 +68,13 @@ export async function runCriteria(runRoot, ctx, doc, opts = {}) {
64
68
  else if (typeof c.cmd === 'string') {
65
69
  const trust = await ensureTrusted(trustRoot, ctx, c.cmd);
66
70
  if (trust !== 'trusted') {
67
- criteria.push({ id: cid, kind: 'cmd', ok: false, detail: `untrusted-${trust} — allow interactively or set WITNESS_TRUST_CMDS=1` });
71
+ // D154. The block now names the verb that unblocks it. `witness trust <id>` is
72
+ // fully resolved — D147's runnability test, applied where the refusal is a detail
73
+ // string rather than a Violation.
74
+ criteria.push({
75
+ id: cid, kind: 'cmd', ok: false,
76
+ detail: `untrusted-${trust} — run: witness trust ${id} (or set WITNESS_TRUST_CMDS=1)`,
77
+ });
68
78
  continue;
69
79
  }
70
80
  const run = execCommand(runRoot, ctx, c.cmd);
package/dist/drive.js ADDED
@@ -0,0 +1,439 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { EXIT, main } from './cli.js';
6
+ import { resolveJudge } from './harness.js';
7
+ import { renderRefusal } from './refusal.js';
8
+ import { loadCanon } from './scan.js';
9
+ import { computeNext, flowAction, resolveFlow } from './verbs/next.js';
10
+ // A conversation is a stage whose product IS the exchange with a human: brainstorm has no
11
+ // artifact to author headlessly, and design authoring/showing needs eyes on a rendered
12
+ // page. Drive prints the handoff and gets out of the way rather than spawning a session
13
+ // that would talk to nobody.
14
+ function isConversation(action) {
15
+ if (action.stage === 'brainstorm')
16
+ return true;
17
+ if (action.line.startsWith('witness recap'))
18
+ return true;
19
+ return action.line.startsWith('witness design ')
20
+ && (action.line.includes('--open') || action.line.includes('--file'));
21
+ }
22
+ // The rule table is an ORDERED FIRST-MATCH list, on `recommend.ts`'s precedent: a
23
+ // misrouted action is attributable to one line here rather than to a weighting. Pure over
24
+ // the NextAction shape — no I/O — so every branch is one `expect` away.
25
+ export function classifyAction(action, root) {
26
+ const { line } = action;
27
+ // Both decision surfaces render `witness decide <gate> <target> …`: tier 2's pending
28
+ // decision (`--show`) and the bound endgame (`liveExits`, which joins several decide
29
+ // verbs with ` | `). The first two tokens after the verb are the gate and the target in
30
+ // both, because `decide`'s own positional parser reads them the same way.
31
+ if (line.startsWith('witness decide ')) {
32
+ const [, , gate, target] = line.split(' ');
33
+ return { kind: 'decision', gate, target: target ?? action.target };
34
+ }
35
+ // D121: a block is the CLI saying the human is choosing, not being told — so a runnable
36
+ // line under a block (the recovery fork, `N ready — ranked below`) is still a stop.
37
+ // Below the decide rule so a gate decision keeps its gate/target rather than its block.
38
+ if (action.block !== undefined) {
39
+ return { kind: 'decision', target: action.target, block: action.block };
40
+ }
41
+ if (isConversation(action))
42
+ return { kind: 'conversation', line };
43
+ // The merge is GitHub's act, not witness's — a flow at `watch` has nothing left for an
44
+ // agent to do. In practice this line arrives in the spawned ship session's OUTPUT
45
+ // (ship.ts:277) rather than in a routing row, which is why the loop reads the child's
46
+ // stream for it too; both readers exist because either surface can carry it.
47
+ if (line.includes('merge PR') || (action.note?.includes('merge PR') ?? false)) {
48
+ return { kind: 'merge', line: action.note?.includes('merge PR') ? action.note : line };
49
+ }
50
+ // `witness check` is computeNext's terminal answer in two states: canon errors that
51
+ // stopped routing early (bare line), and an exhausted ladder (line + a "nothing is
52
+ // owed" note, D157). Idle PRINTS the line rather than swallowing it: a human who sees
53
+ // `drive: idle — witness check` runs the verb, and the note has already told the
54
+ // routed session which state it is in. Spawning a session to guess would burn a
55
+ // context on a diagnosis one command answers.
56
+ if (line === 'witness check' && action.stage === undefined)
57
+ return { kind: 'idle' };
58
+ return { kind: 'spawn', home: action.home ?? root, stage: action.stage, target: action.target, model: action.model };
59
+ }
60
+ // The prompt every spawned session gets. Not a plan, not a stage instruction: the same
61
+ // slash command a human types, which routes through `next` in the session's own home —
62
+ // so a child rediscovers its work from state rather than from what drive believed a
63
+ // moment ago. That is what makes a killed drive costless to re-run (north star 6).
64
+ const SESSION_PROMPT = '/witness';
65
+ // How long a SIGTERMed session gets before SIGKILL, and how long a dead session's pipes
66
+ // get to drain before drive stops waiting on the grandchildren that inherited them.
67
+ const TERM_GRACE_MS = 5_000;
68
+ const EXIT_GRACE_MS = 2_000;
69
+ // This CLI, for the child. A spawned session shells out to `witness` through
70
+ // ${WITNESS_BIN:-npx …} in every stage skill, and an unset value means the child resolves
71
+ // the PUBLISHED version while its parent runs an unpublished one — which the state floor
72
+ // then refuses (cli-behind-state). calibrate.ts:517 sets it for its worker for exactly
73
+ // this reason. An explicitly set value wins: a session that already knows its bin is
74
+ // stating a fact drive has no better answer for.
75
+ function localBin(ctx) {
76
+ return ctx.env.WITNESS_BIN ?? `node ${join(dirname(fileURLToPath(import.meta.url)), 'bin.js')}`;
77
+ }
78
+ // Line-buffered because a pipe's chunks split wherever the kernel felt like it: printing
79
+ // chunks would interleave two streams mid-word and prefix half a line. Every complete
80
+ // line is prefixed and forwarded as it arrives — drive is a terminal a human is watching,
81
+ // and the child's own output IS the progress report (addendum §4).
82
+ function lineSink(prefix, emit) {
83
+ let buf = '';
84
+ return {
85
+ push: (chunk) => {
86
+ buf += chunk;
87
+ const parts = buf.split('\n');
88
+ buf = parts.pop() ?? '';
89
+ for (const line of parts)
90
+ emit(prefix + line);
91
+ },
92
+ flush: () => {
93
+ if (buf !== '') {
94
+ emit(prefix + buf);
95
+ buf = '';
96
+ }
97
+ },
98
+ };
99
+ }
100
+ // One headless session, in the action's home, streamed to the driver's terminal.
101
+ //
102
+ // The harness is the JUDGE ladder's answer (declared first, detection as fallback), not
103
+ // the driver's: this is a programmatic spawn like the reviewer battery and the
104
+ // calibration worker, not a statement about which CLI a human is typing at. Ambient
105
+ // detection deciding what drive spawns would make the same repo drive differently from
106
+ // two terminals.
107
+ export async function spawnSession(step, cfg, ctx, spawnN) {
108
+ const hxR = resolveJudge(ctx.env, cfg.raw);
109
+ if (!hxR.ok) {
110
+ renderRefusal(hxR.violations).forEach((l) => ctx.err(l));
111
+ return 'spawn-failed';
112
+ }
113
+ const { cmd, args, env } = hxR.value.harness.worker.spawn(SESSION_PROMPT, step.model);
114
+ // Test-only seam, on WITNESS_TRUST_CMDS' precedent (decide.ts): the suite points this at
115
+ // a scripted fake that performs real CLI acts. Never documented for users — a spawn
116
+ // binary chosen by ambient env is exactly the identity confusion row 90 removed.
117
+ const bin = ctx.env.WITNESS_DRIVE_AGENT_BIN ?? cmd;
118
+ const prefix = `[${spawnN} ${step.stage ?? 'drive'}/${step.target ?? step.home.split('/').pop() ?? '-'}] `;
119
+ const out = lineSink(prefix, (l) => ctx.out(l));
120
+ const err = lineSink(prefix, (l) => ctx.out(l));
121
+ return await new Promise((resolve) => {
122
+ const child = spawn(bin, args, {
123
+ cwd: step.home,
124
+ env: { ...ctx.env, ...env, WITNESS_BIN: localBin(ctx) },
125
+ stdio: ['ignore', 'pipe', 'pipe'],
126
+ });
127
+ let outcome = 'exited';
128
+ let settled = false;
129
+ let killTimer;
130
+ let grace;
131
+ const settle = (o) => {
132
+ if (settled)
133
+ return;
134
+ settled = true;
135
+ clearTimeout(timer);
136
+ clearTimeout(killTimer);
137
+ clearTimeout(grace);
138
+ out.flush();
139
+ err.flush();
140
+ child.stdout.destroy();
141
+ child.stderr.destroy();
142
+ resolve(o);
143
+ };
144
+ const timer = setTimeout(() => {
145
+ outcome = 'timeout';
146
+ child.kill('SIGTERM');
147
+ // A session that ignores SIGTERM is exactly the session the ceiling exists for.
148
+ killTimer = setTimeout(() => child.kill('SIGKILL'), TERM_GRACE_MS);
149
+ }, cfg.drive.sessionTimeoutMs);
150
+ child.stdout.setEncoding('utf8');
151
+ child.stderr.setEncoding('utf8');
152
+ child.stdout.on('data', (c) => out.push(c));
153
+ child.stderr.on('data', (c) => err.push(c));
154
+ child.on('error', (e) => {
155
+ ctx.err(`${prefix}spawn-failed: ${e.message}`);
156
+ settle('spawn-failed');
157
+ });
158
+ // `close` (stdio drained) is the clean end and the one that keeps every last line.
159
+ // `exit` (the process itself is gone) is the BOUND on it: an agent session spawns its
160
+ // own children — tools, git, node — and they inherit these pipes, so a killed parent
161
+ // whose grandchild survives holds stdout open forever. Waiting for `close` alone is
162
+ // therefore a hang with no timeout above it, which is the one failure a timeout must
163
+ // not have. Measured here: SIGTERM to a shell whose `sleep` survived it.
164
+ child.on('exit', () => {
165
+ clearTimeout(killTimer);
166
+ grace = setTimeout(() => settle(outcome), EXIT_GRACE_MS);
167
+ });
168
+ child.on('close', () => settle(outcome));
169
+ });
170
+ }
171
+ // Twenty sessions per invocation, held in MEMORY and nowhere else (north star 2): a
172
+ // ceiling that persisted would be state drive must clean up, and re-running is how a
173
+ // human says "keep going". It is a runaway guard, not a budget — the no-progress check
174
+ // below is what catches the common case long before this does.
175
+ export const DEFAULT_MAX_SPAWNS = 20;
176
+ // Did anything happen? Every act a session performs appends to a journal stream, so the
177
+ // total line count is the cheapest honest answer — derived, never stored, and blind to
178
+ // which stream grew, which is right: any growth anywhere means the repo moved.
179
+ function journalTotal(root) {
180
+ const dir = join(root, '.witness', 'journal');
181
+ if (!existsSync(dir))
182
+ return 0;
183
+ let total = 0;
184
+ for (const f of readdirSync(dir)) {
185
+ if (!f.endsWith('.jsonl'))
186
+ continue;
187
+ total += readFileSync(join(dir, f), 'utf8').split('\n').filter((l) => l !== '').length;
188
+ }
189
+ return total;
190
+ }
191
+ const STREAM_PREFIX = /^\[\d+ [^\]]*\] /;
192
+ // The loop. Derive, classify, spawn, re-derive — and every turn reads the repo from disk,
193
+ // so nothing drive believed a moment ago can outlive a child that changed it.
194
+ //
195
+ // Deliberately no lazy merge-stamp here (`next` does one): drive journals nothing (§9).
196
+ // The spawned session runs `witness next` in its own home and stamps there, so a merged
197
+ // PR is observed on the very next derivation — by the CLI that owns that act.
198
+ export async function driveLoop(root, cfg, ctx, flags) {
199
+ const maxSpawns = flags.maxSpawns ?? DEFAULT_MAX_SPAWNS;
200
+ let spawns = 0;
201
+ let acted = false;
202
+ let prev;
203
+ for (;;) {
204
+ const canon = loadCanon(root);
205
+ let action;
206
+ if (flags.flow !== undefined) {
207
+ const flowR = resolveFlow(canon, flags.flow);
208
+ // `--flow` is a claim, and a false claim refuses — but only as a claim, at the door.
209
+ // A flow that reached `done` UNDER drive is the loop succeeding: the merge stamp its
210
+ // own child wrote is what made resolveFlow refuse, and reporting that as
211
+ // `terminal-status` would end a completed run with a violations table.
212
+ if (!flowR.ok) {
213
+ if (acted && flowR.violations.some((x) => x.rule === 'terminal-status')) {
214
+ ctx.out(`drive: flow finished — ${flags.flow}`);
215
+ return EXIT.OK;
216
+ }
217
+ renderRefusal(flowR.violations).forEach((l) => ctx.err(l));
218
+ return EXIT.REFUSED;
219
+ }
220
+ const judgeR = resolveJudge(ctx.env, cfg.raw);
221
+ action = flowAction(root, cfg, flowR.value, judgeR.ok ? judgeR.value.harness.name : undefined)
222
+ ?? { line: 'witness check', target: flags.flow };
223
+ }
224
+ else {
225
+ action = computeNext(root, ctx, canon, cfg);
226
+ }
227
+ const step = classifyAction(action, root);
228
+ if (step.kind === 'conversation') {
229
+ ctx.out(`drive: conversation — ${step.line}`);
230
+ ctx.out('help: this stage is an exchange with a human — run it in a chat session, then re-run witness drive');
231
+ return EXIT.OK;
232
+ }
233
+ if (step.kind === 'merge') {
234
+ ctx.out(`drive: merge — ${step.line}`);
235
+ return EXIT.OK;
236
+ }
237
+ if (step.kind === 'idle') {
238
+ ctx.out(`drive: idle — ${action.line}`);
239
+ return EXIT.OK;
240
+ }
241
+ const journal = journalTotal(root);
242
+ // The convergence check, and the reason a crashed drive is safe to re-run: the same
243
+ // action twice with nothing journalled in between means the session could not do what
244
+ // the CLI asked. Spawning a third is how a loop burns a human's tokens all night.
245
+ if (prev?.line === action.line && prev.journal === journal) {
246
+ ctx.out(`drive: no progress — ${action.line}`);
247
+ ctx.out('help: the last act left the repo where it found it — read the stream above, then act on that line yourself');
248
+ return EXIT.FINDINGS;
249
+ }
250
+ // A decision is an act too, and it gets the same convergence guard: a decision that
251
+ // executes and journals nothing would otherwise re-render its own block forever.
252
+ if (step.kind === 'decision') {
253
+ prev = { line: action.line, journal };
254
+ acted = true;
255
+ const settled = await resolveDecision(step, action, ctx);
256
+ if (settled !== undefined)
257
+ return settled;
258
+ continue;
259
+ }
260
+ if (spawns >= maxSpawns) {
261
+ ctx.out(`drive: spawn ceiling reached (${maxSpawns}) — ${action.line}`);
262
+ ctx.out('help: re-run witness drive to continue, or raise --max-spawns');
263
+ return EXIT.FINDINGS;
264
+ }
265
+ prev = { line: action.line, journal };
266
+ spawns += 1;
267
+ acted = true;
268
+ // The merge is announced by the SHIP SESSION's own output (ship.ts:277), not by a
269
+ // routing row — a flow whose PR is open routes to `witness ship` forever, and only
270
+ // the child knows it printed "merge PR". Sniffing the stream is what turns that into
271
+ // a clean exit instead of a no-progress stop.
272
+ let merged;
273
+ const childCtx = {
274
+ ...ctx,
275
+ out: (l) => { if (l.includes('merge PR'))
276
+ merged = l.replace(STREAM_PREFIX, ''); ctx.out(l); },
277
+ };
278
+ const outcome = await spawnSession(step, cfg, childCtx, spawns);
279
+ if (outcome !== 'exited') {
280
+ ctx.out(`drive: spawn-${outcome === 'timeout' ? 'timeout' : 'failed'} — ${action.line}`);
281
+ return EXIT.FINDINGS;
282
+ }
283
+ if (merged !== undefined) {
284
+ ctx.out(`drive: merge — ${merged}`);
285
+ return EXIT.OK;
286
+ }
287
+ }
288
+ }
289
+ // Read back what `renderDecision` printed (recommend.ts:33): a `<n> · [recommended ·]
290
+ // <depth>[ · not runnable]` tag line, then the command indented under it. Parsed rather
291
+ // than recomputed on purpose — the human decides against the bytes on their screen, so
292
+ // the option drive runs must be the option they read, not a second derivation of it.
293
+ export function parseBlock(lines) {
294
+ const out = [];
295
+ lines.forEach((line, i) => {
296
+ const m = /^(\d+) · (.+)$/.exec(line);
297
+ if (!m)
298
+ return;
299
+ const command = (lines[i + 1] ?? '').trim();
300
+ if (!command.startsWith('witness '))
301
+ return;
302
+ const tags = m[2].split(' · ');
303
+ out.push({
304
+ n: Number(m[1]), command,
305
+ recommended: tags.includes('recommended'), runnable: !tags.includes('not runnable'),
306
+ });
307
+ });
308
+ return out;
309
+ }
310
+ // D143's exclusions, verbatim: an obligation-minting override (D122's ledger must not
311
+ // open on an "ok"), terminal acts, and trust grants (D154). `decide` refuses these with
312
+ // `nod-cannot` anyway — drive names the act instead of burning the round-trip.
313
+ function nodExcluded(command) {
314
+ if (command.startsWith('witness abandon '))
315
+ return 'witness abandon';
316
+ return ['--override', '--stop', '--trust-cmds'].find((f) => command.includes(f));
317
+ }
318
+ const AFFIRMATIONS = new Set(['y', 'yes', 'ok', 'okay', 'go']);
319
+ // The option verbs a human can name instead of a number — `approve`, `revise`, `stop`,
320
+ // `abandon`, `override`, `repair`. Ambiguity is reported, never guessed: two options can
321
+ // share a verb (a plain approve beside an approve --trust-cmds), and picking one for the
322
+ // human is authoring their decision.
323
+ function byVerb(word, options) {
324
+ return options.filter((o) => o.command.includes(`--${word}`) || o.command.startsWith(`witness ${word} `));
325
+ }
326
+ function selectOption(answer, options) {
327
+ if (answer.startsWith('witness '))
328
+ return { command: answer }; // typed out, byte-for-byte
329
+ if (AFFIRMATIONS.has(answer.toLowerCase())) {
330
+ const rec = options.find((o) => o.recommended);
331
+ if (!rec)
332
+ return { refuse: 'this block carries no recommendation — name the option (D143)' };
333
+ if (!rec.runnable)
334
+ return { refuse: 'the recommended option needs an id you must fill in — type it out' };
335
+ const excluded = nodExcluded(rec.command);
336
+ if (excluded !== undefined) {
337
+ return { refuse: `nod-cannot: ${excluded} is never taken on an affirmation — name the option (D143)` };
338
+ }
339
+ return { command: `${rec.command} --via affirmation` };
340
+ }
341
+ if (/^\d+$/.test(answer)) {
342
+ const picked = options.find((o) => o.n === Number(answer));
343
+ if (!picked)
344
+ return { refuse: `no option ${answer} in this block` };
345
+ if (!picked.runnable)
346
+ return { refuse: `option ${answer} needs an id you must fill in — type it out` };
347
+ return { command: picked.command };
348
+ }
349
+ const matches = byVerb(answer.toLowerCase(), options);
350
+ if (matches.length === 1)
351
+ return { command: matches[0].command };
352
+ if (matches.length > 1) {
353
+ return { refuse: `${answer} matches options ${matches.map((o) => o.n).join(', ')} — name the number` };
354
+ }
355
+ return { refuse: `not an option: ${answer} — answer with a number, an option verb, or the command itself` };
356
+ }
357
+ // Split a rendered command into argv, honoring the double quotes `--note "…"` arrives in.
358
+ // The block emits commands raw for exactly this reason (recommend.ts:31).
359
+ export function splitArgv(command) {
360
+ const argv = [];
361
+ let cur = '';
362
+ let quoted = false;
363
+ let started = false;
364
+ for (const ch of command) {
365
+ if (ch === '"') {
366
+ quoted = !quoted;
367
+ started = true;
368
+ continue;
369
+ }
370
+ if (ch === ' ' && !quoted) {
371
+ if (started) {
372
+ argv.push(cur);
373
+ cur = '';
374
+ started = false;
375
+ }
376
+ continue;
377
+ }
378
+ cur += ch;
379
+ started = true;
380
+ }
381
+ if (started)
382
+ argv.push(cur);
383
+ return argv;
384
+ }
385
+ // How many times drive re-asks before leaving the stop standing. A human who cannot name
386
+ // an option in three tries is being asked the wrong question, and a prompt that never
387
+ // gives up is a worse treadmill than the one drive exists to end.
388
+ const ASK_LIMIT = 3;
389
+ // The judgment stop, in the driver's own terminal. Rendering goes through the decide
390
+ // verb's `--show` so the block a human reads here is byte-identical to the one they would
391
+ // read in a chat session — D143 is one rule across surfaces, not two implementations.
392
+ //
393
+ // Returns undefined when the loop should continue (the decision executed), or the exit
394
+ // code drive should stop with.
395
+ async function resolveDecision(step, action, ctx) {
396
+ const rendered = [];
397
+ const echo = {
398
+ ...ctx,
399
+ out: (l) => { rendered.push(l); ctx.out(l); },
400
+ err: (l) => { rendered.push(l); ctx.err(l); },
401
+ };
402
+ if (step.block !== undefined) {
403
+ step.block.forEach((l) => echo.out(l));
404
+ }
405
+ else if (step.gate !== undefined && step.target !== undefined) {
406
+ const code = await main(echo, ['decide', step.gate, step.target, '--show']);
407
+ if (code !== EXIT.OK)
408
+ return EXIT.FINDINGS;
409
+ }
410
+ else {
411
+ ctx.out(`drive: decision — ${action.line}`);
412
+ }
413
+ const options = parseBlock(rendered);
414
+ for (let asked = 0; asked < ASK_LIMIT; asked++) {
415
+ const answer = (await ctx.ask('decide>')).trim();
416
+ // Empty is a human declining to decide now. One re-ask (the prompt may have scrolled
417
+ // past the block), then the stop stands — it is not drive's to resolve.
418
+ if (answer === '') {
419
+ if (asked > 0)
420
+ break;
421
+ ctx.out('help: answer with an option number, an option verb, or a bare y to take the recommendation — empty leaves the stop standing');
422
+ continue;
423
+ }
424
+ const pick = selectOption(answer, options);
425
+ if ('refuse' in pick) {
426
+ ctx.out(`help: ${pick.refuse}`);
427
+ continue;
428
+ }
429
+ ctx.out(`drive: running — ${pick.command}`);
430
+ const code = await main(ctx, splitArgv(pick.command).slice(1));
431
+ if (code !== EXIT.OK) {
432
+ ctx.out(`drive: decision refused — ${pick.command}`);
433
+ return EXIT.FINDINGS;
434
+ }
435
+ return undefined;
436
+ }
437
+ ctx.out(`drive: decision stands — ${action.line}`);
438
+ return EXIT.OK;
439
+ }