@popovych.co/witness 0.21.1 → 0.23.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.
Files changed (44) hide show
  1. package/README.md +9 -7
  2. package/dist/abandon.js +5 -2
  3. package/dist/arbiter.js +2 -2
  4. package/dist/ask.js +8 -42
  5. package/dist/autonomy.js +5 -0
  6. package/dist/basis.js +35 -0
  7. package/dist/cli.js +3 -3
  8. package/dist/decisions.js +118 -0
  9. package/dist/design.js +12 -0
  10. package/dist/drive-report.js +9 -1
  11. package/dist/drive.js +71 -34
  12. package/dist/fm.js +1 -1
  13. package/dist/gate.js +1 -1
  14. package/dist/gates/decompose.js +16 -3
  15. package/dist/gates/design.js +7 -14
  16. package/dist/gates/plan.js +47 -15
  17. package/dist/history.js +5 -1
  18. package/dist/reviewed.js +19 -5
  19. package/dist/schemas.js +3 -0
  20. package/dist/settings.js +1 -1
  21. package/dist/settle.js +2 -7
  22. package/dist/verbs/check.js +6 -3
  23. package/dist/verbs/dashboard.js +5 -11
  24. package/dist/verbs/diff.js +26 -1
  25. package/dist/verbs/drive.js +2 -12
  26. package/dist/verbs/log.js +4 -2
  27. package/dist/verbs/next.js +165 -55
  28. package/dist/verbs/read.js +26 -2
  29. package/dist/verbs/recap.js +16 -97
  30. package/dist/verbs/write.js +27 -4
  31. package/package.json +1 -1
  32. package/plugin/.claude-plugin/plugin.json +1 -1
  33. package/plugin/commands/witness.md +4 -3
  34. package/plugin/hooks/session-dashboard.sh +1 -1
  35. package/plugin/skills/witness-brainstorm/SKILL.md +2 -12
  36. package/plugin/skills/witness-decompose/SKILL.md +6 -4
  37. package/plugin/skills/witness-design/SKILL.md +2 -7
  38. package/plugin/skills/witness-implement/SKILL.md +1 -1
  39. package/plugin/skills/witness-plan/SKILL.md +5 -3
  40. package/plugin/skills/witness-ship/SKILL.md +1 -1
  41. package/prompts/plan-critic.md +2 -1
  42. package/prompts/slicing-critic.md +3 -2
  43. package/dist/recap-check.js +0 -66
  44. package/prompts/arbiter-recap.md +0 -23
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
@@ -272,10 +274,10 @@ the wrong file refuses. There are no `WITNESS_*` env vars for configuration.
272
274
  | verb | does |
273
275
  | --- | --- |
274
276
  | `init [--agent claude-code\|pi\|auto]` | scaffold config, principles, journal — one trailer commit; `--agent` also installs that harness's engine, guard and dashboard (idempotent) |
275
- | `recap [--amend] --file <json>` | validate + persist a scope recap; births the effort journal |
276
- | `write <id> --effort <e> --meta <json> --body <md>` | validated manifest → spec/plan on disk + journal entry |
277
- | `diff <spec-id>` | delta since the last realized state (plan pin → empty) |
278
- | `read <id> [--design] [--outline] [--lines <a>-<b>]` | print a spec/plan/design from the primary root — the read route, since a worktree carries no canon copy; `--outline` names each anchor's line range for fat artifacts |
277
+ | `recap [--amend] --file <json>` | validate + persist a scope recap — class, goals, non-goals, constraints and numbered decisions; births the effort journal |
278
+ | `write <id> --effort <e> --meta <json> --body <md>` | validated manifest → spec/plan on disk + journal entry; a spec's `covers` and `carries` map it to the recap's goals and decisions |
279
+ | `diff <spec-id>` | delta since the last realized state (plan pin → empty), and the decisions that moved since that plan |
280
+ | `read <id> [--design] [--outline] [--lines <a>-<b>] [--decisions]` | print a spec/plan/design from the primary root — the read route, since a worktree carries no canon copy; `--outline` names each anchor's line range for fat artifacts; `--decisions` prints the decisions a spec carries with its effort's constraints and non-goals, or an effort's whole recap |
279
281
  | `check` | schema, graph, invariants, needs, trailer audit, probes |
280
282
  | `index` | id · summary · status · depends across the canon, then each spec's plans |
281
283
  | `satisfy <id> --need <id\|n\|text>` | journal a manual need as met — the spec is never rewritten |
@@ -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/abandon.js CHANGED
@@ -34,10 +34,13 @@ function later(root, a, b) {
34
34
  // back under a later commit, whose parent was never the write's — so an amendment read as a create
35
35
  // and was deleted, and a create restored another effort's abandoned amendment. And the same bytes
36
36
  // written again later, by another effort, are that effort's: who may take them back is the
37
- // caller's question, asked of this answer.
38
- export function writeAct(root, id, sha, at = 'HEAD') {
37
+ // caller's question, asked of this answer. `among` narrows the efforts asked (row 198 asks the
38
+ // live ones first).
39
+ export function writeAct(root, id, sha, at = 'HEAD', among) {
39
40
  let found;
40
41
  for (const effort of effortStreams(root)) {
42
+ if (among !== undefined && !among.has(effort))
43
+ continue;
41
44
  if (!readStream(root, effort).some((e) => isWrite(e, id, sha)))
42
45
  continue;
43
46
  for (const { entry, commit } of journalActs(root, effort, at)) {
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/basis.js ADDED
@@ -0,0 +1,35 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { canonicalDecisions, carriedDecisions, decisionsSha } from './decisions.js';
3
+ import { latestRecap } from './journal.js';
4
+ import { effortOf } from './reviewed.js';
5
+ import { canonicalJson, canonicalSha } from './sha.js';
6
+ export function specBasis(root, doc) {
7
+ const decisions = decisionsSha(carriedDecisions(root, doc)?.decisions ?? []);
8
+ return { spec: canonicalSha(doc.meta, doc.body), ...(decisions !== undefined ? { decisions } : {}) };
9
+ }
10
+ export function planBasis(plan) {
11
+ const decisions = plan.meta['decisions-from'];
12
+ return { spec: String(plan.meta['derives-from']), ...(typeof decisions === 'string' ? { decisions } : {}) };
13
+ }
14
+ // The terms that moved, spec first; [] is fresh. An absent pin equals an absent current term.
15
+ export function basisMoved(pinned, current) {
16
+ const moved = [];
17
+ if (pinned.spec !== current.spec)
18
+ moved.push('spec');
19
+ if (pinned.decisions !== current.decisions)
20
+ moved.push('decisions');
21
+ return moved;
22
+ }
23
+ // The decisions term of a plan's reviewed identity (Builder's call 6): what its parent carries,
24
+ // and for a chore plan its chore's own decisions too, which the critic also reads. Never a pin —
25
+ // a chore's decisions in `decisions-from` would re-queue the parent spec forever (call 4).
26
+ // Undefined when neither carries any, so the identity's bytes are today's (c2).
27
+ export function planDecisionsTerm(root, plan, parent) {
28
+ const carried = carriedDecisions(root, parent)?.decisions ?? [];
29
+ const effort = effortOf(root, String(plan.meta.id));
30
+ const recap = effort === undefined ? undefined : latestRecap(root, effort);
31
+ const chore = recap?.class === 'chore' ? recap.decisions ?? [] : [];
32
+ if (chore.length === 0)
33
+ return decisionsSha(carried);
34
+ return createHash('sha256').update(canonicalJson({ parent: canonicalDecisions(carried), chore: canonicalDecisions(chore) })).digest('hex');
35
+ }
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',
@@ -75,8 +75,8 @@ const VERB_USAGE = {
75
75
  migrate: 'witness migrate [--dry-run]',
76
76
  log: 'witness log <stream> [--all] [--lineage]',
77
77
  next: 'witness next',
78
- read: 'witness read <id> [--design] [--outline] [--lines <a>-<b>]',
79
- recap: 'witness recap --file <recap.json> [--ask <id>] [--amend] [--manual]',
78
+ read: 'witness read <id> [--design] [--outline] [--lines <a>-<b>] [--decisions]',
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>',
@@ -0,0 +1,118 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { statSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { writeAct } from './abandon.js';
5
+ import { effortAbandoned, journalRel, latestRecap, readStream, streamIds } from './journal.js';
6
+ import { effortOf, effortWrites } from './reviewed.js';
7
+ import { canonicalJson, canonicalSha } from './sha.js';
8
+ import { kv, list, rows } from './toon.js';
9
+ // Row 198 (Builder's call 2). What a doc carries is what the write that made its CURRENT
10
+ // content said it carries — D168's act, so this adds no second derivation. Two efforts can
11
+ // write identical content with different `carries`; the later act wins, and git is asked only
12
+ // in that rare case. An abandoned effort's act is taken back (D168): while a live effort's write
13
+ // made the same content, the live one carries; content only abandoned writes made is canon of
14
+ // record, and so is what they carried. An id the effort's latest recap no longer holds is
15
+ // history, not carried. A doc no journaled write made (a hand edit, an adopt) carries nothing.
16
+ export function carriedDecisions(root, doc) {
17
+ const id = String(doc.meta.id);
18
+ const sha = canonicalSha(doc.meta, doc.body);
19
+ const index = writerIndex(root);
20
+ const all = index.writes.get(`${id}\0${sha}`) ?? [];
21
+ const live = all.filter((w) => !index.abandoned.has(w.effort));
22
+ const writers = live.length > 0 ? live : all;
23
+ if (writers.length === 0)
24
+ return undefined;
25
+ const effort = writers.length === 1 ? writers[0].effort
26
+ : writeAct(root, id, sha, 'HEAD', new Set(writers.map((w) => w.effort)))?.effort ?? writers[0].effort;
27
+ const carries = writers.find((w) => w.effort === effort)?.carries ?? [];
28
+ const decisions = (index.decisions.get(effort) ?? []).filter((d) => carries.includes(d.id));
29
+ return { effort, decisions };
30
+ }
31
+ let lastIndex;
32
+ function writerIndex(root) {
33
+ const ids = streamIds(root);
34
+ const key = ids.map((id) => {
35
+ const st = statSync(join(root, journalRel(id)), { throwIfNoEntry: false });
36
+ return `${id}:${st?.size ?? '-'}:${st?.mtimeMs ?? '-'}`;
37
+ }).join('\n');
38
+ if (lastIndex?.root === root && lastIndex.index.key === key)
39
+ return lastIndex.index;
40
+ const index = { key, writes: new Map(), decisions: new Map(), abandoned: new Set() };
41
+ for (const effort of ids) {
42
+ const entries = readStream(root, effort);
43
+ if (entries[0]?.t !== 'recap')
44
+ continue;
45
+ if (effortAbandoned(entries))
46
+ index.abandoned.add(effort);
47
+ const last = new Map();
48
+ for (const e of entries) {
49
+ if (e.t === 'recap')
50
+ index.decisions.set(effort, e.decisions ?? []);
51
+ else if (e.t === 'write')
52
+ last.set(`${String(e.artifact)}\0${String(e.sha)}`, (e.carries ?? []));
53
+ }
54
+ for (const [k, carries] of last)
55
+ index.writes.set(k, [...(index.writes.get(k) ?? []), { effort, carries }]);
56
+ }
57
+ lastIndex = { root, index };
58
+ return index;
59
+ }
60
+ const byId = (a, b) => a.id.localeCompare(b.id, undefined, { numeric: true });
61
+ // What a pin reads of a set of decisions: each id with its text, in id order. A recap may reorder
62
+ // them or keep other keys beside the text (validation reads only id and text), and neither is a
63
+ // changed decision — hashing the objects whole re-queued shipped specs with an empty delta.
64
+ export function canonicalDecisions(decisions) {
65
+ return [...decisions].sort(byId).map(({ id, text }) => ({ id, text }));
66
+ }
67
+ // The pin a plan takes on what its parent carries — undefined for none, so a decision-free
68
+ // basis is exactly the spec sha (c2).
69
+ export function decisionsSha(decisions) {
70
+ if (decisions.length === 0)
71
+ return undefined;
72
+ return createHash('sha256').update(canonicalJson(canonicalDecisions(decisions))).digest('hex');
73
+ }
74
+ // Row 198. What moved in a spec's carried decisions since the base plan was derived: added,
75
+ // then changed, then removed, each by id; a removed row shows the text the plan was derived
76
+ // under. A base plan with no `decisions-from` was derived under none, so all it carries now is
77
+ // added. Undefined when the plan pinned decisions but its write journaled no texts — unknown,
78
+ // never guessed. One derivation for `witness diff` and the plan gate's delta block.
79
+ export function decisionDelta(root, base, spec) {
80
+ const effort = base.planId === undefined ? undefined : effortOf(root, base.planId);
81
+ const journaled = effort === undefined ? undefined : effortWrites(root, effort).get(base.planId)?.decisions;
82
+ const before = journaled ?? (base.decisions === undefined ? [] : undefined);
83
+ if (before === undefined)
84
+ return undefined;
85
+ const now = carriedDecisions(root, spec)?.decisions ?? [];
86
+ const was = new Map(before.map((d) => [d.id, d.text]));
87
+ const is = new Set(now.map((d) => d.id));
88
+ return [
89
+ ...now.filter((d) => !was.has(d.id)).sort(byId).map((d) => ({ id: d.id, change: 'added', text: d.text })),
90
+ ...now.filter((d) => was.has(d.id) && was.get(d.id) !== d.text).sort(byId).map((d) => ({ id: d.id, change: 'changed', text: d.text })),
91
+ ...before.filter((d) => !is.has(d.id)).sort(byId).map((d) => ({ id: d.id, change: 'removed', text: d.text })),
92
+ ];
93
+ }
94
+ // Row 198. `witness read <spec> --decisions`: what the spec carries and the standing limits of
95
+ // the effort whose write made its content. Every row prints even when empty, so a reader can
96
+ // tell "none" from "not shown". One rendering for the verb and the plan critic's prompt.
97
+ export function decisionLines(root, doc) {
98
+ const carried = carriedDecisions(root, doc);
99
+ const recap = carried === undefined ? undefined : latestRecap(root, carried.effort);
100
+ return [
101
+ carried === undefined ? 'carried-by: none — no journaled write made this content' : kv('carried-by', carried.effort),
102
+ ...rows('decisions', ['id', 'text'], carried?.decisions ?? []),
103
+ ...rows('constraints', ['id', 'text'], recap?.constraints ?? []),
104
+ ...rows('non_goals', ['id', 'text'], recap?.non_goals ?? []),
105
+ ];
106
+ }
107
+ // `witness read <effort> --decisions`: the effort's whole latest recap — what a chore plan
108
+ // carries, since a chore writes no spec to carry it (§4.5).
109
+ export function recapLines(recap) {
110
+ return [
111
+ `effort: ${recap.effort} · ${recap.class}`,
112
+ ...rows('goals', ['id', 'text'], recap.goals),
113
+ ...rows('decisions', ['id', 'text'], recap.decisions ?? []),
114
+ ...rows('constraints', ['id', 'text'], recap.constraints),
115
+ ...rows('non_goals', ['id', 'text'], recap.non_goals),
116
+ list('slices', recap.slices),
117
+ ];
118
+ }
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,4 +1,5 @@
1
1
  import { basename } from 'node:path';
2
+ import { TARGETLESS } from './drive-presence.js';
2
3
  import { handoffLine, resolveDriver } from './harness.js';
3
4
  import { readStream, streamIds } from './journal.js';
4
5
  import { unmetNeeds } from './needs.js';
@@ -52,7 +53,14 @@ export function driveReport(root, ctx, cfg, start, aside, run) {
52
53
  if (named.has(a.target))
53
54
  continue;
54
55
  if (a.kind === 'conversation') {
55
- 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;
56
64
  wait(a.target, [`wait: conversation · ${who}`,
57
65
  ...(driver.ok ? [` run: ${handoffLine(driver.value.harness, root, undefined)}`] : [])]);
58
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/fm.js CHANGED
@@ -3,7 +3,7 @@ import { dirname } from 'node:path';
3
3
  import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
4
4
  import { ok, refuse, v } from './refusal.js';
5
5
  const FIELD_ORDER = [
6
- 'id', 'type', 'status', 'summary', 'ui', 'parent', 'derives-from', 'design-from',
6
+ 'id', 'type', 'status', 'summary', 'ui', 'parent', 'derives-from', 'design-from', 'decisions-from',
7
7
  'supersedes', 'depends', 'needs', 'criteria', 'steps', 'pr', 'drift', 'design',
8
8
  ];
9
9
  export function splitDoc(raw) {