@popovych.co/witness 0.21.0 → 0.22.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
@@ -186,7 +186,7 @@ the list the CLI reads (`src/settings.ts`).
186
186
  | `ship.merge` | how ship merges its own PR under autonomy · merge \| squash \| rebase — accepts: merge \| squash \| rebase | `merge` |
187
187
  | `ship.ciSettleMs` | ms green CI must hold still before an autonomous merge · 0–1800000 — accepts: an integer from 0 to 1800000 — milliseconds a green check set must hold before witness merges | `60000` |
188
188
  | `autonomy` | on = the CLI settles its own stops and merges green PRs · on \| off · counts once committed — accepts: on \| off | `off` |
189
- | `arbiter.model` | model for the arbiter that settles stops and checks recaps under autonomy · exact id, no aliases — accepts: [provider/]model[:thinking] | `claude-opus-5-5` |
189
+ | `arbiter.model` | model for the arbiter that settles stops under autonomy · exact id, no aliases — accepts: [provider/]model[:thinking] | `claude-opus-5-5` |
190
190
  | `session.contextLimit` | % of the context window at which a session hands over to a fresh one · 1–100 — accepts: an integer from 1 to 100 — % of the session's context window at which witness hands over to a fresh session | `20` |
191
191
  | `implement.stepsPerDispatch` | steps an implement session takes when its context fill can't be read · ≥ 1 — accepts: an integer >= 1 — steps handed to each fresh implement agent | `3` |
192
192
  | `gates.reviewerTimeoutMs` | ms one reviewer, or the arbiter, may run before it is stopped (10 min) · ≥ 1 — accepts: an integer >= 1 — milliseconds each reviewer invocation may take | `600000` |
@@ -233,7 +233,9 @@ need and a trust grant. `witness ship` merges with `ship.merge` only once the sh
233
233
  approved and CI is green — never when the approval lapsed or GitHub refuses — and only after the
234
234
  check set has held still for `ship.ciSettleMs`: gh's watch ends the moment nothing is pending, so
235
235
  a check that registers late would otherwise be missed. Green means at least one check passed and
236
- none failed, was cancelled or is pending.
236
+ none failed, was cancelled or is pending. Brainstorm and design stay conversations with a human in
237
+ every mode: `/witness` starts drive for the machine work and keeps the human's own items —
238
+ decisions left for them, asks to grill, looks to design — in the chat.
237
239
 
238
240
  **`drive`.** `drive.parallelSuites: false` makes test runs in two worktrees take turns, because
239
241
  suites that boot servers or browsers collide. A target that reaches any `drive.caps.*` bound is
@@ -285,7 +287,7 @@ the wrong file refuses. There are no `WITNESS_*` env vars for configuration.
285
287
  | `design <spec-id> --file <html>\|--open\|--reconfirm` | register/show a `ui` spec's approved look |
286
288
  | `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 |
287
289
  | `next` | the one next action across every effort |
288
- | `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 |
290
+ | `drive [--flow <plan-id>] [--max-spawns <n>] [--manual] [--detach]` | 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`; `--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 |
289
291
  | `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 |
290
292
  | `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:` |
291
293
  | `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 |
package/dist/arbiter.js CHANGED
@@ -13,8 +13,8 @@ import { effortOf, effortSpecs } from './reviewed.js';
13
13
  import { anchorRecurrence, gateRuns, roundBudget } from './rounds.js';
14
14
  import { findById } from './scan.js';
15
15
  import { canonicalJson } from './sha.js';
16
- // How a later mode (plan A2's recap check) adds its own prompt without a second invoker.
17
- export const PROMPT_OF = { decision: 'arbiter', recap: 'arbiter-recap' };
16
+ // How a later mode adds its own prompt without a second invoker.
17
+ export const PROMPT_OF = { decision: 'arbiter' };
18
18
  const TEXT_MAX = 600;
19
19
  function renderOptions(menu) {
20
20
  return menu.flatMap((o, i) => [
package/dist/ask.js CHANGED
@@ -3,28 +3,13 @@ import { stateCommit } from './gitio.js';
3
3
  import { appendEntry, effortStreams, entryLine, journalRel, readStream } from './journal.js';
4
4
  import { acquireLock } from './lock.js';
5
5
  import { ok, refuse, v } from './refusal.js';
6
- import { ROUND_BOUND } from './rounds.js';
7
6
  import { crashPoint, pendingTxn, withTxn } from './txn.js';
8
7
  // Row 176. The human's ask is journaled state, on a stream witness owns the way it owns
9
8
  // `floor`. This module is its one home: the entries, the queue derived from them, and the
10
- // one write path the verb, `/witness <text>` and `drive --ask` share — so a crashed session
9
+ // one write path the verb and `/witness <text>` share — so a crashed session
11
10
  // loses nothing, because the brainstorm that answers an ask reads it from here, never from
12
11
  // its prompt.
13
12
  export const ASK_STREAM = 'asks';
14
- // The house bound on review rounds: a recap sent back three times waits for a human.
15
- export const RECAP_CHECK_BOUND = ROUND_BOUND;
16
- export function recapChecks(root, id) {
17
- return readStream(root, ASK_STREAM)
18
- .filter((e) => e.t === 'human-decision' && e.gate === 'recap' && e.artifact === id);
19
- }
20
- // The one rendering of why a check sent a recap back, shared by `recap`'s output, `next`'s
21
- // rung and the arbiter's own prior-checks section — so it lives here, and this module never
22
- // imports recap-check.ts.
23
- export function checkReason(e) {
24
- return e.rule === 'recap-class'
25
- ? `class — the recap says ${e.class} and the check decided ${e.decided ?? '?'} · ${e.reason}`
26
- : e.reason;
27
- }
28
13
  // Display only: the stored text is the human's, trimmed and otherwise as given.
29
14
  export function askText(text) {
30
15
  return oneLine(text);
@@ -37,33 +22,17 @@ export function oneLine(text) {
37
22
  }
38
23
  export function askQueue(root) {
39
24
  const out = [];
25
+ // Row 194. A `human-decision` entry on this stream is an old recap check (row 177): history
26
+ // `log` still prints, and nothing a queue derives from.
40
27
  for (const e of readStream(root, ASK_STREAM)) {
41
28
  if (e.t === 'ask')
42
- out.push({ id: String(e.id), text: String(e.text), state: 'unanswered', checks: 0 });
29
+ out.push({ id: String(e.id), text: String(e.text), state: 'unanswered' });
43
30
  else if (e.t === 'ask-withdrawn') {
44
31
  const a = out.find((x) => x.id === e.ask);
45
32
  if (a)
46
33
  a.state = 'withdrawn';
47
34
  }
48
35
  }
49
- for (const a of out) {
50
- const checks = recapChecks(root, a.id);
51
- const last = checks.at(-1);
52
- a.checks = checks.length;
53
- if (last?.decision === 'revise') {
54
- a.reason = oneLine(checkReason({ rule: String(last.rule), class: last.class, decided: last.decided, reason: String(last.reason ?? '') }));
55
- if (last.note !== undefined)
56
- a.note = oneLine(last.note);
57
- }
58
- if (a.state !== 'unanswered' || last === undefined)
59
- continue;
60
- if (last.decision === 'stop')
61
- a.parked = 'malformed';
62
- else if (last.decision === 'revise' && checks.length >= RECAP_CHECK_BOUND)
63
- a.parked = 'bound';
64
- if (a.parked !== undefined)
65
- a.state = 'parked';
66
- }
67
36
  // An ask is answered once any recap names it, even one an abandon later ended: re-asking is
68
37
  // the human's act, so an abandoned effort never puts its ask back in the queue.
69
38
  const answeredBy = new Map();
@@ -78,7 +47,6 @@ export function askQueue(root) {
78
47
  if (effort !== undefined) {
79
48
  a.state = 'answered';
80
49
  a.effort = effort;
81
- delete a.parked;
82
50
  }
83
51
  }
84
52
  return out;
@@ -114,7 +82,7 @@ export function queueAsk(ctx, root, text) {
114
82
  // crashed `/witness <words>` run again must not queue, and build, the work twice.
115
83
  const queue = askQueue(root);
116
84
  const unanswered = () => askQueue(root).filter((a) => a.state === 'unanswered').length;
117
- const open = queue.find((a) => (a.state === 'unanswered' || a.state === 'parked') && askText(a.text) === askText(text));
85
+ const open = queue.find((a) => a.state === 'unanswered' && askText(a.text) === askText(text));
118
86
  if (open !== undefined)
119
87
  return ok({ id: open.id, unanswered: unanswered(), already: true });
120
88
  // numbered under the lock: two queuers racing must not both take the next id
@@ -165,11 +133,9 @@ export function withdrawAsk(ctx, root, id, note) {
165
133
  lock.value();
166
134
  }
167
135
  }
168
- // The one derivation of which ask is routed next: the oldest still open. A parked ask waits
169
- // for a human, so it is routable whenever a human interviews and skipped under autonomy,
170
- // where re-offering it would repeat a command `recap` refuses.
171
- export function nextAsk(root, autonomous) {
172
- return askQueue(root).find((a) => a.state === 'unanswered' || (a.state === 'parked' && !autonomous));
136
+ // The one derivation of which ask is routed next: the oldest still open.
137
+ export function nextAsk(root) {
138
+ return askQueue(root).find((a) => a.state === 'unanswered');
173
139
  }
174
140
  // The ask an effort answers: what the decompose arbiter judges a slicing against beside the
175
141
  // recap itself (row 177). Named by the recap that birthed the effort — an amend carries no
package/dist/autonomy.js CHANGED
@@ -18,6 +18,11 @@ export function autonomyLines(cfg) {
18
18
  export function keptStopLine(stop, detail) {
19
19
  return cmd('kept-stop', `${KEPT_STOP_NAMES[stop]} — ${detail}`);
20
20
  }
21
+ // Row 179's kept stop for one plan's start: what is missing, then how to satisfy it. Drive's
22
+ // set-aside and the chat's notice (row 195) print this one line.
23
+ export function unmetNeedLine(needs) {
24
+ return keptStopLine('unmet-need', [...needs.map((u) => u.text), ...needs.map((u) => u.satisfy)].join(' · '));
25
+ }
21
26
  // The rules whose recommendation the CLI takes as it stands (design §7 A2's table). Each is
22
27
  // a fix with a right answer — revise here, revise upstream, approve over nits, re-run a
23
28
  // stale or malformed round — so no judgment is owed and none is spent.
package/dist/cli.js CHANGED
@@ -67,7 +67,7 @@ const VERB_USAGE = {
67
67
  floor: 'witness floor --show | --set <triple> --note <why>',
68
68
  design: 'witness design <spec-id> --file <html> | --reconfirm | --open',
69
69
  diff: 'witness diff <spec-id>',
70
- drive: 'witness 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',
70
+ drive: 'witness drive [--flow <plan-id>] [--max-spawns <n>] [--manual] [--detach] — runs every session itself · under autonomy it needs no terminal and reports when nothing else can move',
71
71
  'dispatch-report': 'witness dispatch-report <plan-id> --steps-assigned <n> --steps-completed <n> [--tokens <n>] [--tool-uses <n>] [--duration-ms <n>]',
72
72
  gate: 'witness gate <decompose|plan|implement|ship|design> <target> [--fresh] [--manual]',
73
73
  index: 'witness index',
@@ -76,7 +76,7 @@ const VERB_USAGE = {
76
76
  log: 'witness log <stream> [--all] [--lineage]',
77
77
  next: 'witness next',
78
78
  read: 'witness read <id> [--design] [--outline] [--lines <a>-<b>]',
79
- recap: 'witness recap --file <recap.json> [--ask <id>] [--amend] [--manual]',
79
+ recap: 'witness recap --file <recap.json> [--ask <id>] [--amend]',
80
80
  recover: 'witness recover [--complete | --rollback]',
81
81
  rename: 'witness rename <old-id> <new-id>',
82
82
  satisfy: 'witness satisfy <doc-id> --need <id | index | text>',
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
  }
package/dist/design.js CHANGED
@@ -53,6 +53,18 @@ export function designPairSha(html, spec) {
53
53
  .update(`spec:${canonicalSha(spec.meta, spec.body)}`)
54
54
  .digest('hex');
55
55
  }
56
+ // What the design gate judges: the artifact PAIRED with the spec it realizes. With no artifact
57
+ // the spec alone is the sha, so a re-authored spec still lapses a prior verdict. One derivation
58
+ // for the gate's `resolve` and `currentSha` and for `next`'s revise-owed read (row 194), so none
59
+ // can disagree about what moved.
60
+ export function designGateSha(html, spec) {
61
+ return html !== undefined ? designPairSha(html, spec) : canonicalSha(spec.meta, spec.body);
62
+ }
63
+ // The same sha for the artifact on disk now.
64
+ export function designCurrentSha(root, paths, spec) {
65
+ const abs = join(root, designRel(paths, String(spec.meta.id)));
66
+ return designGateSha(existsSync(abs) ? readFileSync(abs, 'utf8') : undefined, spec);
67
+ }
56
68
  export function designRel(paths, specId) {
57
69
  return `${paths.designs}/${specId}.html`;
58
70
  }
@@ -1,21 +1,17 @@
1
- import { existsSync, readdirSync } from 'node:fs';
2
- import { basename, join } from 'node:path';
1
+ import { basename } from 'node:path';
2
+ import { TARGETLESS } from './drive-presence.js';
3
3
  import { handoffLine, resolveDriver } from './harness.js';
4
- import { readStream } from './journal.js';
4
+ import { readStream, streamIds } from './journal.js';
5
5
  import { unmetNeeds } from './needs.js';
6
6
  import { findById, loadCanon } from './scan.js';
7
7
  import { pendingTxn } from './txn.js';
8
8
  import { owedDecisions, parkedGates } from './verbs/next.js';
9
9
  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
10
  export function runStart(root) {
15
- return { lines: new Map(streams(root).map((s) => [s, readStream(root, s).length])) };
11
+ return { lines: new Map(streamIds(root).map((s) => [s, readStream(root, s).length])) };
16
12
  }
17
13
  export function entriesSince(root, start) {
18
- return streams(root).flatMap((stream) => readStream(root, stream).slice(start.lines.get(stream) ?? 0).map((entry) => ({ stream, entry })));
14
+ return streamIds(root).flatMap((stream) => readStream(root, stream).slice(start.lines.get(stream) ?? 0).map((entry) => ({ stream, entry })));
19
15
  }
20
16
  const prOf = (line) => /PR #(\d+)/.exec(line)?.[1];
21
17
  // Every `run:` and `then:` line is raw: a command is never kv-escaped (D120).
@@ -57,7 +53,14 @@ export function driveReport(root, ctx, cfg, start, aside, run) {
57
53
  if (named.has(a.target))
58
54
  continue;
59
55
  if (a.kind === 'conversation') {
60
- const who = a.target === '-' ? 'brainstorm' : a.target;
56
+ // Row 196. A target-less conversation is a grill: of a queued ask, or — with nothing
57
+ // queued — of what to build next, which waits on nobody, so it is said and not counted.
58
+ const ask = a.target === TARGETLESS ? / --ask (\S+)/.exec(a.line)?.[1] : undefined;
59
+ if (a.target === TARGETLESS && ask === undefined) {
60
+ waits.push('nothing queued — /witness <what to build> in a chat');
61
+ continue;
62
+ }
63
+ const who = a.target === TARGETLESS ? `brainstorm · ask ${ask}` : a.target;
61
64
  wait(a.target, [`wait: conversation · ${who}`,
62
65
  ...(driver.ok ? [` run: ${handoffLine(driver.value.harness, root, undefined)}`] : [])]);
63
66
  }
package/dist/drive.js CHANGED
@@ -9,10 +9,10 @@ import { detectionVariables, resolveJudge } from './harness.js';
9
9
  import { ok, refuse, renderRefusal, v } from './refusal.js';
10
10
  import { findById, loadCanon } from './scan.js';
11
11
  import { resolveSpawn } from './spawn.js';
12
- import { computeNext } from './verbs/next.js';
12
+ import { computeNext, conversationStage } from './verbs/next.js';
13
13
  import { DRIVE_DEADLINE_ENV, DRIVE_ENV, DRIVE_LOG, DRIVE_LOG_ENV, TARGETLESS } from './drive-presence.js';
14
14
  import { ASK_STREAM } from './ask.js';
15
- import { KEPT_STOP_NAMES, keptStopLine } from './autonomy.js';
15
+ import { KEPT_STOP_NAMES, unmetNeedLine } from './autonomy.js';
16
16
  import { unmetNeeds } from './needs.js';
17
17
  import { capHit, parkAtCap } from './caps.js';
18
18
  import { effortStreams, readStream } from './journal.js';
@@ -23,27 +23,17 @@ import { duration, limitIn, realClock, sessionLimit, SINGLE_WAIT_MAX_MS, TOTAL_W
23
23
  import { keepAwake } from './awake.js';
24
24
  import { driveReport, runStart } from './drive-report.js';
25
25
  import { notify } from './notify.js';
26
- // A conversation is a stage whose product IS the exchange with a human: brainstorm has no
27
- // artifact to author headlessly, and design authoring/showing needs eyes on a rendered
28
- // page. Drive prints the handoff and gets out of the way rather than spawning a session
29
- // that would talk to nobody.
30
- function isConversation(action) {
31
- if (action.stage === 'brainstorm')
32
- return true;
33
- if (action.line.startsWith('witness recap'))
34
- return true;
35
- return action.line.startsWith('witness design ')
36
- && (action.line.includes('--open') || action.line.includes('--file'));
37
- }
38
26
  // The rule table is an ORDERED FIRST-MATCH list, on `recommend.ts`'s precedent: a
39
27
  // misrouted action is attributable to one line here rather than to a weighting. Pure over
40
28
  // the NextAction shape — no I/O — so every branch is one `expect` away.
41
29
  //
42
- // Row 180. Under autonomy nothing is a conversation: brainstorm, `witness recap` and design
43
- // authoring run headless (A2), and A1 answers the choose block and the recovery fork with
44
- // the policy's pick. A block that still reaches drive under a runnable line spawns, and the
45
- // session's own `next` takes it, so no block stops an unattended run. `witness decide`
46
- // lines stay decisions in both modes.
30
+ // Row 194. A conversation (`conversationStage`) is one in both modes: brainstorm, `witness
31
+ // recap` and design authoring and showing are exchanges with a human, so drive prints the
32
+ // handoff and gets out of the way rather than spawning a session that would talk to nobody.
33
+ // Row 180: under autonomy A1 answers the choose block and the recovery fork with the policy's
34
+ // pick. A block that still reaches drive under a runnable line spawns, and the session's own
35
+ // `next` takes it, so no block stops an unattended run. `witness decide` lines stay decisions
36
+ // in both modes.
47
37
  export function classifyAction(action, root, opts = {}) {
48
38
  const { line } = action;
49
39
  // Both decision surfaces render `witness decide <gate> <target> …`: tier 2's pending
@@ -60,7 +50,7 @@ export function classifyAction(action, root, opts = {}) {
60
50
  if (action.block !== undefined && !opts.autonomy) {
61
51
  return { kind: 'decision', target: action.target, block: action.block };
62
52
  }
63
- if (!opts.autonomy && isConversation(action))
53
+ if (conversationStage(action))
64
54
  return { kind: 'conversation', line };
65
55
  // The merge is GitHub's act, not witness's — a flow at `watch` has nothing left for an
66
56
  // agent to do. In practice this line arrives in the spawned ship session's OUTPUT
@@ -284,7 +274,7 @@ const moved = (e) => e.t !== 'write-refused' && !vacuousEvidence(e);
284
274
  // A target's key is its line, its stream, and the entries in its owning effort's stream that
285
275
  // are about its own flow (F5: one effort carries every sibling's writes). Target-less work
286
276
  // (brainstorm, the recovery fork) keys on the effort count and the asks stream, because a
287
- // rejected recap check grows the asks and an accepted one births an effort. A crashed
277
+ // queued ask grows the asks and a recap births an effort. A crashed
288
278
  // transaction is part of the target-less key (F4): the recovery fork is judged like any other
289
279
  // work, so a session that leaves the same crash behind is set aside instead of being
290
280
  // respawned to the ceiling.
@@ -380,6 +370,46 @@ export async function driveLoop(root, cfg, ctx, flags, clock = realClock) {
380
370
  return ![...lanes.keys()].some((l) => mine.includes(l) || family(l).includes(t));
381
371
  };
382
372
  const setAside = (key, a) => { aside.set(key, { target: key, ...a }); };
373
+ // Row 196. A target's own next action, read with every other target filtered out: how the
374
+ // settle pass, which holds no action, and `takeBack` derive the key a human may move.
375
+ const ownNext = (key) => computeNext(root, ctx, canonNow, cfg, { scope: key === TARGETLESS ? (t) => t === undefined : (t) => t === key });
376
+ const keyOf = (action) => progressKey(root, action, family);
377
+ // Whether `now` is this target's own answer: a pending transaction (a crash, or a lane caught
378
+ // mid-write) and canon errors answer every scope with one global rung, which says nothing
379
+ // about the target, so reading it as a move would release every aside at once.
380
+ const ownAnswer = (key, now) => pendingTxn(root) === undefined && (key === TARGETLESS || now.target === key);
381
+ // Row 196. First in every round: each target set aside for a human whose key moved since
382
+ // (an ask answered or queued, a need satisfied, a stop decided in a chat) leaves the aside
383
+ // set and competes for a slot again. One whose next act is the human's again is set aside
384
+ // anew, under its new key, by the settle pass or the fill that meets it.
385
+ const takeBack = () => {
386
+ for (const [key, a] of aside) {
387
+ if (a.key0 === undefined)
388
+ continue;
389
+ const now = ownNext(key);
390
+ if (!ownAnswer(key, now) || keyOf(now) === a.key0)
391
+ continue;
392
+ aside.delete(key);
393
+ ctx.out(`drive: taken back — ${key === TARGETLESS ? 'untargeted work' : key} · ${now.line}`);
394
+ }
395
+ };
396
+ // Row 196 (spec §4.3). Once more before the report: a target still the human's is reported as
397
+ // it is now, and one a human moved on to drive's work leaves the set — the report's
398
+ // journal-derived rows (decisions, needs) name whatever else still waits.
399
+ const refreshAside = () => {
400
+ canonNow = loadCanon(root);
401
+ for (const [key, a] of aside) {
402
+ if (a.key0 === undefined)
403
+ continue;
404
+ const now = ownNext(key);
405
+ if (!ownAnswer(key, now) || keyOf(now) === a.key0)
406
+ continue;
407
+ if (conversationStage(now))
408
+ aside.set(key, { ...a, kind: 'conversation', line: now.line, key0: keyOf(now) });
409
+ else if (!now.line.startsWith('witness decide '))
410
+ aside.delete(key);
411
+ }
412
+ };
383
413
  const stuck = (key, action) => {
384
414
  ctx.out(`drive: no progress — ${action.line}`);
385
415
  ctx.out(`help: ${action.target ?? 'untargeted work'} is set aside for this run — read its stream above`);
@@ -399,8 +429,8 @@ export async function driveLoop(root, cfg, ctx, flags, clock = realClock) {
399
429
  const standAside = async (step, action, key) => {
400
430
  if (step.kind === 'conversation') {
401
431
  ctx.out(`drive: conversation — ${step.line}`);
402
- ctx.out('help: this stage is an exchange with a human — run it in a chat session, then re-run witness drive');
403
- setAside(key, { kind: 'conversation', line: step.line, why: 'an exchange with a human' });
432
+ ctx.out('help: run /witness in a chat — drive takes it back when you are done');
433
+ setAside(key, { kind: 'conversation', line: step.line, why: 'an exchange with a human', key0: keyOf(action) });
404
434
  return;
405
435
  }
406
436
  if (step.kind === 'merge') {
@@ -413,14 +443,14 @@ export async function driveLoop(root, cfg, ctx, flags, clock = realClock) {
413
443
  // a `--manual` run's stop, which waits for the human and is never settled.
414
444
  if (autonomous) {
415
445
  ctx.out(`drive: waits on you — ${action.target ?? 'untargeted work'} · decision · ${action.line}`);
416
- setAside(key, { kind: 'decision', line: action.line, why: action.line, ...(step.gate ? { gate: step.gate } : {}) });
446
+ setAside(key, { kind: 'decision', line: action.line, why: action.line, ...(step.gate ? { gate: step.gate } : {}), key0: keyOf(action) });
417
447
  return;
418
448
  }
419
449
  if (unmoved(key, action))
420
450
  return;
421
451
  const r = await resolveDecision(step, action, ctx);
422
452
  if (r === 'stands')
423
- setAside(key, { kind: 'decision', line: action.line, why: action.line, ...(step.gate ? { gate: step.gate } : {}) });
453
+ setAside(key, { kind: 'decision', line: action.line, why: action.line, ...(step.gate ? { gate: step.gate } : {}), key0: keyOf(action) });
424
454
  if (r === 'refused')
425
455
  setAside(key, { kind: 'stuck', line: action.line, why: `decision refused on ${action.line}` });
426
456
  };
@@ -508,7 +538,7 @@ export async function driveLoop(root, cfg, ctx, flags, clock = realClock) {
508
538
  // the pass leaves for a human, drive sets aside. The pass never waits inside a reviewer
509
539
  // (F6: that wait was silent, uncounted and deaf to a signal): an arbiter refused by a usage
510
540
  // limit becomes drive's own pause, and the stop is judged again after it.
511
- const routed = new Set();
541
+ const routed = new Map(); // target → the re-run its settle routed
512
542
  const settlePass = async () => {
513
543
  const { settleOwed, settledLine } = await import('./settle.js');
514
544
  for (const r of await settleOwed(ctx, root, cfg, { scope: admits })) {
@@ -522,14 +552,14 @@ export async function driveLoop(root, cfg, ctx, flags, clock = realClock) {
522
552
  ctx.out(line);
523
553
  if (s.kind === 'kept') {
524
554
  ctx.out(`drive: waits on you — ${r.target} · ${KEPT_STOP_NAMES[s.stop]}`);
525
- setAside(r.target, { kind: 'kept', line: s.line, why: KEPT_STOP_NAMES[s.stop], gate: r.gate, stop: s.stop });
555
+ setAside(r.target, { kind: 'kept', line: s.line, why: KEPT_STOP_NAMES[s.stop], gate: r.gate, stop: s.stop, key0: keyOf(ownNext(r.target)) });
526
556
  }
527
557
  else if (s.kind === 'left') {
528
558
  ctx.out(`drive: waits on you — ${r.target} · decision · ${s.why}`);
529
- setAside(r.target, { kind: 'decision', line: `witness decide ${r.gate} ${r.target} --show`, why: s.why, gate: r.gate });
559
+ setAside(r.target, { kind: 'decision', line: `witness decide ${r.gate} ${r.target} --show`, why: s.why, gate: r.gate, key0: keyOf(ownNext(r.target)) });
530
560
  }
531
561
  else if (s.kind === 'routed') {
532
- routed.add(r.target);
562
+ routed.set(r.target, s.line.replace(/^autonomy: re-run owed — /, ''));
533
563
  }
534
564
  }
535
565
  };
@@ -542,9 +572,9 @@ export async function driveLoop(root, cfg, ctx, flags, clock = realClock) {
542
572
  if (needs.length === 0)
543
573
  return false;
544
574
  ctx.out(`drive: waits on you — ${action.target} · unmet need`);
545
- const line = keptStopLine('unmet-need', [...needs.map((u) => u.text), ...needs.map((u) => u.satisfy)].join(' · '));
575
+ const line = unmetNeedLine(needs);
546
576
  ctx.out(line);
547
- setAside(key, { kind: 'kept', line, why: KEPT_STOP_NAMES['unmet-need'], stop: 'unmet-need' });
577
+ setAside(key, { kind: 'kept', line, why: KEPT_STOP_NAMES['unmet-need'], stop: 'unmet-need', key0: keyOf(action) });
548
578
  return true;
549
579
  };
550
580
  // Row 183. Checked before every spawn for a target, the first included. Target-less work
@@ -594,6 +624,10 @@ export async function driveLoop(root, cfg, ctx, flags, clock = realClock) {
594
624
  for (;;) {
595
625
  stillAutonomous();
596
626
  canonNow = loadCanon(root);
627
+ // A run that is stopping fills nothing more, so a target released now would be set aside
628
+ // by nobody and drop out of the report; it stays where it is, and the report derives it.
629
+ if (!stop && !signalled)
630
+ takeBack();
597
631
  if (autonomous && !stop && !signalled && clock.now() >= pausedUntil)
598
632
  await settlePass();
599
633
  while (!stop && !signalled && lanes.size < parallel && clock.now() >= pausedUntil) {
@@ -609,9 +643,11 @@ export async function driveLoop(root, cfg, ctx, flags, clock = realClock) {
609
643
  }
610
644
  idle = undefined;
611
645
  // A stop whose settle the CLI routed to a re-run: the target's own session runs it,
612
- // because its scoped `next` prints the re-run (A1).
613
- if (step.kind === 'decision' && autonomous && action.target !== undefined && routed.has(action.target)) {
614
- step = { kind: 'spawn', home: root, target: action.target };
646
+ // because its scoped `next` prints the re-run (A1) — unless the re-run is a step of the
647
+ // design session, which is the chat's (row 194).
648
+ const rerun = action.target !== undefined ? routed.get(action.target) : undefined;
649
+ if (step.kind === 'decision' && autonomous && rerun !== undefined) {
650
+ step = conversationStage({ line: rerun }) ? { kind: 'conversation', line: rerun } : { kind: 'spawn', home: root, target: action.target };
615
651
  }
616
652
  if (step.kind !== 'spawn') {
617
653
  await standAside(step, action, key);
@@ -651,6 +687,7 @@ export async function driveLoop(root, cfg, ctx, flags, clock = realClock) {
651
687
  // Row 184. One report when nothing else can move, and for an autonomous run one
652
688
  // notification — a human at a TTY prompt is already watching.
653
689
  const stopped = signalled !== undefined ? `signal ${signalled}` : stop;
690
+ refreshAside();
654
691
  const report = driveReport(root, ctx, cfg, start, [...aside.values()], { autonomous, ...(stopped ? { stopped } : {}) });
655
692
  report.lines.forEach((l) => ctx.out(l));
656
693
  if (autonomous) {
package/dist/gate.js CHANGED
@@ -192,7 +192,7 @@ async function judge(ctx, gateName, target, flags) {
192
192
  return owed !== undefined && !owed.manual;
193
193
  };
194
194
  const canon = loadCanon(root);
195
- const inputR = await spec.resolve(root, ctx, canon, cfgR.value, target, { autonomous });
195
+ const inputR = await spec.resolve(root, ctx, canon, cfgR.value, target);
196
196
  if (!inputR.ok) {
197
197
  renderRefusal(inputR.violations).forEach((l) => ctx.err(l));
198
198
  return EXIT.REFUSED;
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { serializeDoc } from '../fm.js';
4
- import { designPairSha, designRel, designUnseen, elementIds, htmlSha, validateDesignArtifact } from '../design.js';
4
+ import { designCurrentSha, designGateSha, designRel, designUnseen, elementIds, htmlSha, validateDesignArtifact } from '../design.js';
5
5
  import { registerGate } from '../gate.js';
6
6
  import { latestRecap } from '../journal.js';
7
7
  import { ok, refuse, v } from '../refusal.js';
@@ -10,14 +10,10 @@ import { findById } from '../scan.js';
10
10
  import { canonicalSha, short } from '../sha.js';
11
11
  import { contextDigest } from '../delta.js';
12
12
  import { snapshotFiles } from '../snapshot.js';
13
- // What this gate judges: the artifact PAIRED with the spec it realizes. With no artifact
14
- // the spec alone is the sha, so a re-authored spec still lapses a prior verdict. Shared
15
- // by `resolve` and `currentSha` so the two can never disagree about what moved.
16
- const reviewedShaOf = (html, spec) => html !== undefined ? designPairSha(html, spec) : canonicalSha(spec.meta, spec.body);
17
13
  registerGate({
18
14
  gate: 'design',
19
15
  targetKind: 'spec',
20
- async resolve(root, _ctx, canon, cfg, specId, run) {
16
+ async resolve(root, _ctx, canon, cfg, specId) {
21
17
  const spec = findById(canon, specId);
22
18
  if (!spec || spec.meta.type !== 'spec') {
23
19
  return refuse([v('spec', 'unknown-spec', specId, 'a specs/ doc id')]);
@@ -34,11 +30,9 @@ registerGate({
34
30
  // learns about. An agent that drops the show step now hits a loud refusal its repair
35
31
  // loop already handles, instead of silently gating something nobody was shown.
36
32
  //
37
- // Row 178 moves sight from the gate to the approval for an autonomous run. Gating unseen
38
- // mattered because a human decided each round from findings; under autonomy nobody does
39
- // until the kept stop, and `decide design --approve` still refuses a look nobody opened,
40
- // so the human's stop is where it is opened and nothing stamps a look unseen.
41
- const unseen = run?.autonomous ? undefined : designUnseen(root, cfg.paths, specId);
33
+ // Row 194: in every mode. A look comes out of a conversation with a human, so the human
34
+ // has seen it before any critic judges it (row 178's unseen gate is gone).
35
+ const unseen = designUnseen(root, cfg.paths, specId);
42
36
  if (unseen !== undefined) {
43
37
  return refuse([v('design', 'design-unseen', `no sight witnessed for ${short(unseen)}`, `a human shown this artifact — run: witness design ${specId} --open`)]);
44
38
  }
@@ -59,7 +53,7 @@ registerGate({
59
53
  const tree = html !== undefined ? snapshotFiles(root, [{ name: 'design.html', content: html }]) : undefined;
60
54
  return ok({
61
55
  class: (recap?.class ?? 'feature'),
62
- reviewedSha: reviewedShaOf(html, spec),
56
+ reviewedSha: designGateSha(html, spec),
63
57
  ...(tree ? { snapshot: { tree, context: contextDigest(canonicalSha(spec.meta, spec.body)) } } : {}),
64
58
  artifactSha: html !== undefined ? htmlSha(html) : undefined,
65
59
  reviewed: { kind: 'design', artifact: { ids }, spec: { id: specId, body: spec.body } },
@@ -73,8 +67,7 @@ registerGate({
73
67
  const spec = findById(canon, specId);
74
68
  if (!spec || spec.meta.type !== 'spec')
75
69
  return undefined;
76
- const abs = join(root, designRel(cfg.paths, specId));
77
- return reviewedShaOf(existsSync(abs) ? readFileSync(abs, 'utf8') : undefined, spec);
70
+ return designCurrentSha(root, cfg.paths, spec);
78
71
  },
79
72
  // Upstream from the design gate is the spec's SLICING, which reopens the owning
80
73
  // effort's decompose (Decision 52) — so the id is the effort, not the spec.
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/settings.js CHANGED
@@ -111,7 +111,7 @@ export const SETTINGS = [
111
111
  },
112
112
  {
113
113
  path: 'arbiter.model', shape: PIN, since: 2, defaults: { 2: DEFAULT_MODEL }, ambient: true, group: 'behavior',
114
- comment: 'model for the arbiter that settles stops and checks recaps under autonomy · exact id, no aliases', want: PIN_WANT,
114
+ comment: 'model for the arbiter that settles stops under autonomy · exact id, no aliases', want: PIN_WANT,
115
115
  },
116
116
  {
117
117
  path: 'session.contextLimit', shape: { kind: 'int', min: 1, max: 100 }, since: 1, defaults: { 1: 20 }, hookRead: true, group: 'behavior',
package/dist/settle.js CHANGED
@@ -2,7 +2,6 @@ import { trustedCommands, untrustedCmdsFor } from './allowlist.js';
2
2
  import { arbitrate, decisionEvidence, menuFor } from './arbiter.js';
3
3
  import { keptStopLine, policyFor } from './autonomy.js';
4
4
  import { EXIT } from './cli.js';
5
- import { designUnseen } from './design.js';
6
5
  import { liftedHolds } from './needs.js';
7
6
  import { gateSpec } from './gate.js';
8
7
  import { readStream } from './journal.js';
@@ -76,13 +75,9 @@ export async function settleStop(ctx, root, cfg, gate, target) {
76
75
  };
77
76
  };
78
77
  if (policy.kind === 'human') {
79
- // Row 178: an autonomous critic ran on a look nobody opened, so the human's first act is
80
- // to open it — `decide design --approve` refuses until they have.
81
- const unseen = policy.stop === 'design-look' && designUnseen(root, cfg.paths, target) !== undefined;
78
+ // Row 194: the gate ran only on a look the human opened, so approving is their one act.
82
79
  const detail = policy.stop === 'design-look'
83
- ? unseen
84
- ? `the human opens the look with witness design ${target} --open · then approves it or sends a note — witness decide design ${target} --show`
85
- : `the human approves the screen or sends a note — witness decide design ${target} --show`
80
+ ? `the human approves the screen or sends a note — witness decide design ${target} --show`
86
81
  : `${untrusted.join(' · ')} — the human runs witness gate ${gate} ${target} in a terminal and answers each prompt`;
87
82
  const line = keptStopLine(policy.stop, detail);
88
83
  renderDecision(block).forEach((l) => ctx.out(l));