@volter/supercode-orchestrator 0.5.227 → 0.5.229

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 (80) hide show
  1. package/README.md +18 -2
  2. package/activation.mjs +1 -1
  3. package/adapters/agent-mailbox.mjs +10 -0
  4. package/adapters/api-server.mjs +6 -0
  5. package/agents.mjs +149 -40
  6. package/apply/doors.mjs +3 -1
  7. package/bin/agent-channel.mjs +7 -2
  8. package/bin/connection-runner.mjs +60 -19
  9. package/bin/hermes-worker.mjs +2 -1
  10. package/bin/orchestrator.mjs +3 -3
  11. package/bin/runtime.mjs +3 -2
  12. package/board/agent-current.mjs +82 -0
  13. package/board/agent-declarations.mjs +10 -3
  14. package/board/args.mjs +137 -6
  15. package/board/call.mjs +16 -1
  16. package/board/caller-session.mjs +3 -1
  17. package/board/capacity.mjs +59 -45
  18. package/board/card-state.mjs +14 -5
  19. package/board/ci.mjs +521 -178
  20. package/board/cli.mjs +614 -209
  21. package/board/client.mjs +64 -21
  22. package/board/close-current.mjs +36 -0
  23. package/board/current-records.mjs +44 -0
  24. package/board/custody.mjs +2 -1
  25. package/board/dispatch.mjs +1973 -846
  26. package/board/doors.mjs +2 -2
  27. package/board/engine.mjs +623 -85
  28. package/board/event-stream.mjs +10 -5
  29. package/board/fence.mjs +2 -0
  30. package/board/files.mjs +93 -62
  31. package/board/generation-owner.mjs +188 -53
  32. package/board/git-document.mjs +3 -1
  33. package/board/github-rest.mjs +29 -7
  34. package/board/incidents.mjs +5 -17
  35. package/board/listing.mjs +14 -8
  36. package/board/local-admission.mjs +59 -0
  37. package/board/lock-holder.mjs +18 -6
  38. package/board/machine-fleet.mjs +66 -13
  39. package/board/owner-door-worker.mjs +3 -3
  40. package/board/owner-door.mjs +17 -7
  41. package/board/owner-health.mjs +12 -15
  42. package/board/owner-interface.mjs +6 -0
  43. package/board/publication-reader.mjs +50 -0
  44. package/board/published.mjs +40 -12
  45. package/board/queries.mjs +24 -4
  46. package/board/record-view.mjs +30 -3
  47. package/board/request-mail.mjs +37 -6
  48. package/board/resting-admission.mjs +318 -0
  49. package/board/resting-codec-qualifications.json +33 -0
  50. package/board/resting-protocol.mjs +71 -0
  51. package/board/resting-state-protocol.json +120 -0
  52. package/board/resting-state.mjs +113 -0
  53. package/board/runtime-door.mjs +2 -2
  54. package/board/session-continuity.mjs +127 -0
  55. package/board/session-work.mjs +25 -13
  56. package/board/standalone-declaration.mjs +24 -0
  57. package/board/storage-contract.mjs +129 -7
  58. package/board/store.mjs +73 -11
  59. package/board/tools.mjs +56 -13
  60. package/board/wake.mjs +18 -5
  61. package/board/workflow.mjs +284 -82
  62. package/board/workspace.mjs +86 -9
  63. package/board/ztrack.mjs +344 -91
  64. package/connection.mjs +72 -17
  65. package/gates.mjs +2 -1
  66. package/hand-off.mjs +4 -3
  67. package/hermes-cli.mjs +5 -3
  68. package/hermes-locks.mjs +5 -2
  69. package/hosted.mjs +2 -1
  70. package/index.d.ts +2 -0
  71. package/index.mjs +1 -1
  72. package/kanban-worker.mjs +63 -13
  73. package/loop.mjs +33 -0
  74. package/mcp/hermes-kanban-tools.json +109 -50
  75. package/mcp/server.mjs +7 -1
  76. package/ontology.d.ts +26 -0
  77. package/orchestration.mjs +15 -4
  78. package/package.json +19 -12
  79. package/session-recovery.mjs +2 -1
  80. package/worker-route.mjs +37 -19
@@ -17,12 +17,18 @@ import { randomBytes } from 'node:crypto';
17
17
  import { actorOf, appendRunObservation, boardKey, cardBranch, cardHandleOf, event as record, json, now } from './store.mjs';
18
18
  import { releaseAllSideSessions, sideSessions } from './side-sessions.mjs';
19
19
  import { evaluate, holds, render, needsSources } from './expr.mjs';
20
- import { completionEvidence, declaredWorkBranches, namedWorkBranch, reviewCandidate } from './ci.mjs';
20
+ import { completionEvidence, declaredWorkBranches, namedWorkBranch, pendingReviewRequests, reviewCandidate } from './ci.mjs';
21
21
  import { branchClaim, cardCommitsOn, isLocalWorkspace, isWorkspacePath, unrecordedMaking } from './workspace.mjs';
22
22
  import { cardName, cardNumber, identifyNotice, numbered } from './notices.mjs';
23
- import { latestEvent } from './queries.mjs';
23
+ import { dispatchWait, latestEvent } from './queries.mjs';
24
+ import { PAUSE_DEADLINE, RESTING_WRITE_MODE, assertRestingWrite, assertBacklogQualified, deadlineUntil } from './resting-state.mjs';
25
+ import { authorizeReplacement } from './session-continuity.mjs';
26
+ import { endedHere } from '../agents.mjs';
24
27
 
25
28
  const loaded = new Map();
29
+ // Authority requests keep their named decision maker; ordinary missing detail stays with the requested act.
30
+ export const ownerRule = message => Object.assign(new Error(message), { ownerRule: true });
31
+ export const requestDetail = message => Object.assign(new Error(message), { boardDetail: true });
26
32
  // the owner's read door holds the workflow the owner reads (owner-door.mjs), told when it is read again
27
33
  const workflowWatchers = new Set();
28
34
  const hold = (root, entry) => {
@@ -76,6 +82,7 @@ export async function loadWorkflow(root) {
76
82
  // What the board's view of a home is decoded from: the workflow, each profile's config, credentials and the agent
77
83
  // layer, persona, and which profiles there are. A session store or a run log changing is no change to it.
78
84
  const DECODED = /^(?:(?:profiles\/[^/]+\/)?(?:config\.yaml|\.env|agents\.json|AGENTS\.md)|workflow\.yaml|profiles(?:\/[^/]+)?)$/;
85
+ const homeFollowers = new Map();
79
86
 
80
87
  /**
81
88
  * Follow the home's own files, so the held view is read again on its next use after one it is decoded from changes:
@@ -85,6 +92,28 @@ const DECODED = /^(?:(?:profiles\/[^/]+\/)?(?:config\.yaml|\.env|agents\.json|AG
85
92
  * Answers a stop.
86
93
  */
87
94
  export function followHome(root, { onChange = () => {}, onLost = () => {} } = {}) {
95
+ let held = homeFollowers.get(root);
96
+ const follower = { onChange, onLost };
97
+ if (!held) {
98
+ held = { followers: new Set([follower]), stop: null };
99
+ homeFollowers.set(root, held);
100
+ try {
101
+ held.stop = watchHome(root, {
102
+ onChange: name => { for (const subscriber of held.followers) subscriber.onChange(name); },
103
+ onLost: error => { for (const subscriber of held.followers) subscriber.onLost(error); },
104
+ });
105
+ } catch (error) { homeFollowers.delete(root); throw error; }
106
+ } else held.followers.add(follower);
107
+ // Preparation and serving share one continuous watch. Attaching the serving callbacks must not invalidate the
108
+ // preparatory read: that read is already newer than this watch, and real file events alone make it stale.
109
+ return () => {
110
+ if (!held.followers.delete(follower) || held.followers.size) return;
111
+ held.stop();
112
+ if (homeFollowers.get(root) === held) homeFollowers.delete(root);
113
+ };
114
+ }
115
+
116
+ function watchHome(root, { onChange, onLost }) {
88
117
  let watcher = null;
89
118
  let stopped = false;
90
119
  // A decoded file that is a link (a workflow kept in another checkout) is watched at its real target too, in the
@@ -236,7 +265,7 @@ function pattern(p) {
236
265
  // running git here on a path of that machine (a Windows path read on a Mac answered no commits for as long as the card
237
266
  // ran: volter-org t_9a9c2a9c). A folder that is not there, a git that did not answer, a machine that could not be asked:
238
267
  // unknown, with why, and never "no commits".
239
- const commitTimes = new Map(); // `${path}:${id}` → { at, known, value, error }
268
+ const commitTimes = new Map(); // `${path}:${id}` → { at, inputs, known, value, error }
240
269
  // A commit names the card by its id or by its keyed number (`VO-856`); a branch is the card's by its handle too.
241
270
  async function readCardCommit(path, id, branches, handle = null) {
242
271
  if (!existsSync(path)) return { known: false, value: null, error: `no folder ${path} on this machine` };
@@ -283,14 +312,16 @@ async function readCardCommit(path, id, branches, handle = null) {
283
312
  async function observeLastCommit(path, id, branches = [], machine = null, handle = null) {
284
313
  if (!path) return null;
285
314
  const key = `${path}:${id}`;
315
+ // Reuse a reading across rounds, while a changed declaration or machine is read immediately.
316
+ const inputs = JSON.stringify([machine, [...new Set(branches)].sort(), handle?.toUpperCase() ?? null]);
286
317
  const cached = commitTimes.get(key);
287
- if (cached && now() - cached.at < 15) return cached.value;
318
+ if (cached?.inputs === inputs && now() - cached.at < 15) return cached.value;
288
319
  let reading;
289
320
  try {
290
321
  if (isLocalWorkspace({ machine })) reading = await readCardCommit(path, id, branches, handle);
291
322
  else reading = { known: true, value: (await cardCommitsOn(machine, path, id, branches, handle?.toUpperCase() ?? null)).at };
292
323
  } catch (error) { reading = { known: false, value: null, error: String(error?.message ?? error).slice(0, 300) }; }
293
- commitTimes.set(key, { at: now(), ...reading });
324
+ commitTimes.set(key, { at: now(), inputs, ...reading });
294
325
  return reading.value;
295
326
  }
296
327
 
@@ -315,13 +346,15 @@ export function sessionProgress(db, task) {
315
346
  const read = path ? commitTimes.get(`${path}:${task.id}`) ?? { known: false, error: 'its checkout has not been read yet' } : { known: true, value: null };
316
347
  return { event: Number(event?.id ?? 0), commit: read.known ? Number(read.value ?? 0) : null, ...(read.known ? {} : { commit_unread: read.error ?? 'its checkout could not be read' }) };
317
348
  }
318
- /** Observe external facts before entering an optimistic board mutation. */
349
+ /** Observe external facts before entering an optimistic board mutation. `commits: true` reads the card's commits
350
+ * fresh, as every decision on them must; `commits: 'cached'` is the idle pass's alone and reuses a reading under 15 s
351
+ * old with the same declaration, so a board of running cards does not spawn git for each card every round. */
319
352
  export async function refreshCardInputs(board, id, { commits = false } = {}) {
320
353
  const task=board.db.prepare('SELECT t.*,c.subtask_of FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.id=?').get(id);
321
354
  if (!task)return;
322
355
  if (commits && !task.subtask_of) {
323
356
  const { path, machine } = commitSource(board.db, task);
324
- if(path)commitTimes.delete(`${path}:${id}`);
357
+ if (path && commits !== 'cached') commitTimes.delete(`${path}:${id}`);
325
358
  await observeLastCommit(path,id,declaredWorkBranches(board.db,id),machine,cardHandleOf(board,id));
326
359
  }
327
360
  }
@@ -471,8 +504,9 @@ function bodyBlocks(body) {
471
504
  return blocks;
472
505
  }
473
506
  /** Whether `body` is `was` with only outcomes ticked: each changed outcome (a checkbox item under the Acceptance heading,
474
- * read whole with its continuation lines) was `- [ ] X` and is now `- [x] X` or `- [x] X: <pointer>`, or was `- [x] X`
475
- * and is now `- [x] X: <pointer>` (the pointer a tick needs, added after it); no other line added, removed or changed. */
507
+ * read whole with its continuation lines) was `- [ ] X` and is now `- [x] X: <pointer>`, or was `- [x] X`
508
+ * and is now `- [x] X: <pointer>` (the pointer a tick needs, added after it). The tick and pointer go on the outcome's
509
+ * own line and its continuation lines stay unchanged; no other line added, removed or changed. */
476
510
  function onlyTicks(was, body) {
477
511
  if (typeof body !== 'string') return false;
478
512
  const before = bodyBlocks(was);
@@ -483,13 +517,18 @@ function onlyTicks(was, body) {
483
517
  const now = after[i];
484
518
  if (block.lines.join('\n') === now.lines.join('\n')) return true;
485
519
  if (block.section !== 'acceptance' || !block.item || !now.item) return false;
486
- const pointed = (ticked) => now.text.startsWith(`${ticked}: `) && now.text.trim().length > ticked.trim().length + 1;
487
- // Outcome prose may contain colons. Preserve the entire old item and permit only an appended pointer.
488
- if (TICKED_OUTCOME.test(block.text)) return pointed(block.text);
489
- const open = OPEN_OUTCOME.exec(block.text);
490
- if (!open) return false;
491
- const ticked = `${open[1]}[x] ${open[2]}`;
492
- return now.text === ticked || pointed(ticked);
520
+ // Outcome prose may contain colons. Preserve the entire old outcome and permit only an appended pointer.
521
+ const ticks = (old, text) => {
522
+ const pointed = (ticked) => text.startsWith(`${ticked}: `) && text.slice(ticked.length + 2).trim().length > 0;
523
+ if (TICKED_OUTCOME.test(old)) return pointed(old);
524
+ const open = OPEN_OUTCOME.exec(old);
525
+ if (!open) return false;
526
+ const ticked = `${open[1]}[x] ${open[2]}`;
527
+ return pointed(ticked);
528
+ };
529
+ // The tick and its pointer go on the outcome's own line, its continuation lines (its `- source:` lines) unchanged,
530
+ // as a bare outcome is ticked.
531
+ return block.lines.slice(1).join('\n') === now.lines.slice(1).join('\n') && ticks(block.lines[0], now.lines[0]);
493
532
  });
494
533
  }
495
534
 
@@ -540,10 +579,10 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {}, lazy
540
579
  const coderProfile = once(() => entry?.profiles?.[codingProfile()]);
541
580
  const configuredCoder = once(() => coderProfile()?.worker);
542
581
  const changes = once(() => json(one(db, "SELECT payload FROM task_events WHERE task_id = ? AND kind = 'changes_requested' ORDER BY id DESC LIMIT 1", task.id)?.payload, {}) ?? {});
543
- const interruption = once(() => json(one(db, `SELECT payload FROM task_events WHERE task_id = ? AND kind IN ('blocked', 'block_loop_detected', 'dependency_wait', 'gave_up',
544
- 'unblocked', 'changes_requested', 'review_reopened', 'status', 'reclaimed', 'stale', 'timed_out', 'crashed', 'spawn_failed', 'rate_limited', 'session_lost')
545
- ORDER BY id DESC LIMIT 1`, task.id)?.payload, {}) ?? {});
546
- const lastBlock = once(() => one(db, "SELECT kind, payload FROM task_events WHERE task_id = ? AND kind IN ('blocked', 'unblocked', 'dependency_wait') ORDER BY id DESC LIMIT 1", task.id));
582
+ const interruption = once(() => json(latestEvent(db, task.id, ['blocked', 'block_loop_detected', 'dependency_wait', 'gave_up',
583
+ 'unblocked', 'changes_requested', 'review_reopened', 'status', 'reclaimed', 'stale', 'timed_out', 'crashed', 'spawn_failed', 'rate_limited', 'session_lost'],
584
+ 'payload')?.payload, {}) ?? {});
585
+ const lastBlock = once(() => latestEvent(db, task.id, ['blocked', 'unblocked', 'dependency_wait'], 'kind,payload'));
547
586
  const waitingOn = once(() => lastBlock()?.kind === 'blocked' ? json(lastBlock().payload, {})?.waiting_on ?? null : null);
548
587
  const currentRun = once(() => task.current_run_id ? one(db, 'SELECT metadata FROM task_runs WHERE id = ?', task.current_run_id) : null);
549
588
  const session = once(() => json(currentRun()?.metadata, {})?.supercode ?? {});
@@ -560,6 +599,7 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {}, lazy
560
599
  status,
561
600
  blocked_by: blocks,
562
601
  paused: json(state.paused, null),
602
+ backlog: json(state.backlog, null),
563
603
  detached: json(state.detached, null),
564
604
  // the person the card is assigned to (custody.mjs personOf), or null: a person's card runs no session and closes on
565
605
  // that person's completion
@@ -592,8 +632,7 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {}, lazy
592
632
  // `paused`: a paused subtask's pause (who and why), or null; a paused subtask holds no review of its arc
593
633
  get subtasks() { return all(db, `SELECT t.id, ${related}, t.title, t.assignee, c.paused FROM tasks t JOIN supercode_cards c ON c.task_id = t.id WHERE c.subtask_of = ? ORDER BY t.created_at`, task.id)
594
634
  .map((t) => ({ ...t, paused: json(t.paused, null) })); },
595
- get queue_reason() { return json(one(db, "SELECT payload FROM task_events WHERE task_id = ? AND kind = 'respawn_guarded' AND created_at >= ? ORDER BY id DESC LIMIT 1",
596
- task.id, Number(one(db, 'SELECT since FROM supercode_cards WHERE task_id = ?', task.id)?.since ?? 0))?.payload, {})?.reason ?? null; },
635
+ get queue_reason() { return dispatchWait(db, task.id)?.reason ?? null; },
597
636
  // the message whose answer the standing block waits for (`block --waiting-on`), and that answer's id once it has landed
598
637
  get waiting_on() { return waitingOn(); },
599
638
  get state_request() { return stateRequestOf(db, task.id); },
@@ -604,7 +643,7 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {}, lazy
604
643
  // whether an edit of its text only ticks outcome lines (the card's own session's door to its acceptance)
605
644
  only_ticks: (body) => onlyTicks(task.body, body),
606
645
  asked_of: () => askedOf(waitingOn()),
607
- get last_block_event() { return one(db, "SELECT kind FROM task_events WHERE task_id = ? AND kind IN ('blocked', 'unblocked') ORDER BY id DESC LIMIT 1", task.id)?.kind ?? null; },
646
+ get last_block_event() { return latestEvent(db, task.id, ['blocked', 'unblocked'], 'kind')?.kind ?? null; },
608
647
  // where the latest interruption says the card resumes (any status the workflow declares); none recorded: null
609
648
  get resume_status() { const value = interruption(); return value.resume_status ?? value.retry_status ?? value.source_status ?? null; },
610
649
  get last_run() { return lastRun() ?? {}; },
@@ -688,7 +727,7 @@ function queueTransitionNotice(db, board, entry, task, from, name, payload, befo
688
727
  if (adopting) return;
689
728
  const after = cardOf(db, task.id), to = statusOf(db, after);
690
729
  const targetRun = beforeRun;
691
- const state = (t, status) => JSON.stringify([status, t.current_run_id, t.paused, t.archived, t.blocked_by]);
730
+ const state = (t, status) => JSON.stringify([status, t.current_run_id, t.paused, t.backlog, t.archived, t.blocked_by]);
692
731
  const beforeState = json(beforeRun?.metadata, {})?.supercode?.state;
693
732
  const afterState = beforeRun && json(one(db, 'SELECT metadata FROM task_runs WHERE id=?', beforeRun.id)?.metadata, {})?.supercode?.state;
694
733
  if (!targetRun || (state(task, from) === state(after, to) && beforeState === afterState) || effects.some((e) => e.kind === 'session_notice' && e.runId === targetRun.id)) return;
@@ -756,24 +795,40 @@ function declaredStatus(db, board, entry, task) {
756
795
  * `{ from, to, effects }`; throws the refusal when no transition applies or a validator fails.
757
796
  */
758
797
  export function validateEvent(db, board, entry, id, name, { roles = [], payload = {}, extra = {}, localOnly = false } = {}) {
759
- // A card ends with its reason: a cancel or archive that says nothing why is refused, whoever sends it, the manager
760
- // included. The reason is all the next reader of an ended card has (an empty-reason cancel once ended an arc that had
761
- // shipped half, and nothing on the card said so).
762
- if ((name === 'cancel' || name === 'archive') && !String(payload?.reason ?? '').trim()) throw new Error(`${id}: ${name} needs a reason (--reason <why>)`);
798
+ // Attribute an omitted reason to the request itself; never invent evidence about the work.
799
+ if ((name === 'cancel' || name === 'archive') && !String(payload?.reason ?? '').trim())
800
+ payload = { ...payload, reason: 'none given' };
763
801
  // A card the document no longer lists (a removal the document cannot make) still takes the manager's cancel or
764
802
  // archive: that ends it the way the document already has, and is the one way a manager clears it.
765
- if (db.boardErrors?.has(id) && !(/cannot change card removal/.test(db.boardErrors.get(id)) && ['cancel', 'archive'].includes(name) && roles.includes('manager'))) throw new Error(`${id}: ${db.boardErrors.get(id)}`);
803
+ if (db.boardErrors?.has(id) && !(/cannot change card removal/.test(db.boardErrors.get(id)) && ['cancel', 'archive'].includes(name) && roles.includes('manager'))) throw requestDetail(`${id}: ${db.boardErrors.get(id)}`);
766
804
  const { workflow, params } = entry;
767
805
  if (workflow.run && (workflow.run.on?.[name] || Object.values(workflow.run.states ?? {}).some((s) => s.on?.[name]))) return null;
768
806
  const task = cardOf(db, id);
769
807
  const from = localOnly ? statusOf(db, task) : declaredStatus(db, board, entry, task);
808
+ // Normalize relative durations on the board clock, before workflow guards see the event.
809
+ if (name === 'pause') {
810
+ if (!roles.includes('manager')) throw ownerRule('only the manager pauses a card');
811
+ assertRestingWrite('paused', payload, task.paused);
812
+ if (RESTING_WRITE_MODE !== 'legacy-holds') payload = { ...payload, until: deadlineUntil(payload.until ?? '1h', now(), 'pause') };
813
+ }
814
+ if (name === 'backlog' || name === 'plan') {
815
+ if (name === 'backlog') assertRestingWrite('backlog', payload);
816
+ if (name === 'backlog' && ['done', 'archived', 'cancelled'].includes(storedAs(workflow, from))) throw requestDetail('an ended card cannot enter backlog');
817
+ if (name === 'backlog' && payload.pause_revision != null && !task.paused) throw requestDetail('backlog migration refused: the named pause is no longer current');
818
+ if (name === 'backlog' && task.paused) {
819
+ const revision = task.paused.generation ?? latestEvent(db, id, ['paused'], 'id')?.id;
820
+ if (revision == null || String(payload.pause_revision ?? '') !== String(revision)) throw requestDetail('backlog migration requires --pause-revision naming the current pause generation (or its legacy paused event id)');
821
+ }
822
+ }
823
+ const expiry = workflow.flags?.paused?.expires;
824
+ if (payload.expired_generation != null && !dueFlagExpiry(task, name, roles, payload, expiry)) throw requestDetail('pause expiry is stale, early, or not authorized by this workflow');
770
825
  if (localOnly && !workflow.statuses[from]) return null; // only fire adopts an imported status
771
826
  const candidates = transitionsFor(workflow, from, name);
772
827
  // a refusal names the right form: each transition's description, its card placeholders filled with this card
773
828
  const said = (t) => t.description ? String(t.description).replaceAll('<arc>', id).replaceAll('<card>', id) : null;
774
829
  if (!candidates.length) {
775
830
  const elsewhere = (workflow.verbs?.[name] ?? []).filter((t) => t.from?.length && t.description);
776
- throw new Error(`${id} is ${from}; ${name.replace(/_/g, '-')} does not apply there.${elsewhere.map((t) => ` From ${t.from.join(' or ')}: ${said(t)}`).join('')}`);
831
+ throw requestDetail(`${id} is ${from}; ${name.replace(/_/g, '-')} does not apply there.${elsewhere.map((t) => ` From ${t.from.join(' or ')}: ${said(t)}`).join('')}`);
777
832
  }
778
833
  // `sender`: the roles the event was sent with (a validator can ask who is asking)
779
834
  const scope = scopeOf(db, board, task, { params, event: payload, lazy: localOnly, deferSources: localOnly, extra: { lanes: lanesIn(entry), ...extra, sender: roles, verb: name } });
@@ -782,7 +837,7 @@ export function validateEvent(db, board, entry, id, name, { roles = [], payload
782
837
  const state = name === 'complete' ? 'done' : name === 'cancel' ? 'cancelled' : null;
783
838
  const request = state && candidates.some(transition => transition.by?.includes('creator'))
784
839
  ? ` Ask the manager with workflow request-state ${id} --state ${state} --reason <why> --evidence <verdict-and-pointers>.` : '';
785
- throw new Error(`${id} is ${from}; ${name.replace(/_/g, '-')} there is for ${[...new Set(candidates.flatMap((t) => t.by ?? []))].join(' or ')}. ${candidates.map(said).filter(Boolean).join(' ')}${request}`);
840
+ throw ownerRule(`${id} is ${from}; ${name.replace(/_/g, '-')} there is for ${[...new Set(candidates.flatMap((t) => t.by ?? []))].join(' or ')}. ${candidates.map(said).filter(Boolean).join(' ')}${request}`);
786
841
  }
787
842
  // A source-dependent branch is undecidable before observation; preflight must not choose its fallback.
788
843
  const needsSource = expr => needsSources(expr, workflow.derived);
@@ -792,14 +847,12 @@ export function validateEvent(db, board, entry, id, name, { roles = [], payload
792
847
  // none of the sender's rules holds, but another role's does (one verb with a rule per case, as `set` has one per
793
848
  // field): it is that role's to send
794
849
  const theirs = taken ? [] : candidates.filter((t) => !allowed.includes(t) && holds(t.guard ?? null, scope));
795
- if (theirs.length) throw new Error(`${id} is ${from}; ${name.replace(/_/g, '-')} there is for ${[...new Set(theirs.flatMap((t) => t.by ?? []))].join(' or ')}. ${theirs.map(said).filter(Boolean).join(' ')}${allowed.length ? ` Yours: ${allowed.map(said).filter(Boolean).join(' ')}` : ''}`);
850
+ if (theirs.length) throw ownerRule(`${id} is ${from}; ${name.replace(/_/g, '-')} there is for ${[...new Set(theirs.flatMap((t) => t.by ?? []))].join(' or ')}. ${theirs.map(said).filter(Boolean).join(' ')}${allowed.length ? ` Yours: ${allowed.map(said).filter(Boolean).join(' ')}` : ''}`);
796
851
  if (!taken) {
797
- const state = scope.card;
798
- const reasons = [state.paused && `paused: ${state.paused.reason ?? 'manager pause'}`,
799
- state.blocked_by.length && `blocked by ${state.blocked_by.map(b => b.id).join(', ')}`,
800
- state.dispatch_hold && 'dispatch held', state.current_run_id && `current run ${state.current_run_id}`,
801
- state.archived && 'archived'].filter(Boolean);
802
- const none = new Error(`${id} is ${from}; ${name.replace(/_/g, '-')} cannot proceed: ${reasons.join('; ') || `guard ${allowed.map(t => t.guard).join(' | ')}`}`);
852
+ // Only the evaluated rules explain a refusal. Incidental state, such as a current run, need not be a condition
853
+ // of any rule and must not be reported as its cause.
854
+ const reasons = allowed.map(t => `failed rule ${t.guard}${said(t) ? ` (${said(t)})` : ''}`);
855
+ const none = new Error(`${id} is ${from}; ${name.replace(/_/g, '-')} cannot proceed: ${reasons.join(' | ')}`);
803
856
  none.noTransition = true; throw none;
804
857
  }
805
858
  for (const v of taken.validate ?? []) {
@@ -822,13 +875,18 @@ export function fire(db, board, entry, id, name, { roles = [], payload = {}, ext
822
875
  if (name === 'specify' && taken.target && (entry.workflow.statuses[from]?.run_role === 'reviewer' || ['done', 'archived'].includes(storedAs(entry.workflow, from))))
823
876
  taken = { ...taken, target: undefined };
824
877
  const to = take(db, board, entry, task, from, taken, scope, effects);
878
+ // A kept reviewer is closed by any set on the card, its end or its move to backlog, whatever its status (ADR 0019).
879
+ const after = cardOf(db, id);
880
+ if (name === 'set' || after.backlog || after.archived || ['done', 'cancelled', 'archived'].includes(storedAs(entry.workflow, statusOf(db, after))))
881
+ closeKeptReviewer(db, id, effects);
825
882
  settle(db, board, entry, id, effects);
826
883
  projectStatus(db, board, entry, id);
827
- // A manager's or creator's explicit act releases a hold, and a run it ends is their decision: the ended-run audit
828
- // never turns it into an incident that waits for a second act
884
+ // Explicit recovery or reconfiguration can release a hold, and a run it ends is their decision: the ended-run
885
+ // audit never turns it into an incident that waits for a second act. Resume clears only its pause, through the
886
+ // same declared actions whether the manager or deadline invokes it; independent dispatch holds remain.
829
887
  const ended = task.current_run_id && one(db, 'SELECT ended_at FROM task_runs WHERE id = ?', task.current_run_id)?.ended_at != null ? task.current_run_id : null;
830
888
  if ((scope.card.dispatch_hold || ended) && roles.some(role => ['manager','creator'].includes(role)) &&
831
- (['resume','reclaim','replace_session','reopen','reopen_review','return_review'].includes(name) || (name === 'set' && ['assignee','machine','model'].includes(payload.field))))
889
+ (['reclaim','replace_session','reopen','reopen_review','return_review'].includes(name) || (name === 'set' && ['assignee','machine','model'].includes(payload.field))))
832
890
  record(db,id,'dispatch_released',{reason:`explicit ${name}`,incident:scope.card.last_events.run_incident?.payload?.run ?? null,...(ended ? { ended_run: ended } : {}),
833
891
  ...(ended && name === 'set' && payload.field === 'assignee' && (payload.value ?? null) !== (task.assignee ?? null)
834
892
  ? { reassigned_from: task.assignee ?? null, reassigned_to: payload.value ?? null, abandon_waiting_input: true } : {})});
@@ -863,7 +921,7 @@ function cardOf(db, id) {
863
921
  const row = one(db, 'SELECT * FROM tasks WHERE id = ?', id);
864
922
  if (!row) throw new Error(`no card ${id}`);
865
923
  const ours = one(db, 'SELECT * FROM supercode_cards WHERE task_id = ?', id);
866
- return { ...row, blocked_by: json(ours?.blocked_by, []) ?? [], paused: json(ours?.paused, null), detached: json(ours?.detached, null), archived: Boolean(ours?.archived), skills: json(row.skills, []) ?? [], machine: ours?.machine ?? null, subtask_of: ours?.subtask_of ?? null };
924
+ return { ...row, blocked_by: json(ours?.blocked_by, []) ?? [], paused: json(ours?.paused, null), backlog: json(ours?.backlog, null), detached: json(ours?.detached, null), archived: Boolean(ours?.archived), skills: json(row.skills, []) ?? [], machine: ours?.machine ?? null, subtask_of: ours?.subtask_of ?? null };
867
925
  }
868
926
 
869
927
  // one transition: the old status's exit actions, the move, the transition's actions, the new status's entry actions
@@ -872,7 +930,6 @@ function take(db, board, entry, task, from, transition, scope, effects) {
872
930
  const target = transition.target ? String(templateValue(transition.target, scope) ?? '') : null;
873
931
  if (target && !workflow.statuses[target]) throw new Error(`the workflow moves ${task.id} to ${target}, which it does not declare`);
874
932
  const moves = target && target !== from;
875
- if (moves && scope.card.paused) throw new Error('a paused card must be resumed before its stage can change');
876
933
  const ctx = { db, board, entry, id: task.id, scope: { ...scope, before: scope.card }, from, to: target ?? from, effects, runId: task.current_run_id ?? null };
877
934
  if (moves) for (const a of workflow.statuses[from].exit ?? []) act(ctx, a);
878
935
  if (moves) setStatus(db, workflow, task.id, target);
@@ -880,16 +937,34 @@ function take(db, board, entry, task, from, transition, scope, effects) {
880
937
  if (moves) {
881
938
  for (const a of workflow.statuses[target].entry ?? []) act(ctx, a);
882
939
  if (workflow.statuses[target].run || workflow.statuses[target].run_role) effects.push({ kind: 'run', id: task.id, status: target });
940
+ // a card's final close (done, cancelled, archived) retires the workspace the board made for it, whatever the
941
+ // workflow declares: removed only when that loses nothing, else kept and described on the card (engine cleanupWorkspace)
942
+ if (['done', 'cancelled', 'archived'].includes(storedAs(workflow, target))) queueCleanup(db, task.id);
883
943
  }
884
944
  return target ?? from;
885
945
  }
886
946
 
947
+ /** Card `id`'s workspace cleanup recorded once for the delivery pass (engine runEffects), which reads its machine's git
948
+ * outside any verb or round and tries again while that machine cannot answer; a card not closed when it runs is left.
949
+ * Its kind is `retire_workspace`, which a reader from before this rule does not know and drops unread: that reader's
950
+ * `cleanup_workspace` forces the worktree's removal, so a rollback leaves the folder rather than losing what is in it. */
951
+ export function queueCleanup(db, id, { scratch = false } = {}) {
952
+ // one row per card: a workflow that declares cleanup_workspace also has its card's board-made scratch folder removed
953
+ // (engine cleanupWorkspace), so its ask turns a pending row's scratch on rather than queueing a second retirement
954
+ const plain = JSON.stringify({ kind: 'retire_workspace', id }), withScratch = JSON.stringify({ kind: 'retire_workspace', id, scratch: true });
955
+ if (one(db, 'SELECT 1 FROM supercode_effects WHERE effect = ? AND failed_at IS NULL', withScratch)) return;
956
+ const pending = one(db, 'SELECT id FROM supercode_effects WHERE effect = ? AND failed_at IS NULL', plain);
957
+ if (pending) { if (scratch) run(db, 'UPDATE supercode_effects SET effect = ? WHERE id = ?', withScratch, pending.id); return; }
958
+ run(db, 'INSERT INTO supercode_effects (effect) VALUES (?)', scratch ? withScratch : plain);
959
+ }
960
+
887
961
  /** Put the card in `status`: kanban.db holds its `stored_as`, our table the exact name where they differ. */
888
962
  export function setStatus(db, workflow, id, status) {
889
963
  const stored = storedAs(workflow, status);
890
964
  run(db, 'UPDATE tasks SET status = ? WHERE id = ?', stored, id);
891
965
  run(db, `INSERT INTO supercode_cards (task_id, stage, since, after_fired) VALUES (?, ?, ?, NULL)
892
966
  ON CONFLICT(task_id) DO UPDATE SET stage = excluded.stage, since = excluded.since, after_fired = NULL`, id, stored === status ? null : status, now());
967
+ record(db, id, 'stage_entered', { status });
893
968
  // an arc that ends releases its side sessions with it (side-sessions.mjs)
894
969
  if (['done', 'archived'].includes(stored) && !db.prepare('SELECT subtask_of FROM supercode_cards WHERE task_id = ?').get(id)?.subtask_of) releaseAllSideSessions(db, id, `${id} is ${status}`);
895
970
  }
@@ -902,6 +977,11 @@ export function settle(db, board, entry, id, effects = []) {
902
977
  for (let hops = 0; hops < 16; hops += 1) {
903
978
  const task = cardOf(db, id);
904
979
  const from = statusOf(db, task);
980
+ // Backlog freezes runnable lifecycle movement, while relations and terminal cleanup still settle. An ended
981
+ // parent may retain its backlog attribution, but its open children must resume/cancel through their workflow.
982
+ if (task.backlog && !['done', 'archived'].includes(storedAs(workflow, from))) {
983
+ projectStatus(db, board, entry, id); return effects;
984
+ }
905
985
  const state = workflow.statuses[from];
906
986
  // An unchanged card with no automatic/timed moves needs no history scope at all. The organization's five
907
987
  // statuses have none, but recomputeReady used to read every history field on every unsettled card anyway.
@@ -944,6 +1024,42 @@ function dueAfter(db, workflow, task, status, scope) {
944
1024
  return null;
945
1025
  }
946
1026
 
1027
+ // Expiry is a narrowly delegated ordinary event, not platform authority to clear arbitrary flags.
1028
+ function dueFlagExpiry(task, name, roles, payload, expiry) {
1029
+ const pause = task.paused;
1030
+ return Boolean(expiry?.at === 'card.paused.until' && expiry.event === name && roles.includes('platform') && pause?.generation
1031
+ && payload.expired_generation === pause.generation && Number.isSafeInteger(pause.until) && pause.until <= now());
1032
+ }
1033
+
1034
+ /** Indexed current deadlines only; called transactionally before admission and idle observation. */
1035
+ export function settleExpiringFlags(db, board, entry, effects = []) {
1036
+ const expiry = entry.workflow.flags?.paused?.expires;
1037
+ if (!expiry) return 0;
1038
+ if (expiry.at !== 'card.paused.until' || !expiry.event) throw new Error('paused.expires must name card.paused.until and its ordinary resume event');
1039
+ let count = 0;
1040
+ for (;;) {
1041
+ const due = all(db, `SELECT task_id,paused FROM supercode_cards WHERE paused IS NOT NULL AND ${PAUSE_DEADLINE} <= ?
1042
+ AND json_type(paused,'$.until')='integer' AND length(json_extract(paused,'$.generation'))>0 ORDER BY ${PAUSE_DEADLINE},task_id LIMIT 64`, now());
1043
+ if (!due.length) break;
1044
+ for (const row of due) {
1045
+ const pause = json(row.paused, null);
1046
+ const result = fire(db, board, entry, row.task_id, expiry.event, { roles: ['platform'], payload: {
1047
+ expired_generation: pause.generation, reason: `pause expired at ${new Date(pause.until * 1000).toISOString()}`,
1048
+ } });
1049
+ if (cardOf(db, row.task_id).paused) throw new Error(`${row.task_id}: declared pause expiry did not clear its flag`);
1050
+ effects.push(...result.effects); count++;
1051
+ }
1052
+ }
1053
+ return count;
1054
+ }
1055
+
1056
+ /** The next current deadline drives the owner's wake even when launches are disabled. */
1057
+ export function nextFlagExpiry(db, entry) {
1058
+ if (!entry.workflow.flags?.paused?.expires) return null;
1059
+ return one(db, `SELECT ${PAUSE_DEADLINE} AS at FROM supercode_cards WHERE paused IS NOT NULL AND ${PAUSE_DEADLINE} IS NOT NULL
1060
+ AND json_type(paused,'$.until')='integer' AND length(json_extract(paused,'$.generation'))>0 ORDER BY ${PAUSE_DEADLINE},task_id LIMIT 1`)?.at ?? null;
1061
+ }
1062
+
947
1063
  /**
948
1064
  * Where a new card starts: the first `initial` transition that holds (`event` is the create's own words), its status
949
1065
  * set and its actions run (a goal card by the workflow's default, for one). Answers the status. A validator that fails
@@ -982,6 +1098,11 @@ function act(ctx, action) {
982
1098
  // what happens is the workflow's. A kind with no entry here carries the event's own fields.
983
1099
  const firstLine = (text) => String(text ?? '').trim().split('\n')[0].slice(0, 400) || null;
984
1100
  const FORMAT = {
1101
+ paused: ({ e, after }) => ({ reason: after.paused?.reason ?? e.reason, until: after.paused?.until, generation: after.paused?.generation, by: after.paused?.by }),
1102
+ pause_extended: ({ before, after }) => ({ ...after.paused, previous: before.paused }),
1103
+ resumed: ({ e, before }) => ({ reason: e.reason ?? null, pause: before.paused, cause: e.expired_generation ? 'expired' : 'manager', ...(e.expired_generation ? { expired_generation: e.expired_generation } : {}) }),
1104
+ backlogged: ({ after, before }) => ({ ...after.backlog, previous_lane: before.status }),
1105
+ planned: ({ e, before }) => ({ reason: e.reason ?? null, backlog: before.backlog }),
985
1106
  completed: ({ e }) => {
986
1107
  const artifacts = Array.isArray(e.metadata?.artifacts) ? e.metadata.artifacts.filter((a) => typeof a === 'string' && a.trim()) : [];
987
1108
  return { ci: e.ci ?? null, result_len: e.result ? String(e.result).length : 0, summary: firstLine(e.summary ?? e.result), ...(artifacts.length ? { artifacts } : {}) };
@@ -990,15 +1111,17 @@ const FORMAT = {
990
1111
  block_loop_detected: ({ e, after, source, params }) => ({ reason: e.reason ?? null, kind: e.kind ?? null, recurrences: Number(after.block_recurrences || 0), limit: params.block_recurrence_limit, source_status: source }),
991
1112
  dependency_wait: ({ e, source }) => ({ reason: e.reason ?? null, on: e.on ?? null, source_status: source }),
992
1113
  review_requested: ({ e, before, after }) => ({ summary: firstLine(e.summary), ci: e.ci ?? null, pr: e.ci?.pr ?? null, commit: e.ci?.commit ?? null,
1114
+ requested_pr: e.pr ?? null, requested_pr_head: e.pr_head ?? null,
993
1115
  implementer: before.assignee, implementer_machine: before.machine ?? null, implementer_model: before.implementer_model ?? null,
994
1116
  implementer_model_override: before.model_override ?? null, implementer_provider_override: before.provider_override ?? null, reviewer: after.assignee }),
995
1117
  // the reviewer is the lane that reviewed (the card's assignee while in review), unless the verdict names one
996
1118
  changes_requested: ({ e, before, after }) => ({ reason: e.reason ?? null, reviewer: e.reviewer ?? before.assignee ?? null, implementer: after.assignee }),
997
1119
  review_reopened: ({ after, to }) => ({ status: to, implementer: after.assignee }),
998
1120
  // the manager's return of a review with no verdict: the request it returned and its run, so the next review's selection
999
- // reads which heads it named (ci.mjs returnedReview)
1121
+ // reads which heads it named (ci.mjs returnedReview); a reopen-review return names the re-declaration it answered
1122
+ // (`redeclared`), and holds none
1000
1123
  review_returned: ({ e, before, after, to }) => ({ reason: e.reason ?? null, status: to, review_request: before.last_events.review_requested?.id ?? null,
1001
- review_run: before.current_run_id ?? null, implementer: after.assignee }),
1124
+ review_run: before.current_run_id ?? null, implementer: after.assignee, ...(e.redeclared ? { redeclared: e.redeclared } : {}) }),
1002
1125
  review_voided: ({ e, before }) => ({ reason: e.reason, review_request: before.last_events.review_requested?.id ?? null,
1003
1126
  review_run: before.latest_review?.id ?? null, verdict: Math.max(before.last_events.review_pass?.id ?? 0, before.last_events.changes_requested?.id ?? 0) || null }),
1004
1127
  // what arrived (an unblock says it), and where the card resumes
@@ -1027,14 +1150,14 @@ const ACTIONS = {
1027
1150
  let blocks = json(row?.blocked_by, []) ?? [];
1028
1151
  if (add) {
1029
1152
  const id = String(add);
1030
- if (id === ctx.id) throw new Error('a card cannot block itself');
1153
+ if (id === ctx.id) throw requestDetail('a card cannot block itself');
1031
1154
  const kind = id.startsWith('m-') ? 'message' : 'card';
1032
- if (kind === 'card' && !one(ctx.db, 'SELECT id FROM tasks WHERE id = ?', id)) throw new Error(`no blocking card ${id}`);
1155
+ if (kind === 'card' && !one(ctx.db, 'SELECT id FROM tasks WHERE id = ?', id)) throw requestDetail(`no blocking card ${id}`);
1033
1156
  if (kind === 'card') {
1034
1157
  const seen = new Set(); const pending = [id];
1035
1158
  while (pending.length) {
1036
1159
  const node = pending.pop();
1037
- if (node === ctx.id) throw new Error('blocking relation would create a cycle');
1160
+ if (node === ctx.id) throw requestDetail('blocking relation would create a cycle');
1038
1161
  if (seen.has(node)) continue;
1039
1162
  seen.add(node);
1040
1163
  const related = json(one(ctx.db, 'SELECT blocked_by FROM supercode_cards WHERE task_id = ?', node)?.blocked_by, []) ?? [];
@@ -1044,12 +1167,12 @@ const ACTIONS = {
1044
1167
  if (!blocks.some((b) => b.id === id)) blocks.push({ id, kind, reason, by: actorOf(), since: now() });
1045
1168
  }
1046
1169
  if (remove) {
1047
- if (!String(reason ?? '').trim()) throw new Error('unblock must say what arrived');
1170
+ if (!String(reason ?? '').trim()) throw requestDetail('unblock must say what arrived');
1048
1171
  let resolved = remove;
1049
1172
  if (remove !== '*' && remove !== 'messages') {
1050
1173
  const matches = blocks.filter(b => b.id === remove || (b.kind === 'message' && String(remove).startsWith('m-') &&
1051
1174
  (String(remove).startsWith(b.id) || b.id.startsWith(String(remove)))));
1052
- if (matches.length !== 1) throw new Error(matches.length ? `ambiguous dependency ${remove}; name one of ${matches.map(b => b.id).join(', ')}` : `no dependency ${remove}; current blockers: ${blocks.map(b => b.id).join(', ') || 'none'}`);
1175
+ if (matches.length !== 1) throw requestDetail(matches.length ? `ambiguous dependency ${remove}; name one of ${matches.map(b => b.id).join(', ')}` : `no dependency ${remove}; current blockers: ${blocks.map(b => b.id).join(', ') || 'none'}`);
1053
1176
  resolved = matches[0].id;
1054
1177
  }
1055
1178
  blocks = blocks.filter((b) => resolved === 'messages' ? b.kind !== 'message' : resolved !== '*' && b.id !== resolved);
@@ -1059,14 +1182,27 @@ const ACTIONS = {
1059
1182
  run(ctx.db, 'DELETE FROM task_links WHERE child_id = ?', ctx.id);
1060
1183
  for (const block of blocks.filter((b) => b.kind === 'card')) run(ctx.db, 'INSERT OR IGNORE INTO task_links (parent_id, child_id) VALUES (?, ?)', block.id, ctx.id);
1061
1184
  },
1062
- flag(ctx, { name, value, reason = null }) {
1063
- if (!['paused', 'archived'].includes(name)) throw new Error(`unknown flag ${name}`);
1185
+ flag(ctx, { name, value, reason = null, until = null }) {
1186
+ if (!['paused', 'backlog', 'archived'].includes(name)) throw requestDetail(`unknown flag ${name}`);
1064
1187
  if (name === 'paused') {
1065
1188
  const was = json(one(ctx.db, 'SELECT paused FROM supercode_cards WHERE task_id = ?', ctx.id)?.paused, null);
1066
1189
  const clearBy = ctx.entry.workflow.flags?.paused?.clear_by ?? ['pauser', 'owner'];
1067
- if (!value && was && !clearBy.some((role) => role === 'pauser' ? was.by === actorOf() : ctx.scope.sender?.includes(role))) throw new Error(`only ${clearBy.join(' or ')} may resume this card; ask the manager with your reason`);
1068
- if (value && !String(reason ?? '').trim()) throw new Error('pause needs a reason');
1069
- cardField(ctx, name, value ? JSON.stringify({ by: actorOf(), reason }) : null);
1190
+ const expiry = dueFlagExpiry({ paused: was }, ctx.scope.verb, ctx.scope.sender ?? [], ctx.scope.event ?? {}, ctx.entry.workflow.flags?.paused?.expires);
1191
+ if (!value && was && !expiry && !clearBy.some((role) => role === 'pauser' ? was.by === actorOf() : ctx.scope.sender?.includes(role))) throw ownerRule(`only ${clearBy.join(' or ')} may resume this card; ask the manager with your reason`);
1192
+ if (value && !ctx.scope.sender?.includes('manager')) throw ownerRule('only the manager pauses a card');
1193
+ if (value && (typeof reason !== 'string' || !reason.trim())) throw requestDetail('pause needs a reason');
1194
+ if (value) assertRestingWrite(name, { until: until ?? ctx.scope.event?.until }, was);
1195
+ cardField(ctx, name, value ? JSON.stringify(RESTING_WRITE_MODE === 'legacy-holds' ? { by: actorOf(), reason }
1196
+ : { by: actorOf(), reason, until: deadlineUntil(until ?? ctx.scope.event?.until, now(), 'pause'), generation: randomBytes(16).toString('hex') }) : null);
1197
+ } else if (name === 'backlog') {
1198
+ if (value && !String(reason ?? '').trim()) throw requestDetail('backlog needs a reason');
1199
+ if (value) assertRestingWrite(name, {});
1200
+ const backlog = value ? { by: actorOf(), reason, since: now() } : null;
1201
+ if (value) assertBacklogQualified(ctx.db, ctx.id, { backlog });
1202
+ cardField(ctx, name, backlog ? JSON.stringify(backlog) : null);
1203
+ if (value && ctx.scope.before.paused) ctx.effects.push(...fire(ctx.db, ctx.board, ctx.entry, ctx.id, 'resume', {
1204
+ roles: ['manager'], payload: { reason: 'manager migration from parked pause to backlog' },
1205
+ }).effects);
1070
1206
  } else cardField(ctx, name, Number(Boolean(value)));
1071
1207
  },
1072
1208
  every(ctx, interval) {
@@ -1105,15 +1241,15 @@ const ACTIONS = {
1105
1241
  },
1106
1242
  assign(ctx, { field, value }) {
1107
1243
  if (field === 'reviewer') {
1108
- if (value != null && (typeof value !== 'string' || !ctx.entry.profiles?.[value]?.worker?.harness)) throw new Error('A card reviewer names a runnable profile in this home.');
1244
+ if (value != null && (typeof value !== 'string' || !ctx.entry.profiles?.[value]?.worker?.harness)) throw requestDetail('A card reviewer names a runnable profile in this home.');
1109
1245
  record(ctx.db, ctx.id, 'reviewer_set', { reviewer: value ?? null }, ctx.runId);
1110
1246
  return;
1111
1247
  }
1112
1248
  if (field === 'machine') {
1113
1249
  const task = one(ctx.db, 'SELECT t.current_run_id,t.workspace_kind,c.workspace_anchor,c.machine FROM tasks t JOIN supercode_cards c ON c.task_id=t.id WHERE t.id=?', ctx.id);
1114
- if (task.current_run_id) throw new Error('End the current run before moving its card.');
1250
+ if (task.current_run_id) throw requestDetail('End the current run before moving its card.');
1115
1251
  if ((value ?? null) === task.machine) return;
1116
- if (task.workspace_kind !== 'scratch' && (!task.workspace_anchor || isWorkspacePath(task.workspace_anchor))) throw new Error('declare the repository and branch with workflow specify --workspace before moving a legacy path card');
1252
+ if (task.workspace_kind !== 'scratch' && (!task.workspace_anchor || isWorkspacePath(task.workspace_anchor))) throw requestDetail('declare the repository and branch with workflow specify --workspace before moving a legacy path card');
1117
1253
  ACTIONS.forget_session(ctx, '*');
1118
1254
  // a worktree the card made with only its path recorded (workspace.mjs unrecordedMaking) is recorded as made before
1119
1255
  // the move clears the path, so the branch it made stays its own on the new machine
@@ -1124,7 +1260,7 @@ const ACTIONS = {
1124
1260
  cardField(ctx, 'machine', value ?? null);
1125
1261
  return;
1126
1262
  }
1127
- if (!ASSIGNABLE.has(field)) throw new Error(`the workflow assigns ${field}, which is not a card field it may set`);
1263
+ if (!ASSIGNABLE.has(field)) throw requestDetail(`the workflow assigns ${field}, which is not a card field it may set`);
1128
1264
  if (field === 'assignee') {
1129
1265
  // A model override names a model of the harness it was chosen for; a lane of another harness cannot run it
1130
1266
  // (a Claude model left on a Codex lane fails every turn), so it goes with the old lane, on the record
@@ -1169,6 +1305,8 @@ const ACTIONS = {
1169
1305
  // the card's own coder bound as its reviewer leaves the reviewer slot unclosed (handBackCoder), here or earlier in
1170
1306
  // this move (its close_session); the run being ended is the one it was handed back from
1171
1307
  const handed = handBackCoder(ctx.db, ctx.id, runId) || handedBackFrom(json(one(ctx.db, 'SELECT metadata FROM task_runs WHERE id = ?', runId)?.metadata, {})?.supercode, runId);
1308
+ // the arc's reviewer keeps its session while a review is pending: the next review is its next round's brief
1309
+ if (close_session && reviewerKept(ctx, runId)) close_session = false;
1172
1310
  if (!handed) runSession(ctx, session => ({ ...session, retain_after_run: !close_session }));
1173
1311
  // A declared completion may end the attempt while retaining its role session. In particular, its own caller
1174
1312
  // must be able to finish and return before the board starts the independent review.
@@ -1186,13 +1324,14 @@ const ACTIONS = {
1186
1324
  run(ctx.db, 'UPDATE tasks SET current_run_id = NULL, claim_lock = NULL, claim_expires = NULL, worker_pid = NULL WHERE id = ?', ctx.id);
1187
1325
  },
1188
1326
  claim(ctx, role, scope) {
1189
- if (detachedCard(ctx.db, ctx.id)) throw new Error('a detached card must receive a verified adoption before starting another run');
1327
+ if (detachedCard(ctx.db, ctx.id)) throw requestDetail('a detached card must receive a verified adoption before starting another run');
1190
1328
  const task = cardOf(ctx.db, ctx.id);
1329
+ if (task.backlog) throw requestDetail('a backlog card cannot claim a run; the manager must plan it first');
1191
1330
  const t = now();
1192
1331
  const ttl = Number(ctx.entry.params.claim_ttl_seconds) || 900;
1193
- if (ctx.entry.workflow.run && (task.subtask_of || scope.card.paused || scope.card.archived || scope.card.blocked_by.length)) throw new Error('this card cannot run while blocked, paused, archived, or a subtask');
1332
+ if (ctx.entry.workflow.run && (task.subtask_of || scope.card.paused || scope.card.archived || scope.card.blocked_by.length)) throw requestDetail('this card cannot run while blocked, paused, archived, or a subtask');
1194
1333
  const lock = ctx.scope.event?.lock;
1195
- if (!lock) throw new Error('a claim needs the dispatcher\'s lock');
1334
+ if (!lock) throw requestDetail('a claim needs the dispatcher\'s lock');
1196
1335
  if (task.current_run_id) {
1197
1336
  run(ctx.db, "UPDATE task_runs SET status = 'reclaimed', outcome = 'reclaimed', summary = COALESCE(summary, 'invariant recovery on re-claim'), ended_at = ?, claim_lock = NULL, claim_expires = NULL, worker_pid = NULL WHERE id = ? AND ended_at IS NULL", t, task.current_run_id);
1198
1337
  }
@@ -1227,7 +1366,7 @@ const ACTIONS = {
1227
1366
  given ? JSON.stringify({ ...was, ...given, ...(was.supercode ? { supercode: was.supercode } : {}) }) : (Object.keys(was).length ? JSON.stringify(was) : null), ctx.runId);
1228
1367
  },
1229
1368
  link(ctx, parent) {
1230
- if (!one(ctx.db, 'SELECT id FROM tasks WHERE id = ?', parent)) throw new Error(`${parent} is no card on this board`);
1369
+ if (!one(ctx.db, 'SELECT id FROM tasks WHERE id = ?', parent)) throw requestDetail(`${parent} is no card on this board`);
1231
1370
  run(ctx.db, 'INSERT OR IGNORE INTO task_links (parent_id, child_id) VALUES (?, ?)', parent, ctx.id);
1232
1371
  },
1233
1372
  unlink(ctx, parent) {
@@ -1236,13 +1375,14 @@ const ACTIONS = {
1236
1375
  specify(ctx, said) {
1237
1376
  if (said?.workspace) {
1238
1377
  const task = cardOf(ctx.db, ctx.id), ws = said.workspace;
1239
- if (task.current_run_id) throw new Error('end the current run before changing its workspace');
1240
- const moves = Object.hasOwn(ws, 'machine');
1241
- // A retarget lets go of the card's sessions, but never of the arc's own coder while it is open (openCoder): its
1242
- // next run, a review's return included, is delivered into that session, never a fresh one started beside it
1243
- // (VO-3755 t_c2d5d21b: a retarget forgot adopted coder c46c2896 at 06:51:01Z on 2026-10-27, and the run after its
1244
- // review's changes_requested, 2736, launched ee91b4d8 beside it). A move to another machine lets it go.
1245
- forgetSessions(ctx, '*', null, moves ? null : openCoder(ctx.db, ctx.id));
1378
+ if (task.current_run_id) throw requestDetail('end the current run before changing its workspace');
1379
+ const moves = Object.hasOwn(ws, 'machine') && (ws.machine ?? null) !== (task.machine ?? null);
1380
+ // A same-machine retarget changes work, not conversation identity. Only an actual admitted machine move
1381
+ // authorizes replacement; stopped coder and reviewer conversations are retained too (ADR 0248).
1382
+ if (moves) {
1383
+ if (ws.kind !== 'scratch' && (!ws.repository || isWorkspacePath(ws.repository))) throw requestDetail('a machine move requires a portable repository declaration');
1384
+ forgetSessions(ctx, '*', null);
1385
+ }
1246
1386
  const prior = one(ctx.db, 'SELECT workspace_anchor FROM supercode_cards WHERE task_id=?', ctx.id);
1247
1387
  record(ctx.db, ctx.id, 'workspace_changed', { from: { kind: task.workspace_kind, repository: prior?.workspace_anchor, branch: task.branch_name, ...(moves ? { machine: task.machine } : {}) }, to: { kind: ws.kind, repository: ws.repository, branch: ws.branch, ...(moves ? { machine: ws.machine } : {}) } }, null);
1248
1388
  run(ctx.db, 'UPDATE tasks SET workspace_kind=?,workspace_path=?,branch_name=? WHERE id=?', ws.kind, ws.path ?? null, ws.branch ?? null, ctx.id);
@@ -1293,7 +1433,7 @@ const ACTIONS = {
1293
1433
  comment(ctx, body) {
1294
1434
  run(ctx.db, 'INSERT INTO task_comments (task_id, author, body, created_at) VALUES (?, ?, ?, ?)', ctx.id, 'dispatcher', String(body), now());
1295
1435
  },
1296
- cleanup_workspace(ctx) { ctx.effects.push({ kind: 'cleanup_workspace', id: ctx.id }); },
1436
+ cleanup_workspace(ctx) { queueCleanup(ctx.db, ctx.id, { scratch: true }); },
1297
1437
  stop_run(ctx) { ctx.effects.push({ kind: 'stop_run', id: ctx.id, runId: ctx.runId }); },
1298
1438
  // A review's pass is the work locked in: the dispatcher merges every PR the verdict judged, at the head it judged
1299
1439
  // (dispatch.mjs mergeReviewed), and sends the card `reviewed_merged` once all are, or `merge_refused` when a head moved.
@@ -1306,6 +1446,7 @@ const ACTIONS = {
1306
1446
  // the run leaving the status is the one whose session closes, never a run started after it (a reclaim's successor)
1307
1447
  close_session(ctx, slot) {
1308
1448
  const through = ctx.runId ?? one(ctx.db, 'SELECT MAX(id) AS id FROM task_runs WHERE task_id = ?', ctx.id)?.id ?? 0;
1449
+ if (slot === 'reviewer' && reviewerKept(ctx)) return;
1309
1450
  requestSessionClose(ctx.db, ctx.id, { slot, through });
1310
1451
  ctx.effects.push({ kind: 'close_session', id: ctx.id, slot, runId: through });
1311
1452
  },
@@ -1332,7 +1473,7 @@ const ACTIONS = {
1332
1473
  // a question from the board to a session, sent under a message id chosen now: `{{asked}}` in the actions after it is
1333
1474
  // that id (what a block waits on), and the answer to it lands in the board's mailbox
1334
1475
  ask(ctx, { to, text }) {
1335
- if (!to) throw new Error(`the workflow asks nobody about ${ctx.id} (ask.to is empty)`);
1476
+ if (!to) throw requestDetail(`the workflow asks nobody about ${ctx.id} (ask.to is empty)`);
1336
1477
  const id = `m-${randomBytes(12).toString('hex')}`;
1337
1478
  ctx.scope.asked = id;
1338
1479
  const batch = ctx.scope.verb === 'request_state' ? {
@@ -1395,13 +1536,40 @@ export function addDependency(db, entry, id, parent) {
1395
1536
  }
1396
1537
 
1397
1538
  /** Persist close intent in the same transaction as the transition; I/O can fail or never run. */
1539
+ /** The verdicts that keep the arc's reviewer: its own changes requested, pass or complete (ADR 0019). */
1540
+ const VERDICT_VERBS = new Set(['request_changes', 'request_state', 'complete']);
1541
+ /** Whether the arc's reviewer keeps its session as its run ends (`runId`, else its reviewer slot): only its own verdict,
1542
+ * while a review is pending, so the next one is delivered to that same session as its round's brief (ADR 0019).
1543
+ * Every other end of its run closes it. */
1544
+ function reviewerKept(ctx, runId = null) {
1545
+ if (!VERDICT_VERBS.has(ctx.scope?.verb) || !ctx.scope?.sender?.includes('reviewer')) return false;
1546
+ if (runId != null) {
1547
+ const session = json(one(ctx.db, 'SELECT metadata FROM task_runs WHERE id = ?', runId)?.metadata, {})?.supercode;
1548
+ if (session?.role !== 'reviewer' && session?.slot !== 'reviewer') return false;
1549
+ }
1550
+ return pendingReviewRequests(ctx.db, ctx.id).length > 0;
1551
+ }
1552
+
1553
+ /** Close the arc's kept reviewer (ADR 0019): the latest reviewer-slot run whose session its verdict kept open. */
1554
+ export function closeKeptReviewer(db, id, effects = null) {
1555
+ for (const row of all(db, 'SELECT id, metadata FROM task_runs WHERE task_id = ? AND ended_at IS NOT NULL ORDER BY id DESC', id)) {
1556
+ const session = json(row.metadata, {})?.supercode;
1557
+ if ((session?.slot ?? 'worker') !== 'reviewer') continue;
1558
+ if (session.retain_after_run && !session.close_requested && !session.close_confirmed_at && !session.forgotten && !session.close_transferred_to) {
1559
+ requestSessionClose(db, id, { runId: row.id });
1560
+ effects?.push({ kind: 'close_session', id, slot: 'reviewer', runId: row.id });
1561
+ }
1562
+ return;
1563
+ }
1564
+ }
1565
+
1398
1566
  export function requestSessionClose(db, id, { runId = null, slot = null, through = null } = {}) {
1399
1567
  for (const row of all(db, 'SELECT id, metadata FROM task_runs WHERE task_id = ?', id)) {
1400
1568
  if ((runId != null && row.id !== runId) || (through != null && row.id > through)) continue;
1401
1569
  const whole = json(row.metadata, {}) ?? {};
1402
1570
  const session = whole.supercode;
1403
1571
  if (detachedSession(db, session)) continue;
1404
- if ((!session?.pane && !session?.launch_key && !(session?.surface === 'native' && session.session_id)) || session.close_transferred_to || session.close_confirmed_at || (slot != null && (session.slot ?? 'worker') !== slot)) continue;
1572
+ if ((!session?.pane && !session?.launch_key && !(session?.surface === 'native' && session.session_id) && !(session?.surface === 'headless' && session.pid)) || session.close_transferred_to || session.close_confirmed_at || (slot != null && (session.slot ?? 'worker') !== slot)) continue;
1405
1573
  // A reviewer binding of the card's own coder is never the review's to close (handBackCoder)
1406
1574
  if ((session.slot ?? 'worker') === 'reviewer' && handBackCoder(db, id, row.id)) continue;
1407
1575
  if (session.close_requested && !session.closed_at) continue;
@@ -1413,15 +1581,38 @@ export function requestSessionClose(db, id, { runId = null, slot = null, through
1413
1581
 
1414
1582
  /**
1415
1583
  * Let go of card `ctx.id`'s sessions in slot `name` ('*': every slot), those of another harness than `harness` when it is
1416
- * given: each is asked to close and is never continued; the slot's next run starts a new session. `keep` names a worker
1417
- * session that stays the arc's (openCoder): it is neither closed nor forgotten, so its next run is delivered into it.
1584
+ * given. Only the retained deliberate configuration/replacement decision permits its successor to launch.
1418
1585
  */
1419
- function forgetSessions(ctx, name, harness, keep = null) {
1586
+ function forgetSessions(ctx, name, harness) {
1587
+ const verb = ctx.scope.verb, said = ctx.scope.event ?? {};
1588
+ const manager = ctx.scope.sender?.some(role => ['manager', 'owner'].includes(role));
1589
+ const machineChange = verb === 'set' && said.field === 'machine' && (said.value ?? null) !== (ctx.scope.before.machine ?? null);
1590
+ const workspaceMove = verb === 'specify' && Object.hasOwn(said.workspace ?? {}, 'machine')
1591
+ && (said.workspace.machine ?? null) !== (ctx.scope.before.machine ?? null);
1592
+ const harnessChange = verb === 'set' && said.field === 'assignee' && harness
1593
+ && ctx.entry.profiles?.[ctx.scope.before.assignee]?.worker?.harness !== harness;
1594
+ const replacement = verb === 'replace_session';
1595
+ if (!machineChange && !workspaceMove && !harnessChange && !replacement) return;
1596
+ if (!manager) throw new Error('only a deliberate manager or owner decision may replace an established role conversation');
1597
+ const targetMachine = workspaceMove ? said.workspace.machine : machineChange ? said.value : ctx.scope.before.machine;
1598
+ // A replacement's successor runs on the profile the card now assigns (in review, its reviewer), never the old session's
1599
+ // harness: a slot whose profile moved to another harness could otherwise never be authorized.
1600
+ const assigned = replacement ? ctx.entry.profiles?.[ctx.scope.before.assignee]?.worker?.harness : null;
1601
+ ctx.replacementSource ??= { id: randomBytes(16).toString('hex'), kind: verb, field: said.field ?? null,
1602
+ value: said.value ?? null, ...(workspaceMove ? { workspace: said.workspace } : {}) };
1603
+ ctx.replacementSlots ??= new Map();
1420
1604
  for (const row of all(ctx.db, 'SELECT id, metadata FROM task_runs WHERE task_id = ? AND metadata IS NOT NULL ORDER BY id DESC', ctx.id)) {
1421
1605
  const whole = json(row.metadata, {}) ?? {};
1422
1606
  const session = whole.supercode;
1423
- if ((!session?.session_id && !session?.pane && !session?.launch_key) || (name !== '*' && (session.slot ?? 'worker') !== name) || (harness && (session.harness ?? 'claude-code') === harness)) continue;
1424
- if (keep && session.session_id === keep && (session.slot ?? 'worker') === 'worker') continue;
1607
+ if ((!session?.session_id && !session?.pane && !session?.launch_key && !session?.pid) || (name !== '*' && (session.slot ?? 'worker') !== name) || (harness && (session.harness ?? 'claude-code') === harness)) continue;
1608
+ const slot = session.slot ?? 'worker';
1609
+ // An agent that ended here is never named as the successor: the decision names
1610
+ // no agent, and the role's next generation is minted at launch (ADR 0248: an ended identity is not revived).
1611
+ const ended = session.agent_id && session.agent_org && endedHere(ctx.board.root, session.agent_org, session.agent_id);
1612
+ if (!ctx.replacementSlots.has(slot)) ctx.replacementSlots.set(slot, authorizeReplacement(ctx.db, ctx.id, slot, {
1613
+ source: ctx.replacementSource, reason: said.reason ?? `deliberate ${verb}`,
1614
+ target: { harness: harness ?? assigned ?? session.harness ?? 'claude-code', machine: targetMachine ?? null, agent: ended ? null : session.agent_id ?? null },
1615
+ }));
1425
1616
  requestSessionClose(ctx.db, ctx.id, { runId: row.id });
1426
1617
  const latest = json(one(ctx.db, 'SELECT metadata FROM task_runs WHERE id=?', row.id)?.metadata, {}) ?? {};
1427
1618
  // a reviewer binding the close handed back to its coder's slot is the coder's, and is not forgotten with the review
@@ -1551,7 +1742,12 @@ export function runEvent(db, id, kind, payload) {
1551
1742
  export function fireRun(db, board, entry, id, name, { roles = [], payload = {}, extra = {} } = {}) {
1552
1743
  const task = cardOf(db, id);
1553
1744
  const row = task.current_run_id && one(db, 'SELECT * FROM task_runs WHERE id = ?', task.current_run_id);
1554
- if (!row) throw new Error(`${id} has no current run for ${name}`);
1745
+ if (!row && name === 'heartbeat') {
1746
+ record(db, id, 'heartbeat', payload, null);
1747
+ const status = statusOf(db, task);
1748
+ return { from: status, to: status, moved: false, effects: [] };
1749
+ }
1750
+ if (!row) throw requestDetail(`${id} has no current run for ${name}`);
1555
1751
  const machine = entry.workflow.run;
1556
1752
  const meta = json(row.metadata, {}) ?? {};
1557
1753
  const from = meta.supercode?.state ?? machine.initial;
@@ -1559,7 +1755,7 @@ export function fireRun(db, board, entry, id, name, { roles = [], payload = {},
1559
1755
  const candidates = [...(machine.states[from]?.on?.[name] ?? []), ...(machine.on?.[name] ?? [])];
1560
1756
  const t = candidates.find((t) => (!t.from?.length || t.from.includes(from)) && (!t.by?.length || t.by.some((r) => roles.includes(r))) && holds(t.guard ?? null, scope));
1561
1757
  if (!t) { const error = new Error(`${id}: run event ${name} does not apply`); error.noTransition = true; throw error; }
1562
- for (const v of t.validate ?? []) if (!holds(v.expr, scope)) throw new Error(render(v.message, scope));
1758
+ for (const v of t.validate ?? []) if (!holds(v.expr, scope)) throw requestDetail(render(v.message, scope));
1563
1759
  const to = t.target ? String(templateValue(t.target, scope)) : from;
1564
1760
  if (!machine.states[to]) throw new Error(`unknown run state ${to}`);
1565
1761
  const effects = [];
@@ -1624,10 +1820,16 @@ function settleRelations(db, board, entry, id, effects) {
1624
1820
  export function storedStatus(db, board, entry, id) {
1625
1821
  const task = cardOf(db, id);
1626
1822
  const status = statusOf(db, task);
1823
+ // An ended card stores its end: a hold left on it (a block, a pause, a backlog) never outranks done or cancelled.
1824
+ const ended = ['done', 'archived', 'cancelled'].includes(storedAs(entry.workflow, status));
1825
+ if (task.backlog && !task.archived && !ended) {
1826
+ assertBacklogQualified(db, id, { backlog: task.backlog });
1827
+ return { status, stored: 'todo' };
1828
+ }
1627
1829
  // Native holds are stored honestly. Hermes lowering is an export concern;
1628
1830
  // older homes may still declare a lossy paused -> blocked storage rule.
1629
1831
  const nativeHolds = Boolean(entry.workflow.flags?.paused);
1630
- if (nativeHolds && !task.archived) {
1832
+ if (nativeHolds && !task.archived && !ended) {
1631
1833
  if (task.paused) return { status, stored: 'paused' };
1632
1834
  if (task.blocked_by.length) return { status, stored: 'blocked' };
1633
1835
  }
@@ -1639,7 +1841,7 @@ export function storedStatus(db, board, entry, id) {
1639
1841
  }
1640
1842
 
1641
1843
  export function projectStatus(db, board, entry, id) {
1642
- if (!entry.workflow.stored_as?.length && !entry.workflow.flags?.paused) return;
1844
+ if (!entry.workflow.stored_as?.length && !entry.workflow.flags?.paused && !entry.workflow.flags?.backlog) return;
1643
1845
  const { status, stored } = storedStatus(db, board, entry, id);
1644
1846
  // Only a change is written: an unchanged write still commits, and a commit wakes the dispatcher that called this.
1645
1847
  run(db, 'UPDATE tasks SET status = ? WHERE id = ? AND status IS NOT ?', stored, id, stored);