@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
package/board/cli.mjs CHANGED
@@ -1,3 +1,8 @@
1
+ import { registerWorkService, closeWorkLedger } from '@volter/supercode-harness-sdk/work-ledger';
2
+ import { registerWorkOperations, measureWork } from "@volter/supercode-harness-sdk/work-ledger";
3
+ registerWorkOperations([{"kind":"door","operation":"board.command"}]);
4
+ import { measureSyncChild } from '@volter/supercode-harness-sdk/work-ledger';
5
+ const spawnSync = (...args) => measureSyncChild(() => workLedgerNativeSpawnSync(...args));
1
6
  // `supercode workflow <verb>` (docs/architecture/orchestrator.md §2.9): supercode's own board, in Hermes Kanban's
2
7
  // format, and its dispatcher. Every verb of `hermes kanban` is here under its own name, with its own flags and its own
3
8
  // output lines, so a caller of Hermes's CLI calls this one unchanged; supercode's own verbs (`set`, `adopt`, `detach`, `close`,
@@ -7,11 +12,12 @@
7
12
  import { machineCall, closeDoors, onLocalReopen } from './doors.mjs';
8
13
  import { createHash } from 'node:crypto';
9
14
  import { DatabaseSync } from 'node:sqlite';
10
- import { homeOf, parse } from './args.mjs';
15
+ import { assertWorkflowVerb, homeOf, parse } from './args.mjs';
11
16
  import { BOARD_OPERATION_VERBS, boardHome, boardOwnerSocket, CALLER_FACTS } from '@volter/teams/board-owner';
12
- import { current, isCallersCall, ownCall, within } from './call.mjs';
17
+ import { current, isCallersCall, ownCall, refuseUnreadManager, requestReceiptEnvelope, within } from './call.mjs';
13
18
  import { cardState, holdDescription } from './card-state.mjs';
14
- import { detachSession } from './custody.mjs';
19
+ import { breakGlassLog, breakGlassReason, guardRefusal, recordBreakGlass } from '@volter/teams/break-glass';
20
+ import { detachSession, sessionOperation } from './custody.mjs';
15
21
  import { reclaimSpace, taskStatusOf, trimDue, trimPublications } from './published.mjs';
16
22
  import { executionOf, readingContext } from './session-work.mjs';
17
23
  import { attachmentRecord, readAttachment } from './attachment-read.mjs';
@@ -22,46 +28,45 @@ import { openOwnerDoor } from './owner-door.mjs';
22
28
  import { inBoardGeneration, generationWork, admitGeneration, grantGenerationWriter, parkGeneration, stopGeneration, tellOwner, admittedWork, GENERATION_STOPPED } from './generation.mjs';
23
29
  import { existsSync, mkdirSync, readdirSync, realpathSync, renameSync, rmSync, statSync, statfsSync, unlinkSync, copyFileSync, readFileSync, appendFileSync, writeFileSync } from 'node:fs';
24
30
  import { notRuntime, ownHome } from './owner-files.mjs';
25
- import { execFile, spawnSync } from 'node:child_process';
26
- import { supercodeInvocation } from '@volter/supercode-harness-sdk/supercode-program';
31
+ import { spawnSync as workLedgerNativeSpawnSync } from 'node:child_process';
27
32
  import { holdLock } from './lock-holder.mjs';
28
33
  import { takeEpoch } from './fence.mjs';
29
- import { dispatcherNotice, holdOwnerHealth, holdRound, holdSwitch, watchOwnerHealth } from './owner-health.mjs';
34
+ import { dispatcherDiagnostics, holdOwnerHealth, holdRound, holdSwitch } from './owner-health.mjs';
30
35
  import { boardTool } from './tools.mjs';
31
36
  import { arcSources, assertSourceReceipt } from './ci.mjs';
32
- import { noteStep, recordSlow, takeSteps } from '@volter/supercode-harness-sdk/slow-log';
37
+ import { noteStep, recordSlow, takeSteps, timed } from '@volter/supercode-harness-sdk/slow-log';
33
38
  // read by name, so an older harness SDK without them still loads: the owner then keeps no per-verb or loop lines
34
39
  import * as slowLog from '@volter/supercode-harness-sdk/slow-log';
35
40
  import { basename, dirname, join, resolve } from 'node:path';
36
41
  import { fileURLToPath } from 'node:url';
37
42
  import { homedir, tmpdir, userInfo } from 'node:os';
43
+ import { prepareBoardRestingAdmission } from './resting-admission.mjs';
44
+ import { resolveNativeSessionHome } from '@volter/supercode-interchange';
38
45
  import { storageContract } from './storage-contract.mjs';
39
- import { boardKey, cardRef, actorOf, CALLER_RUN_ADDRESS, CALLER_RUN_AGENT, SESSION_CURRENT_RUNS, SESSION_RUNS, sessionIndexed, heldConfigWatched, heldDocumentsChanged, heldDocumentsWatched, holdBoards, retireHeldBoards, releaseHeldBoards, publishMarked, view, backingPaths, layOut, backingOf, boardExists, boardCopyOf, boardPath, briefPath, DEFAULT_BOARD, event, json, listBoards, logsDir, now, openBoard, setActor, setPrincipal, principalOf, isCaller, tx } from './store.mjs';
40
- import { isLocalWorkspace } from './workspace.mjs';
46
+ import { boardKey, cardRef, actorOf, CALLER_RUN_ADDRESS, CALLER_RUN_AGENT, SESSION_CURRENT_RUNS, SESSION_RUNS, sessionIndexed, heldConfigWatched, heldDocumentsChanged, ownBoardWrite, heldDocumentsWatched, holdBoards, retireHeldBoards, releaseHeldBoards, publishMarked, view, backingPaths, layOut, backingOf, boardExists, boardCopyOf, boardPath, briefPath, DEFAULT_BOARD, event, json, listBoards, logsDir, now, openBoard, setActor, setPrincipal, principalOf, isCaller, tx } from './store.mjs';
47
+ import { cardWorktrees, isLocalWorkspace } from './workspace.mjs';
41
48
  import { openZtrackBoard } from './ztrack.mjs';
42
49
  import * as engine from './engine.mjs';
43
50
  import { recordCardAgentChanges } from './agent-declarations.mjs';
44
51
 
45
- // A mailbox read through the mailbox's own command, awaited (never a wait that holds the event loop). Gap A31: a
46
- // helper process until the daemon owns mail (order 3 PR 4).
47
- const mailRead = (verb) => new Promise((resolve) => {
48
- const env = commandEnv(), selected = supercodeInvocation(supercodeBin(), ['message', verb, '--json'], env);
49
- execFile(selected.program, selected.arguments, { encoding: 'utf8', env, timeout: 15_000, maxBuffer: 64 * 1024 * 1024 }, (_, stdout) => resolve(stdout ?? ''));
50
- });
52
+ // A mailbox read through this machine's daemon's mail door (in this process, over its held channel), as `supercode
53
+ // message waiting|expired --json` reads it: its rows, or none when the daemon does not answer.
54
+ const mailRows = async (verb) => {
55
+ try { const { rows } = await machineCall(null, `harness.v1.mail.${verb}`, {}, { timeoutMs: 15_000 }); return Array.isArray(rows) ? rows : []; }
56
+ catch { return []; }
57
+ };
51
58
 
52
- /** The messages on this machine the mailbox still has to deliver (`supercode message waiting`), by message id. */
59
+ /** The messages on this machine the mailbox still has to deliver (`harness.v1.mail.waiting`), by message id. */
53
60
  async function mailWaiting() {
54
- const stdout = await mailRead('waiting');
55
- try { return new Map((JSON.parse(stdout) ?? []).map((mail) => [mail.id, mail])); } catch { return new Map(); }
61
+ return new Map((await mailRows('waiting')).map((mail) => [mail.id, mail]));
56
62
  }
57
63
 
58
64
  /**
59
- * The messages on this machine whose delivery the mailbox gave up on (`supercode message expired`), by message id:
65
+ * The messages on this machine whose delivery the mailbox gave up on (`harness.v1.mail.expired`), by message id:
60
66
  * filed by the board, never delivered within the mailbox's bound, kept unread. Empty when the mailbox does not answer.
61
67
  */
62
68
  async function mailExpiries() {
63
- const stdout = await mailRead('expired');
64
- try { return new Map((JSON.parse(stdout) ?? []).map((expiry) => [expiry.id, expiry])); } catch { return new Map(); }
69
+ return new Map((await mailRows('expired')).map((expiry) => [expiry.id, expiry]));
65
70
  }
66
71
 
67
72
  /**
@@ -76,7 +81,7 @@ async function boardPublishing(root) {
76
81
  const board = listed?.boards?.find((b) => b.root === here);
77
82
  return board ? { ...board, refused: listed.refused ?? null } : null;
78
83
  }
79
- import { answerQuestion, dispatchingOf, knowLocalMachine, requestAdoption, alertManagers, boardAddress, boardLabel, boardSettings, boardCaller, callerSession, commandEnv, reconcileCallerLaunch, closeRequested, closeEnded, lanesOf, localMachine, observeFleet, paused, plan, startCard, releaseClaims, stopDispatching, resumeDispatching, supercodeBin, tick, deliver, startDelivery } from './dispatch.mjs';
84
+ import { answerQuestion, assertHeadlessBinding, dispatchingOf, knowLocalMachine, requestAdoption, alertManagers, boardAddress, boardLabel, boardSettings, boardCaller, callerSession, commandEnv, reconcileCallerLaunch, closeRequested, closeEnded, lanesOf, localMachine, normalMachine, observeFleet, paused, plan, startCard, releaseClaims, stopDispatching, resumeDispatching, supercodeBin, tick, deliver, startDelivery } from './dispatch.mjs';
80
85
  import { watchBoard } from './wake.mjs';
81
86
  import { attachSideSession, releaseSideSession, sideArcs, sideSessions } from './side-sessions.mjs';
82
87
  import { onPanesChanged, onRecordsChanged, readFleetRecords, fleetRecordsCurrent } from './machine-fleet.mjs';
@@ -86,7 +91,7 @@ import { drainAgentCredentials, fileFleetNotice } from './dispatch.mjs';
86
91
  import { streamBoardPublications } from './event-stream.mjs';
87
92
  import { completionOwnerOf, followHome, isLatestReviewer, loadWorkflow, readHome, requestSessionClose, scopeOf, statusOf, timeOf, useWorkflow, workflowEntry, workflowFor, watchWorkflows } from './workflow.mjs';
88
93
  import { evaluate } from './expr.mjs';
89
- import { agentDoor,agentScope,activeAgentAssignments,reassignAgent,agentUsage,localAgentRecord,agentRunForSession } from '../agents.mjs';
94
+ import { agentDoor,agentScope,activeAgentAssignments,cardRoleAssignment,reassignAgent,agentUsage,localAgentRecord,agentRunForSession } from '../agents.mjs';
90
95
 
91
96
  // Text a verb stores, read from --file/--body-file PATH (`-` for stdin) when given: a shell never expands what it
92
97
  // never sees, as it does `$(...)` and `$VAR` in an argument.
@@ -119,7 +124,7 @@ The board's verbs, with their flags:
119
124
  set <id> <assignee|machine|model|every|reviewer> <value|none> [--provider] [--ids ...] assign <id> <profile|none> set-model <id> [model] [--provider] set-goal <id>... [--goal-max-turns N] [--off]
120
125
  reassign <id> --agent AGENT [--role implementer] (moves a card role's assignment to another agent; sessions keep their identity)
121
126
  claim <id> [--ttl] reclaim <id> [--reason] heartbeat <id> [--note] [--until <time|30m>] link|unlink <parent> <child>
122
- comment <id> (<text> | --file PATH|-) [--author] [--max-len] [(--pr URL --pr-head SHA)... | --pr none] [--source owner/repo:branch... | --source none] attach <id> <path> [--name] [--content-type] attachments <id> attach-rm <n>
127
+ comment <id> (<text> | --file PATH|-) [--author] [--max-len] [(--pr URL --pr-head SHA)... | --pr none] [--source owner/repo:branch... | --source none] [--pr-disposition merge|release-only [--release-target TARGET] --reason TEXT | --release-target TARGET --released-artifact SHA --release-evidence POINTER [--attest-release] --reason TEXT] attach <id> <path> [--name] [--content-type] attachments <id> attach-rm <n>
123
128
  attachment-read <id> <attachment-id> [--offset N --length N --version V] bounded base64/metadata JSON, including through --at
124
129
  complete <id>... [--result] [--summary] [--metadata JSON] [--commit SHA] edit <id> --result [--summary] [--metadata]
125
130
  person-done <id> --person person:<principal> (--result <answer> | --result-file PATH|-) [--workplace-task <Task id>]
@@ -127,9 +132,11 @@ The board's verbs, with their flags:
127
132
  integration reports it from the home; the board checks it names the card's person. Never a session's verb.
128
133
  finish <id> --result TEXT (when declared: end your completed attempt; the board selects review)
129
134
  block <id> [reason] (--on <card> | --waiting-on <message id>) [--ids ...] schedule <id> [reason] [--ids ...]
130
- pause <id> --reason <why> resume <id> cancel <id> --reason <why>
135
+ pause <id> --reason <why> --until <UTC time|2h> resume <id>
136
+ backlog <id> --reason <why> [--pause-revision <generation|legacy event id>] plan <id>
137
+ cancel <id> --reason <why>
131
138
  request-state <id> --state block|pause|done|cancelled --reason <why> [--on <card>|--waiting-on <message>] [--evidence <pointers>]
132
- unblock <id>... --on <blocker> --reason <what arrived> request-review <id> [--plan] --pr URL --pr-head SHA [--summary] [--reviewer] [--force] [--commit SHA] request-changes <id> <reason> [--finding <finding>]... reopen-review <id>... --reason <why> (voids the verdict, or restarts a review run that has none) return-review <id> --reason <why> (manager: back to its coder, no verdict) reopen <id>... [--reason]
139
+ unblock <id>... --on <blocker> --reason <what arrived> request-review <id> [--plan] --pr URL --pr-head SHA [--supersedes QUEUED_EVENT] [--summary] [--reviewer] [--force] [--commit SHA] request-changes <id> <reason> [--finding <finding>]... reopen-review <id>... --reason <why> (voids the verdict, or restarts a review run that has none; returns it when the arc re-declared its sources since its request) return-review <id> --reason <why> (manager: back to its coder, no verdict) reopen <id>... [--reason]
133
140
  promote <id> [reason] [--ids ...] [--force] [--dry-run] archive <id>... --reason <why> [--rm <id>...]
134
141
  specify <id> [--readable-id ID] [--title --body | --body-file PATH|-] [--workspace scratch|worktree:<repository>|dir:<repository>] [--branch NAME] [--machine M|none] [--all]
135
142
  decompose <id> [--child "<title>[@<profile>]"]... [--all] file the card's subtasks atomically, if its workflow allows
@@ -176,6 +183,7 @@ And:
176
183
  comment <id> TEXT (--pr URL --pr-head SHA)...: the card's implementer or the manager adds PRs (including merged ones) to the arc's discovered source set, in the arc's repositories (its workspace, its declared sources, its tasks'), each followed at its current head; each declaration adds to the earlier ones, on any run, and --pr none alone clears them. This records evidence; only the board starts its review.
177
184
  comment <arc> TEXT --source owner/repo:branch...: the arc's implementer or the manager declares every repository and branch the arc changes, on the arc itself; its review discovers PRs from them and accepts --pr declarations in them, with no task registered there. Each declaration adds to the earlier ones, and --source none alone clears them.
178
185
  request-review <id> --pr URL --pr-head SHA [--summary "<what the PR does>"]: hand one ready PR to its PR review at its pushed head, whatever other PRs the arc has; merge it at that head once the review passes. A request naming its PR is a PR review.
186
+ request-review <id> --supersedes QUEUED_EVENT --pr URL --pr-head SHA: explicitly replace that card's pending queued request after fresh admission; preserve its receipt and FIFO position, without changing the active review.
179
187
  request-review <id> --plan --pr URL --pr-head SHA: a big arc's plan review, of the proposed ADR's PR at its pushed head, before any code. The arc's final review (of every PR not merged at a PR-reviewed head, with its Acceptance) is the board's.
180
188
  request-state <id> --state done (--reviewed-sha SHA)...: record the verdict on every requested PR head.
181
189
  request-state <id>... --state STATE --reason REASON: request several decisions in one retained manager mail.
@@ -185,6 +193,11 @@ And:
185
193
  heartbeat and request review of its card, specify or decompose it in triage, and work its subtasks. A side session
186
194
  (adopt --side) works the arc's subtasks and comments on the arc; the arc's run and state stay its main session's. The home's
187
195
  workflow declares who may block, pause or finish a card; request-state mails its manager without changing state.
196
+ A card's session writes outside its own card (another card, a board verb, --all) only on purpose: with
197
+ --break-glass "<why it is needed>" it passes that guard (and the manager check adopt, release and detach repeat) and is
198
+ recorded in the machine's break-glass journal with the session, what it wrote and the reason, followed there by its
199
+ outcome when it was refused or failed; without one nothing is written and the board says why the write is guarded.
200
+ The workflow's own rules for who may make each transition still apply.
188
201
  `;
189
202
 
190
203
 
@@ -340,7 +353,23 @@ async function requester(args) {
340
353
 
341
354
  const tagsOf = (card) => { const tags = Array.isArray(card.tags) ? card.tags : json(card.tags, null); return Array.isArray(tags) ? tags : card.tenant ? [card.tenant] : []; };
342
355
 
343
- const say = (args, human, value) => { current().out(args.json ? `${JSON.stringify(value, null, 2)}\n` : `${human}\n`); };
356
+ const say = (args, human, value) => {
357
+ const requests = current().routedRequests?.splice(0) ?? [];
358
+ const accepted = current().acceptedRequests?.splice(0) ?? [];
359
+ const queued = current().queuedRequests?.splice(0) ?? [];
360
+ const unqueued = current().unqueuedReviews?.splice(0) ?? [];
361
+ const receipts = requestReceiptEnvelope({ requests, accepted, queued, unqueued });
362
+ if (receipts) {
363
+ value = receipts;
364
+ human = [
365
+ ...requests.map(request => `Retained ${request.requested} for ${request.manager} (request ${request.message_id}).`),
366
+ ...queued.map(request => `${request.already ? 'Already queued' : 'Queued'} review of ${request.pr ?? request.requested ?? 'the PR'} after the current review.${request.pending ? ` Waiting: ${request.reason}` : ''}`),
367
+ ...accepted.map(request => `Recorded ${request.requested}${request.from ? `; card remains ${request.to}` : ''}: ${request.reason}`),
368
+ ...unqueued.map(request => request.reason),
369
+ ].join('\n');
370
+ }
371
+ current().out(args.json ? `${JSON.stringify(value, null, 2)}\n` : `${human}\n`);
372
+ };
344
373
 
345
374
  // Hermes's own output lines, so a reader of `hermes kanban list` reads this one
346
375
  const ICONS = { todo: '◻', ready: '▶', running: '●', scheduled: '⏱', blocked: '⊘', paused: '⏸', done: '✓', archived: '—' };
@@ -351,7 +380,8 @@ const ts = (t) => {
351
380
  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
352
381
  };
353
382
  // a card's status is the workflow's (triaging, reviewing, …); kanban.db stores the Hermes status it maps to
354
- const line = (key) => (t) => `${ICONS[t.status] ?? '?'} ${engine.cardLabel(key, t.number, t.id, t.number ? t.readable_id : null).padEnd(32)} ${(['paused','blocked'].includes(t.status) ? t.status : t.stage ?? t.status).padEnd(8)}${holdDescription(t) ? ` [${holdDescription(t)}]` : t.detached ? ` [session belongs to ${t.detached.person}]` : ['running','review'].includes(t.status) ? ` [session ${t.execution?.state ?? 'unknown'}${t.execution?.reason ? ` — ${t.execution.reason}` : ''}]` : ''} ${(t.assignee || '(unassigned)').padEnd(20)}${t.tenant ? ` [${t.tenant}]` : ''} ${t.title}${t.machine ? ` @${t.machine}` : ''}`;
383
+ const hasExecution = (t) => ['running', 'review'].includes(t.status) || (t.execution?.state === 'awaiting_session' && t.execution.since != null);
384
+ const line = (key) => (t) => `${ICONS[t.status] ?? '?'} ${engine.cardLabel(key, t.number, t.id, t.number ? t.readable_id : null).padEnd(32)} ${(['paused','blocked'].includes(t.status) ? t.status : t.stage ?? t.status).padEnd(8)}${holdDescription(t) ? ` [${holdDescription(t)}]` : t.detached ? ` [session belongs to ${t.detached.person}]` : hasExecution(t) ? ` [session ${t.execution?.state ?? 'unknown'}${t.execution?.reason ? ` — ${t.execution.reason}` : ''}]` : ''} ${(t.assignee || '(unassigned)').padEnd(20)}${t.tenant ? ` [${t.tenant}]` : ''} ${t.title}${t.machine ? ` @${t.machine}` : ''}`;
355
385
  // `key` is the card's board's (store.mjs boardKey); `ref` the card as a person names it (`VO-856`)
356
386
  const stateOptions = root => ({ nativeHolds: Boolean(workflowFor(root).workflow.flags?.paused) });
357
387
  const cardDict = (raw, key, options) => { const t = cardState(raw, options); return ({
@@ -362,19 +392,10 @@ const cardDict = (raw, key, options) => { const t = cardState(raw, options); ret
362
392
  session_id: t.session_id, workflow_template_id: t.workflow_template_id, current_step_key: t.current_step_key,
363
393
  machine: t.machine ?? null, subtask_of: t.subtask_of ?? null, execution: t.execution ?? null, detached: t.detached ?? null,
364
394
  reviewer: t.reviewer ?? null,
365
- paused: t.paused, blocked_by: t.blocked_by,
395
+ paused: t.paused, backlog: t.backlog ?? null, blocked_by: t.blocked_by,
366
396
  }); };
367
397
 
368
398
  /** Hermes's durations: seconds, or 30s / 5m / 2h / 1d. */
369
- // a moment the caller names: an ISO time, or a duration from now (30m, 2h); epoch seconds, or null
370
- function untilOf(v) {
371
- if (v === undefined || v === null || v === '' || v === true) return null;
372
- if (/^\d+(?:\.\d+)?[smhd]?$/i.test(String(v).trim())) return now() + duration(v);
373
- const t = Date.parse(String(v));
374
- if (Number.isNaN(t)) throw new Error(`malformed --until ${v} (expected an ISO time such as 2026-09-29T04:35Z, or a duration such as 2h)`);
375
- return Math.floor(t / 1000);
376
- }
377
-
378
399
  function duration(v) {
379
400
  if (v === undefined || v === null || v === '') return null;
380
401
  const s = String(v).trim().toLowerCase();
@@ -429,13 +450,130 @@ function resolveCardNumbers(verb, rest, args, withBoard, keyOf, exists) {
429
450
  }));
430
451
  }
431
452
 
432
- /** One verb in full, in the call it runs in (call.mjs): a caller's in the owner, or the process's own here. */
453
+ const isServiceVerb = verb => ['serve', 'daemon'].includes(verb);
454
+ const localServiceDoorError = () => Object.assign(new Error('ERR_BOARD_SERVICE_LOCAL_ONLY: a resident board starts through its local stable owner door'), { code: 'ERR_BOARD_SERVICE_LOCAL_ONLY' });
455
+
456
+ /** Admit only the scoped session guard for a new owner. The caller keeps its outcome open through the actual
457
+ * owner lifetime and cleanup; admitting a service alone never completes a break-glass act.
458
+ * An incomplete request is retained and returns null without admitting a service. */
459
+ export async function admitBoardService(argv) {
460
+ let args;
461
+ try {
462
+ args = parse(argv);
463
+ assertSourceReceipt(args);
464
+ } catch (error) {
465
+ await retainUnappliedRequest(argv, error);
466
+ return null;
467
+ }
468
+ const [verb, ...rest] = args._;
469
+ if (!isServiceVerb(verb)) throw localServiceDoorError();
470
+ const root = homeOf(args);
471
+ let glass = null;
472
+ const admission = { root, args, finish(failure, code) {
473
+ if (glass) followBreakGlass(glass, failure, code);
474
+ } };
475
+ try {
476
+ const sid = callerSession();
477
+ if (!sid) return admission;
478
+ const named = boardOf(root, args), slug = isView(root, named) ? DEFAULT_BOARD : named;
479
+ if (!boardExists(root, slug)) return admission;
480
+ const board = openBoard(root, slug);
481
+ try {
482
+ const guard = scopedSessionGuard(board, { root, slug, verb, rest, args, sid });
483
+ if (!guard) return admission;
484
+ const entry = await loadWorkflow(root);
485
+ if (managerSession(sid, entry.params, false)) return admission;
486
+ // Explicit break glass authorizes this scope crossing without pretending an unread agent main was resolved.
487
+ const explicit = typeof args['break-glass'] === 'string' && args['break-glass'].trim();
488
+ if (!explicit) {
489
+ const managers = Array.isArray(entry.params?.managers) ? entry.params.managers : [];
490
+ if (managers.some(address => /^sc:([^:\s]+):agent:([^:\s]+)$/.test(String(address)))) {
491
+ await ownerStep('machine identity', () => knowLocalMachine());
492
+ await ownerStep('manager main sessions', () => readAgentMains(root));
493
+ if (managerSession(sid, entry.params)) return admission;
494
+ }
495
+ breakGlassOrRefuse(guard.refused, guard.scope);
496
+ }
497
+ breakGlassReason(explicit, {});
498
+ current().recorded = board.db.prepare(CALLER_RUN_ADDRESS).get(sid)?.address ?? null;
499
+ if (!current().recorded && !boardCaller()) await ownerStep('machine identity', () => knowLocalMachine());
500
+ setActor(callerAddress());
501
+ glass = breakGlassOrRefuse(guard.refused, guard.scope);
502
+ return admission;
503
+ } finally { board.close(); }
504
+ } catch (error) {
505
+ admission.finish(error, 1);
506
+ throw error;
507
+ }
508
+ }
509
+
510
+ /** One verb in full, in the call it runs in (call.mjs): a caller's in the owner, or the process's own here. A card's
511
+ * session's write that broke glass (breakGlassOrRefuse) is followed in the journal by what became of it, unless it
512
+ * was simply done: the journal never shows an act as done that was refused or failed after its entry was written. */
433
513
  export async function runVerb(argv) {
514
+ const context = parse(argv, { contextOnly: true });
515
+ if (isServiceVerb(context._[0]) && !context.help) throw localServiceDoorError();
516
+ const call = current();
517
+ call.glass = null;
518
+ call.routedRequests = [];
519
+ call.acceptedRequests = [];
520
+ call.queuedRequests = [];
521
+ call.unqueuedReviews = [];
522
+ call.boardCommand = null;
523
+ let code, failure = null;
524
+ try {
525
+ code = await runVerbBody(argv);
526
+ if (call.routedRequests.length || call.acceptedRequests.length || call.queuedRequests.length || call.unqueuedReviews.length) say(parse(argv, { contextOnly: true }), '', null);
527
+ return code;
528
+ } catch (error) {
529
+ failure = error;
530
+ code = await retainUnappliedRequest(argv, error);
531
+ return code;
532
+ }
533
+ finally { if (call.glass) followBreakGlass(call.glass, failure, code); }
534
+ }
535
+
536
+ // Ordinary verbs and local service admission retain incomplete input through
537
+ // one owner, while the caller still holds this home's writer authority.
538
+ async function retainUnappliedRequest(argv, error) {
539
+ const call = current();
540
+ if (call.readOnly) throw Object.assign(error, { code: READ_BY_OWNER });
541
+ // an unread manager identity is answered to its caller as itself, never handed to the manager
542
+ if (error.code === 'manager_identity_unavailable') throw error;
543
+ // Earlier doors (argument parsing, caller scope, attachments and starts) can
544
+ // discover a request they cannot apply before the workflow sees its event.
545
+ // Record incomplete detail locally or route an explicitly reserved act.
546
+ // No restricted action is retried here and no manager role is inferred.
547
+ const args = parse(argv, { contextOnly: true });
548
+ const root = call.boardCommand?.root ?? homeOf({ ...args, root: args.root ?? call.facts.SUPERCODE_AGENT_HOME });
549
+ await loadWorkflow(root);
550
+ const named = call.boardCommand?.slug ?? boardOf(root, args);
551
+ const slug = isView(root, named) ? DEFAULT_BOARD : named;
552
+ const board = openBoard(root, slug);
553
+ try {
554
+ if (!actorOf()) setActor(callerAddress() ?? 'unresolved board caller');
555
+ let id = NO_CARD_WORDS.has(args._[0]) ? null : call.boardCommand?.id ?? args._[1] ?? null;
556
+ if (id && !engine.getTask(board.db, id)) id = engine.cardByReadableId(board.db, id) ?? null;
557
+ const ownerRule = error.ownerRule || error.code === 'forbidden'
558
+ || /only the manager|only .*manager.*creator|scoped to|outside.*own card|person's completion.*never by a session/i.test(error.message ?? '');
559
+ if (!ownerRule && (error.errcode != null || /^(?:E[A-Z_]+|ERR_)/.test(error.code ?? '') || error instanceof TypeError || error instanceof ReferenceError)) throw error;
560
+ const receipt = await (ownerRule ? engine.routeRequest : engine.acceptRequest)(board, { id, name: args._[0] ?? 'workflow', payload: { argv }, reason: String(error.message ?? error) });
561
+ say(args, '', null);
562
+ return receipt.ok === false ? 1 : 0;
563
+ } catch (handoffError) {
564
+ if (handoffError === error || handoffError.code === 'manager_identity_unavailable') throw handoffError;
565
+ // A failed store or mailbox enqueue is a technical failure, never a
566
+ // successful handoff. Keep both exact paths visible to the caller.
567
+ throw new Error(`${error.message}; board request could not be retained: ${handoffError.message}`, { cause: handoffError });
568
+ } finally { board.close(); }
569
+ }
570
+
571
+ async function runVerbBody(argv) {
434
572
  const args = parse(argv);
435
573
  assertSourceReceipt(args);
436
- await ownerStep('machine identity', () => knowLocalMachine());
437
574
  const [verb, ...rest] = args._;
438
575
  if (!verb || verb === 'help' || args.help) { current().out(HELP); return 0; }
576
+ await ownerStep('machine identity', () => knowLocalMachine());
439
577
  const root = homeOf(args);
440
578
  if (verb === 'events') return streamBoardPublications({root,after:args.after,once:args.once});
441
579
  if (verb === 'listeners') {
@@ -456,26 +594,29 @@ export async function runVerb(argv) {
456
594
  }
457
595
  const viewTag = isView(root, named) ? named : null;
458
596
  const slug = viewTag ? DEFAULT_BOARD : named;
597
+ current().boardCommand = { root, slug, id: rest[0] ?? null };
459
598
  const open = (create = false) => { const b = openBoard(root, slug, { create, document: args.document ?? null, backing: create ? args.backing ?? null : null }); b.label = boardLabel(root, slug); return b; };
460
599
  const withBoard = (fn, create = false) => { const b = open(create); try { return fn(b); } finally { b.close(); } };
461
600
  const withBoardAsync = async (fn, create = false) => { const b = open(create); try { return await fn(b); } finally { b.close(); } };
462
601
  // the board's key (store.mjs boardKey), read when a card is named or shown by its number
463
602
  const keyOf = () => boardKey(root, slug);
464
- resolveCardNumbers(verb, rest, args, withBoard, keyOf, () => boardExists(root, slug));
465
- const id = rest[0];
466
- const need = (n, what) => { if (rest.length < n) throw new Error(`${verb} needs ${what}`); };
467
- const ids = (first = rest) => [...first, ...(args.ids ?? [])];
468
603
 
469
604
  // a caller that is a card's own session (its harness names it; the board recorded which session it launched)
470
605
  const listing = ['list','ls'].includes(verb);
471
606
  const readingDispatching = verb === 'dispatching' && rest.length === 0;
472
- const reading = READS.has(verb) || readingDispatching;
607
+ const reading = READS.has(verb) || readingDispatching || (verb === 'dispatch' && args['dry-run']);
473
608
  // a listing on the owner's read door reads the workflow the owner holds (owner-door.mjs), and names no session: it
474
609
  // records nothing, and `--mine` is the caller's profile
475
610
  const readDoor = Boolean(current().readOnly);
476
611
  if (!readDoor) await ownerStep('workflow', () => loadWorkflow(root)).catch((error) => { if (verb !== 'init' && verb !== 'boards') throw error; });
477
- // A listing does not authorize a transition and needs no manager's main session.
478
- if (!listing && !readingDispatching) await ownerStep('manager main sessions', () => readAgentMains(root));
612
+ assertWorkflowVerb(args, workflowFor(root, { optional: true })?.workflow);
613
+ resolveCardNumbers(verb, rest, args, withBoard, keyOf, () => boardExists(root, slug));
614
+ const id = rest[0];
615
+ current().boardCommand.id = id ?? null;
616
+ const need = (n, what) => { if (rest.length < n) throw new Error(`${verb} needs ${what}`); };
617
+ const ids = (first = rest) => [...first, ...(args.ids ?? [])];
618
+ // Read-only answers grant no transition. Init still admits the initial board write.
619
+ if (!reading || verb === 'init') await ownerStep('manager main sessions', () => readAgentMains(root));
479
620
  const sid = listing && (!args.mine || readDoor) ? null : callerSession();
480
621
  // what this command records is the calling session's act (its address, as its subscriptions name it)
481
622
  current().recorded = sid && boardExists(root, slug) ? withBoard((b) => b.db.prepare(CALLER_RUN_ADDRESS).get(sid)?.address) ?? null : null;
@@ -483,19 +624,16 @@ export async function runVerb(argv) {
483
624
  const recordedAgent = sid && boardExists(root, slug)
484
625
  ? await ownerStep('committed native agent ownership', () => withBoard((b) => recordedRunAgent(b.db, sid, callerAddress()))) : null;
485
626
  setPrincipal(await ownerStep('caller principal', () => callerPrincipal(root, sid, { teams: !sid && !reading, recordedAgent })));
486
- // the dispatcher as the owner holds it, said once per call (owner-health.mjs; nothing is read for it)
487
- if (!current().said.has(`dispatcher:${slug}`)) { current().said.add(`dispatcher:${slug}`); for (const line of dispatcherNotice(slug)) current().err(`${line}\n`); }
488
627
  if (sid && !reading && boardExists(root, slug)) {
489
628
  // binding the caller's launch is a write: only the owner, answering the caller's own verb, makes it (a local read
490
629
  // never writes)
491
630
  await withBoardAsync(async (b) => {
492
631
  if (isCallersCall()) await reconcileCallerLaunch(b, sid);
493
- const own = { ...ownCards(b.db, sid), side: sideArcs(b.db, sid) };
494
- if (!own.worked.size && !own.side.size) return;
495
- // A copied home owns no live sessions or manager addresses. Its owner may
496
- // answer local verbs while every board's dispatching remains off.
497
- const copyServe = verb === 'serve' && Boolean(boardCopyOf(b)) && listBoards(root).every(board => !dispatchingOf(root, board).on);
498
- authorize(b.db, verb, rest, args, own, { copyServe });
632
+ // Working a card does not take away a workflow-declared manager's board authority. Adoption keeps identity;
633
+ // the ordinary transition checks below still admit each act against that manager role.
634
+ if (rolesFor(b.db, '', sid, workflowFor(root).params).includes('manager')) return;
635
+ const guard = scopedSessionGuard(b, { root, slug, verb, rest, args, sid });
636
+ if (guard) current().glass = breakGlassOrRefuse(guard.refused, guard.scope);
499
637
  });
500
638
  }
501
639
  // the board's workflow (the IR's): what each verb does, and who may send it, is its
@@ -604,9 +742,10 @@ export async function runVerb(argv) {
604
742
  })), true);
605
743
  await withBoardAsync(b=>engine.runEffects(b,created.effects??[]));
606
744
  let answer = { id: created.id, existing: created.existing };
745
+ if (current().glass) current().glass.created = created.id;
607
746
  if (args.adopt) {
608
- withBoard(b => { if (!rolesFor(b.db, created.id, sid, workflowFor(root).params).includes('manager')) throw new Error('Only the manager adopts an existing session.'); });
609
- withBoard(b => requestAdoption(b, created.id, args.adopt));
747
+ withBoard(b => { if (!current().glass && !rolesFor(b.db, created.id, sid, workflowFor(root).params).includes('manager')) throw new Error('Only the manager adopts an existing session.'); });
748
+ await withBoardAsync(b => requestAdoption(b, created.id, args.adopt));
610
749
  answer = { ...answer, adoption_requested: args.adopt };
611
750
  }
612
751
  // Starting a card is the dispatcher's: this write's wake hint reaches it, and the card's
@@ -637,8 +776,9 @@ export async function runVerb(argv) {
637
776
 
638
777
  case 'list': case 'ls': {
639
778
  const assignee = args.assignee ?? (args.mine ? callerName() : null);
779
+ if (args.backlog != null && !['true', 'false'].includes(String(args.backlog))) throw new Error('--backlog takes true or false');
640
780
  const cards = await withBoardAsync(b => listCardsInTurns(b.db, { ...stateOptions(root), detailed: Boolean(args.json), status: args.status, assignee, machine: args.machine, includeArchived: args.archived || args.all, includeSubtasks: args.subtasks || args.all,
641
- tenant: args.tenant, session: args.session, workflowTemplateId: args['workflow-template-id'], stepKey: args['step-key'], tags: [...(viewTag ? [viewTag] : []), ...(args.tag ?? [])] }));
781
+ backlog: args.backlog == null ? null : args.backlog === 'true', tenant: args.tenant, session: args.session, workflowTemplateId: args['workflow-template-id'], stepKey: args['step-key'], tags: [...(viewTag ? [viewTag] : []), ...(args.tag ?? [])] }));
642
782
  const sorters = {
643
783
  assignee: (a, b) => String(a.assignee ?? '').localeCompare(String(b.assignee ?? '')), created: (a, b) => a.created_at - b.created_at,
644
784
  'created-desc': (a, b) => b.created_at - a.created_at, priority: (a, b) => a.priority - b.priority, 'priority-desc': (a, b) => b.priority - a.priority,
@@ -665,7 +805,8 @@ export async function runVerb(argv) {
665
805
  need(1, 'a card id');
666
806
  const { settings } = await laneState(root).catch(() => ({ settings: boardSettings({}) }));
667
807
  const card = withBoard((b) => {
668
- const got = engine.showCard(b.db, id, stateOptions(root));
808
+ const got = engine.showCard(b.db, id, { ...stateOptions(root), review: engine.reviewsCard(b.db, id, { sessionId: sid, runId: workerRun(b.db, id, sid) }) });
809
+ if (got.review_request) return got;
669
810
  got.runs = engine.runs(b.db, id, { stateType: args['state-type'] ?? null, stateName: args['state-name'] ?? null });
670
811
  got.task.execution = executionOf(engine.currentRun(b.db, id), readingContext(b.db, got.task.id, got.task.current_run_id));
671
812
  got.runs = got.runs.map(run => ({ ...run, execution: executionOf(run) }));
@@ -679,6 +820,11 @@ export async function runVerb(argv) {
679
820
  : timeOf(b.db, got.task);
680
821
  return got;
681
822
  });
823
+ if (card.review_request) {
824
+ if (args.json) say(args, '', card);
825
+ else current().out(`${withBoard((b) => view(b, (db) => engine.cardContext(db, id, { review: true })))}\n`);
826
+ return 0;
827
+ }
682
828
  const t = cardState({ ...card.task, stage: card.stage }, stateOptions(root));
683
829
  const latest = [...card.runs].reverse().find((r) => r.summary)?.summary ?? null;
684
830
  if (args.json) {
@@ -694,7 +840,7 @@ export async function runVerb(argv) {
694
840
  if (t.reviewer) out.push(` reviewer: ${t.reviewer} (card override)`);
695
841
  if (t.detached) out.push(` session: ${t.detached.session.address} belongs to ${t.detached.person} (detached ${ts(t.detached.at)}; dispatcher does not own it)`);
696
842
  // what the card's session is doing as the board last read it, beside the lane (ADR 0024)
697
- else if (['running', 'review'].includes(t.status)) out.push(` session: ${card.task.execution?.state ?? 'unknown'}${card.task.execution?.reason ? ` — ${card.task.execution.reason}` : ''}`);
843
+ else if (hasExecution(t)) out.push(` session: ${card.task.execution?.state ?? 'unknown'}${card.task.execution?.reason ? ` — ${card.task.execution.reason}` : ''}`);
698
844
  // the question the card's session shows on its pane, while it shows (VO-3395), with the door that answers it
699
845
  const asked = card.runs.find((r) => r.id === t.current_run_id)?.metadata?.supercode?.question;
700
846
  if (asked?.options?.length) {
@@ -781,7 +927,7 @@ export async function runVerb(argv) {
781
927
  return 0;
782
928
  }
783
929
 
784
- case 'context': need(1, 'a card id'); current().out(`${withBoard((b) => view(b, (db) => engine.cardContext(db, id)))}\n`); return 0;
930
+ case 'context': need(1, 'a card id'); current().out(`${withBoard((b) => view(b, (db) => engine.cardContext(db, id, { review: engine.reviewsCard(db, id, { sessionId: sid, runId: workerRun(db, id, sid) }) })))}\n`); return 0;
785
931
 
786
932
  case 'runs': {
787
933
  need(1, 'a card id');
@@ -810,7 +956,7 @@ export async function runVerb(argv) {
810
956
  if(!task)throw new Error(`no card ${id}`);
811
957
  withBoard(b=>{if(!rolesFor(b.db,id,sid,workflowFor(root).params).includes('manager'))throw new Error('Only the manager moves a card assignment between agents.');});
812
958
  const role=args.role??'implementer';
813
- const assignment=activeAgentAssignments(root).find(item=>item.org===scope.org&&item.kind==='card'&&item.target===id&&item.role===role);
959
+ const assignment=activeAgentAssignments(root).find(item=>item.org===scope.org&&item.id===cardRoleAssignment(id,role));
814
960
  if(!assignment)throw new Error('declare the card role before reassigning its assignment');
815
961
  const changed=await reassignAgent(root,scope.org,assignment.id,args.agent,{context});
816
962
  withBoard(b=>recordCardAgentChanges(b,id));
@@ -842,10 +988,10 @@ export async function runVerb(argv) {
842
988
  return 0;
843
989
  }
844
990
  case 'reclaim': need(1, 'a card id'); await withBoardAsync((b) => engine.reclaim(b, id, args.reason ?? (rest.slice(1).join(' ') || null))); say(args, `Reclaimed ${id}`, { ok: true }); return 0;
845
- case 'heartbeat': need(1, 'a card id'); await withBoardAsync((b) => engine.heartbeat(b, id, args.note ?? null, { until: untilOf(args.until) })); say(args, `Heartbeat recorded for ${id}`, { ok: true }); return 0;
991
+ case 'heartbeat': need(1, 'a card id'); await withBoardAsync((b) => engine.heartbeat(b, id, args.note ?? null, { until: args.until ?? null })); say(args, `Heartbeat recorded for ${id}${args.until != null ? `; wait recorded until ${args.until}` : ''}`, { ok: true, ...(args.until != null ? { until: args.until } : {}) }); return 0;
846
992
 
847
993
  case 'link': need(2, 'a parent and a child'); await withBoardAsync((b) => engine.link(b, rest[0], rest[1])); say(args, `Linked ${rest[0]} -> ${rest[1]}`, { ok: true }); return 0;
848
- case 'unlink': { need(2, 'a parent and a child'); const gone = await withBoardAsync((b) => engine.unlink(b, rest[0], rest[1])); say(args, gone ? `Unlinked ${rest[0]} -> ${rest[1]}` : `no link ${rest[0]} -> ${rest[1]}`, { ok: gone }); return gone ? 0 : 1; }
994
+ case 'unlink': { need(2, 'a parent and a child'); const gone = await withBoardAsync((b) => engine.unlink(b, rest[0], rest[1])); say(args, gone ? `Unlinked ${rest[0]} -> ${rest[1]}` : `Link ${rest[0]} -> ${rest[1]} is already absent`, { ok: true, already: !gone }); return 0; }
849
995
 
850
996
  // C4: a card's ordered tags; Hermes's tenant follows the first, and a board view selects by tag
851
997
  case 'tag': case 'untag': {
@@ -870,7 +1016,7 @@ export async function runVerb(argv) {
870
1016
  if (args.file !== undefined && rest.length > 1) throw new Error(`comment ${id} takes its text from --file or from words, not both; unexpected: ${JSON.stringify(rest.slice(1).join(' ').slice(0, 120))}. Nothing was recorded.`);
871
1017
  let text = textFrom(args.file, rest.slice(1).join(' '));
872
1018
  if (args['max-len']) text = text.slice(0, Number(args['max-len']));
873
- const n = await withBoardAsync((b) => engine.comment(b, id, isLatestReviewer(b.db, id, actorOf()) ? actorOf() : args.author ?? callerAddress() ?? callerName(), text, { pr: args.pr ?? null, pr_head: args['pr-head'] ?? null, source: args.source ?? null }));
1019
+ const n = await withBoardAsync((b) => engine.comment(b, id, isLatestReviewer(b.db, id, actorOf()) ? actorOf() : args.author ?? callerAddress() ?? callerName(), text, { pr: args.pr ?? null, pr_head: args['pr-head'] ?? null, source: args.source ?? null, disposition: args['pr-disposition'] ?? null, release_target: args['release-target'] ?? null, reason: args.reason ?? null, readback: !args['pr-disposition'] && (args['release-target'] || args['released-artifact'] || args['release-evidence'] || args['attest-release']) ? { target: args['release-target'], artifact: args['released-artifact'], evidence: args['release-evidence'], attest: Boolean(args['attest-release']) } : null }));
874
1020
  say(args, `Comment added to ${id}`, { ok: true, comment: n }); return 0;
875
1021
  }
876
1022
  case 'attach': {
@@ -880,7 +1026,7 @@ export async function runVerb(argv) {
880
1026
  withBoard(b => { if (!engine.getTask(b.db, id)) throw new Error(`no card ${id}`); });
881
1027
  const directory = join(slug === DEFAULT_BOARD ? join(root, 'kanban', 'attachments') : join(boardDir(root, slug), 'attachments'), id);
882
1028
  const result = receiveAttachment({ directory, upload: args.upload, caller: callerAddress() ?? actorOf(), input: current().stdin,
883
- add: (file, name) => withBoard(b => engine.addAttachment(b, id, { file, name, contentType: args['content-type'] ?? null, author: args.author ?? callerName() })) });
1029
+ add: (file, name) => withBoard(b => engine.addAttachment(b, id, { file, name, contentType: args['content-type'] ?? null, author: args.author ?? callerAddress() ?? callerName() })) });
884
1030
  say(args, result.id ? `Attached ${result.name} to ${id} (#${result.id}, sha256 ${result.sha256})` : `Upload ${args.upload}: ${result.received ?? 'aborted'}`, result);
885
1031
  return 0;
886
1032
  }
@@ -892,7 +1038,7 @@ export async function runVerb(argv) {
892
1038
  let target = join(dir, name);
893
1039
  for (let n = 1; existsSync(target); n += 1) target = join(dir, `${n}-${name}`);
894
1040
  copyFileSync(source, target);
895
- const n = withBoard((b) => engine.addAttachment(b, id, { file: { path: target, size: statSync(target).size }, name, contentType: args['content-type'] ?? null, author: args.author ?? callerName() }));
1041
+ const n = withBoard((b) => engine.addAttachment(b, id, { file: { path: target, size: statSync(target).size }, name, contentType: args['content-type'] ?? null, author: args.author ?? callerAddress() ?? callerName() }));
896
1042
  say(args, `Attached ${name} to ${id} (#${n})`, { ok: true, id: n, path: target }); return 0;
897
1043
  }
898
1044
  case 'attachments': {
@@ -928,17 +1074,7 @@ export async function runVerb(argv) {
928
1074
  case 'person-done': {
929
1075
  need(1, 'a card id');
930
1076
  if (sid) throw new Error('A person\'s completion is reported by the Workplace integration from the home, never by a session; nothing was done.');
931
- // The board's refusal (the card is no person's, or another person's, or already ended) is said with exit code 2: the
932
- // reporter does not send it again. Any other failure is the reporter's to retry; the same report again is `already`.
933
- let done;
934
- try { done = await withBoardAsync((b) => engine.personDone(b, id, { person: args.person ?? null, result: textFrom(args['result-file'], args.result), task: args['workplace-task'] ?? null })); }
935
- catch (error) {
936
- // the card's refusal is final, as dispatch.mjs takes a moved card's (a transition that does not apply there), and
937
- // so is an answer refused as a credential dump: sent again, it is refused again
938
- if (!error.refusal && !error.noTransition && !/does not apply|there is for/.test(String(error.message)) && error.code !== 'credential_dump') throw error;
939
- say(args, `${id} refused ${args.person ?? 'the person'}'s completion: ${error.message}`, { ok: false, card: id, person: args.person ?? null, refused: error.message });
940
- return 2;
941
- }
1077
+ const done = await withBoardAsync((b) => engine.personDone(b, id, { person: args.person ?? null, result: textFrom(args['result-file'], args.result), task: args['workplace-task'] ?? null }));
942
1078
  say(args, done.already ? `${id} was already completed by ${args.person}` : `Completed ${id} for ${args.person}`, { ok: true, card: id, person: args.person, already: done.already });
943
1079
  return 0;
944
1080
  }
@@ -964,42 +1100,56 @@ export async function runVerb(argv) {
964
1100
  case 'unblock': {
965
1101
  need(1, 'a card id');
966
1102
  const results = [];
967
- for (const t of ids()) results.push([t, (await withBoardAsync((b) => engine.unblock(b, t, { reason: args.reason ?? null, on: args.on ?? args['waiting-on'] ?? null }))).to]);
1103
+ const applied = [];
1104
+ for (const t of ids()) {
1105
+ const result = await withBoardAsync((b) => engine.unblock(b, t, { reason: args.reason ?? null, on: args.on ?? args['waiting-on'] ?? null }));
1106
+ results.push([t, result.to]);
1107
+ if (!result.routed && !result.accepted) applied.push(t);
1108
+ }
968
1109
  // Hermes records the reason as a comment on each card it unblocks
969
- if (args.reason) for (const [t] of results) await withBoardAsync((b) => engine.comment(b, t, callerName(), `UNBLOCK: ${args.reason}`));
1110
+ if (args.reason) for (const t of applied) await withBoardAsync((b) => engine.comment(b, t, callerAddress() ?? callerName(), `UNBLOCK: ${args.reason}`));
970
1111
  say(args, results.map(([t, s]) => `Unblocked ${t} → ${s}`).join('\n'), { ok: true, results }); return 0;
971
1112
  }
972
- case 'request-review': need(1, 'a card id'); await withBoardAsync((b) => engine.requestReview(b, id, { summary: args.summary ?? null, reviewer: args.reviewer ?? null, metadata: metadataOf(args.metadata) ?? null, commit: args.commit ?? null, branch: args.branch ?? null, pr: args.pr ?? null, pr_head: args['pr-head'] ?? args.head ?? args.commit ?? null, plan: Boolean(args.plan), force: Boolean(args.force), expectedRunId: workerRun(b.db, id, sid) })); say(args, `${id} → review`, { ok: true }); return 0;
1113
+ case 'request-review': {
1114
+ need(1, 'a card id');
1115
+ const result = await withBoardAsync((b) => engine.requestReview(b, id, { summary: args.summary ?? null, reviewer: args.reviewer ?? null, metadata: metadataOf(args.metadata) ?? null, commit: args.commit ?? null, branch: args.branch ?? null, pr: args.pr ?? null, pr_head: args['pr-head'] ?? args.head ?? args.commit ?? null, plan: Boolean(args.plan), force: Boolean(args.force), supersedes: args.supersedes ?? null, expectedRunId: workerRun(b.db, id, sid) }));
1116
+ say(args, `${id} → review`, result);
1117
+ return result.ok === false ? 1 : 0;
1118
+ }
973
1119
  case 'request-changes': {
974
- need(2, 'a card id and the reason');
1120
+ need(1, 'a card id');
975
1121
  // each finding is filed as a task on the arc first: the arc's review is due again once every one is closed (ci.mjs answeredChanges)
976
1122
  const findings = [].concat(args.finding ?? []).map((f) => String(f).trim()).filter(Boolean);
977
1123
  const filed = [];
978
1124
  for (const finding of findings) filed.push((await withBoardAsync((b) => engine.createCard(b, { title: finding, subtaskOf: id }))).id);
979
- await withBoardAsync((b) => engine.requestChanges(b, id, { reason: rest.slice(1).join(' '), reviewer: args.reviewer ?? null }));
1125
+ await withBoardAsync((b) => engine.requestChanges(b, id, { reason: args.reason ?? (rest.slice(1).join(' ') || 'none given'), reviewer: args.reviewer ?? null }));
980
1126
  say(args, `${id} sent back for changes${filed.length ? `; findings filed as ${filed.join(', ')}` : ''}`, { ok: true, findings: filed }); return 0;
981
1127
  }
982
1128
  case 'reopen': {
983
1129
  // a done card back to work; the cards that built on it are invalidated with it (Hermes's reopen)
984
1130
  need(1, 'a card id');
985
1131
  const results = [];
986
- for (const t of ids()) results.push([t, (await withBoardAsync((b) => engine.reopen(b, t, { author: callerName(), reason: args.reason ?? null }))).to]);
1132
+ for (const t of ids()) results.push([t, (await withBoardAsync((b) => engine.reopen(b, t, { author: callerAddress() ?? callerName(), reason: args.reason ?? null }))).to]);
987
1133
  say(args, results.map(([t, s]) => `Reopened ${t} → ${s}`).join('\n'), { ok: true, results }); return 0;
988
1134
  }
989
1135
  case 'reopen-review': {
990
1136
  need(1, 'a card id');
991
1137
  const results = [];
992
- for (const t of ids()) { const moved = await withBoardAsync((b) => engine.reopenReview(b, t, { reason: args.reason ?? null })); results.push([t, moved.to, moved.restarted_run ?? null]); }
1138
+ for (const t of ids()) { const moved = await withBoardAsync((b) => engine.reopenReview(b, t, { reason: args.reason ?? null })); results.push([t, moved.to, moved.restarted_run ?? null, moved.returned_run ?? null]); }
993
1139
  // a review still running with no verdict is restarted, not voided: its run ends, its reviewer session is closed and
994
- // the board's next review run resumes that same session with the round's brief
995
- say(args, results.map(([t, s, run]) => run ? `Restarted ${t}'s review: run ${run} ended with no verdict and its reviewer session was closed; the board's next review run resumes it (${s}). No verdict was voided.` : `Reopened ${t} → ${s}`).join('\n'), { ok: true, results }); return 0;
1140
+ // the board's next review run resumes that same session with the round's brief; an arc re-declared since its request
1141
+ // is returned to its coder instead. A PR with a newer queued request returns only its obsolete execution; the
1142
+ // retained queue owns that PR's next review through ordinary admission.
1143
+ say(args, results.map(([t, s, run, returned, queued]) => queued ? `Returned ${t}'s obsolete review: run ${returned} ended with no verdict. Newest queued request ${queued} for that PR retains its receipt and position for ordinary review admission.`
1144
+ : returned ? `Returned ${t}'s review to its coder (${s}): run ${returned} ended with no verdict, since the arc re-declared its sources after its review request. Its next review is of the declared set, requested only when the ordinary review rules make one due (for example a declared PR at a head no verdict has judged, or the final review once every PR is merged at the head its PR review passed). No verdict was voided.`
1145
+ : run ? `Restarted ${t}'s review: run ${run} ended with no verdict and its reviewer session was closed; the board's next review run resumes it (${s}). No verdict was voided.` : `Reopened ${t} → ${s}`).join('\n'), { ok: true, results }); return 0;
996
1146
  }
997
1147
  case 'return-review': {
998
1148
  // the manager's door out of review: a card there by mistake goes back to its coder, its review run ending as returned
999
1149
  need(1, 'a card id');
1000
1150
  const reason = args.reason ?? (rest.slice(1).join(' ') || null);
1001
1151
  const moved = await withBoardAsync((b) => engine.returnReview(b, id, { reason }));
1002
- say(args, `Returned ${id} from review → ${moved.to}: its review run ended as returned with no verdict; its coder continues, and the board reviews again once a pushed PR head changes.`, { ok: true, ...moved }); return 0;
1152
+ say(args, `Returned ${id} from review → ${moved.to}: its review run ended as returned with no verdict; its coder continues, and the board reviews again once a pushed PR head changes or the arc re-declares its sources (comment --pr, --pr none, --source, --source none).`, { ok: true, ...moved }); return 0;
1003
1153
  }
1004
1154
  case 'promote': {
1005
1155
  need(1, 'a card id');
@@ -1012,14 +1162,17 @@ export async function runVerb(argv) {
1012
1162
  const ok = ['todo', 'blocked'].includes(card.task.status) && (!open.length || args.force);
1013
1163
  return { id: t, ok, reason: ok ? null : !['todo', 'blocked'].includes(card.task.status) ? `${t} is ${card.task.status}` : `parents not done: ${open.join(', ')}` };
1014
1164
  }));
1015
- say(args, verdicts.map((v) => `${v.ok ? 'would promote' : 'would refuse'} ${v.id}${v.reason ? `: ${v.reason}` : ''}`).join('\n'), verdicts);
1165
+ say(args, verdicts.map((v) => `${v.ok ? 'would promote' : 'would record request'} ${v.id}${v.reason ? `: ${v.reason}` : ''}`).join('\n'), verdicts);
1016
1166
  return 0;
1017
1167
  }
1018
- for (const t of targets) await withBoardAsync((b) => engine.promote(b, t, { actor: callerName(), reason, force: args.force }));
1168
+ for (const t of targets) await withBoardAsync((b) => engine.promote(b, t, { actor: callerAddress() ?? callerName(), reason, force: args.force }));
1019
1169
  say(args, targets.map((t) => `Promoted ${t} → ready`).join('\n'), { ok: true }); return 0;
1020
1170
  }
1021
1171
  case 'archive': {
1022
- for (const t of rest) await withBoardAsync((b) => engine.archive(b, t, { reason: args.reason }));
1172
+ for (const t of rest) {
1173
+ const result = await withBoardAsync((b) => engine.archive(b, t, { reason: args.reason }));
1174
+ if (result.routed || result.accepted) { say(args, '', null); return 0; }
1175
+ }
1023
1176
  const purged = [];
1024
1177
  for (const t of args.rm ?? []) {
1025
1178
  withBoard((b) => tx(b, (db) => {
@@ -1049,6 +1202,7 @@ export async function runVerb(argv) {
1049
1202
  results.push({ id: t, children: await withBoardAsync((b) => engine.decompose(b, t, children, { author: args.author ?? callerAddress() ?? callerName() })) });
1050
1203
  } else {
1051
1204
  const session = await withBoardAsync((b) => startCard(b, t, { lanes, settings, roles: rolesFor(b.db, t, sid, workflowFor(root).params) }));
1205
+ if (session?.routed) { results.push({ id: t, ...session }); continue; }
1052
1206
  results.push({ id: t, session: session.address, mode: session.mode });
1053
1207
  }
1054
1208
  }
@@ -1064,7 +1218,7 @@ export async function runVerb(argv) {
1064
1218
  const order = ['info', 'warning', 'error', 'critical'];
1065
1219
  const found = withBoard((b) => {
1066
1220
  const cards = args.task ? [engine.getTask(b.db, args.task)].filter(Boolean) : engine.listCards(b.db, stateOptions(root));
1067
- return [...(args.task ? [] : engine.diagnosePublishing(publishing).map((d) => ({ task: '(board)', title_of_task: null, ...d }))),
1221
+ return [...[...dispatcherDiagnostics(slug), ...(args.task ? [] : engine.diagnosePublishing(publishing))].map((d) => ({ task: '(board)', title_of_task: null, ...d })),
1068
1222
  ...cards.flatMap((t) => engine.diagnose(b.db, t, { ...stateOptions(root), failureLimit: settings.failureLimit, fleet, launchWindowSeconds: settings.launchWindow, publishing, expiredMail, waitingMail }).map((d) => ({ task: t.id, title_of_task: t.title, ...d })))]
1069
1223
  .filter((d) => !args.severity || order.indexOf(d.severity) >= order.indexOf(args.severity));
1070
1224
  });
@@ -1219,22 +1373,35 @@ export async function runVerb(argv) {
1219
1373
  else for (const r of reports) current().out(`${r.board}: ${r.error ? `error: ${r.error}` : `${r.effects_waiting ?? 0} effect(s) waiting, ${r.effects_failed ?? 0} failed for good, ${r.notified.length} notified`}\n`);
1220
1374
  return reports.some((r) => r.error) ? 1 : 0;
1221
1375
  }
1222
- case 'daemon': case 'serve': return serve(root, args);
1223
-
1224
1376
  case 'gc': {
1225
1377
  const eventDays = Number(args['event-retention-days'] ?? 30);
1226
1378
  const logDays = Number(args['log-retention-days'] ?? 30);
1227
1379
  let removedWs = 0;
1228
1380
  const scratchRoot = slug === DEFAULT_BOARD ? join(root, 'kanban', 'workspaces') : join(boardDir(root, slug), 'workspaces');
1229
- const archived = withBoard((b) => b.db.prepare("SELECT t.id,t.workspace_kind,t.workspace_path,c.machine FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.status = 'archived'").all());
1381
+ // archived cards, and closed cards whose worktree was last kept (its merge may have landed since); a worktree
1382
+ // last removed, or last refused (a folder the board never removes), is not read again
1383
+ const archived = withBoard((b) => {
1384
+ const last = b.db.prepare("SELECT kind, json_extract(payload, '$.refused') AS refused FROM task_events WHERE task_id = ? AND kind IN ('worktree_kept', 'worktree_removed') AND json_extract(payload,'$.path')=? ORDER BY id DESC LIMIT 1");
1385
+ return b.db.prepare(`SELECT t.id,t.status,t.workspace_kind,t.workspace_path,c.machine FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id
1386
+ WHERE t.status = 'archived' OR (t.workspace_kind = 'worktree' AND t.status IN ('done', 'cancelled'))`).all().filter((t) => {
1387
+ if (t.workspace_kind !== 'worktree') return t.status === 'archived';
1388
+ return cardWorktrees(b.db, t).some(allocation => {
1389
+ const seen = last.get(t.id, allocation.path);
1390
+ return !seen || seen.kind === 'worktree_kept' && !seen.refused;
1391
+ });
1392
+ });
1393
+ });
1394
+ const unread = [];
1230
1395
  for (const t of archived) {
1231
- if (!isLocalWorkspace(t)) continue;
1232
- if (t.workspace_kind === 'worktree' && t.workspace_path && existsSync(t.workspace_path)) {
1233
- // an archived card's worktree goes, as Hermes's gc removes it (the branch stays)
1234
- const r = spawnSync('git', ['-C', t.workspace_path, 'worktree', 'remove', '--force', t.workspace_path], { encoding: 'utf8' });
1235
- if (r.status === 0) removedWs += 1;
1396
+ if (t.workspace_kind === 'worktree') {
1397
+ // a closed card's worktree goes as the delivery pass retires it: only when its branch is merged, its tree clean
1398
+ // and its sessions closed, else kept and described on the card (engine cleanupWorkspace; the branch stays); a
1399
+ // card in a person's custody is theirs, and one card's failure leaves the rest of gc to run
1400
+ try { if (await withBoardAsync((b) => sessionOperation(b, { taskId: t.id, kind: 'workflow gc workspace cleanup' }, () => engine.cleanupWorkspace(b, t.id)))) removedWs += 1; }
1401
+ catch (error) { unread.push(`${t.id}: ${String(error?.message ?? error).slice(0, 200)}`); }
1236
1402
  continue;
1237
1403
  }
1404
+ if (!isLocalWorkspace(t) || t.status !== 'archived') continue;
1238
1405
  if (t.workspace_kind !== 'scratch') continue;
1239
1406
  const dir = resolve(t.workspace_path || join(scratchRoot, t.id));
1240
1407
  if (!dir.startsWith(`${resolve(scratchRoot)}/`) || !existsSync(dir)) continue;
@@ -1245,7 +1412,7 @@ export async function runVerb(argv) {
1245
1412
  let logs = 0;
1246
1413
  const logDir = logsDir(root, slug);
1247
1414
  if (existsSync(logDir)) for (const f of readdirSync(logDir)) { const p = join(logDir, f); if (statSync(p).isFile() && statSync(p).mtimeMs < Date.now() - logDays * 86400_000) { unlinkSync(p); logs += 1; } }
1248
- say(args, `GC complete: ${removedWs} workspace(s), ${events} event row(s), ${logs} log file(s) removed`, { workspaces: removedWs, events, logs });
1415
+ say(args, `GC complete: ${removedWs} workspace(s), ${events} event row(s), ${logs} log file(s) removed${unread.length ? `; worktrees not retired now: ${unread.join('; ')}` : ''}`, { workspaces: removedWs, events, logs, workspaces_kept: unread });
1249
1416
  return 0;
1250
1417
  }
1251
1418
  case 'compact': {
@@ -1282,7 +1449,7 @@ export async function runVerb(argv) {
1282
1449
  for (let tries = 0; tries < 5 && sized(`${path}-wal`) > 0; tries++) { vacuum('PRAGMA wal_checkpoint(TRUNCATE)'); if (sized(`${path}-wal`) > 0) await new Promise((done) => setTimeout(done, 1000)); }
1283
1450
  return { trimmed, reclaimed_bytes: before.bytes - onDisk(), wal_bytes: sized(`${path}-wal`), pages_before: before.pages, free_before: before.free, pages_after: pages(), free_after: free(), seconds: Math.round((performance.now() - began) / 100) / 10 };
1284
1451
  });
1285
- if (report.refused) { say(args, `${report.trimmed} older change(s) cut; no rebuild: ${report.refused}`, report); return 1; }
1452
+ if (report.refused) throw new Error(`${report.trimmed} older change(s) cut; rebuild needs the manager to resolve: ${report.refused}`);
1286
1453
  say(args, `${report.trimmed} older change(s) cut; the store and its WAL are ${Math.round(report.reclaimed_bytes / 1048576)} MB smaller on disk after ${report.seconds} s (${report.pages_before} pages, ${report.free_before} free, to ${report.pages_after}, ${report.free_after} free; WAL ${report.wal_bytes} bytes)`, report);
1287
1454
  return 0;
1288
1455
  }
@@ -1313,40 +1480,61 @@ export async function runVerb(argv) {
1313
1480
  // A headless worker records its harness session on its run (kanban-worker.mjs), through the owner like every board
1314
1481
  // write. Only the run the owner launched for this worker: the launch's secret (in the worker's environment alone,
1315
1482
  // its hash kept on the run), its card and its run are checked at the write, against the card's current, unended run.
1316
- need(2, 'a run id and a session id');
1483
+ need(1, 'a run id');
1317
1484
  // the secret is the worker's own environment (SUPERCODE_RUN_LAUNCH, sent as a caller fact), never an argument
1318
- const runId = Number(id), harness = args.harness ?? null, launch = current().facts.SUPERCODE_RUN_LAUNCH ?? '', card = typeof args.task === 'string' ? args.task : '';
1319
- if (!Number.isSafeInteger(runId) || !SAFE_SESSION.test(rest[1]) || (harness !== null && !SAFE_SESSION.test(harness)) || !launch || !card) throw new Error('run-session takes a run id, a session id, --harness and --task, from a worker the owner launched');
1485
+ const runId = Number(id), sessionId = rest[1] ?? null, harness = args.harness ?? null, launch = current().facts.SUPERCODE_RUN_LAUNCH ?? '', card = typeof args.task === 'string' ? args.task : '';
1486
+ if (!Number.isSafeInteger(runId) || (sessionId !== null && !SAFE_SESSION.test(sessionId)) || typeof harness !== 'string' || !SAFE_SESSION.test(harness) || !launch || !card || (sessionId === null && args['native-home'] == null)) throw new Error('run-session takes a run id, a session id or --native-home, --harness and --task, from a worker the owner launched');
1487
+ let nativeHome;
1488
+ if (args['native-home'] != null) {
1489
+ nativeHome = json(args['native-home'], null);
1490
+ const env = nativeHome?.env, homes = nativeHome?.homes;
1491
+ if (!env || typeof env !== 'object' || Array.isArray(env) || !homes || typeof homes !== 'object' || Array.isArray(homes)
1492
+ || Object.values(env).some(value => value !== null && typeof value !== 'string')) throw new Error('native-home must name the native store and its path selectors');
1493
+ const canonical = await resolveNativeSessionHome(harness, env, process.cwd());
1494
+ const same = (left, right) => Object.keys(left).length === Object.keys(right).length && Object.entries(left).every(([key, value]) => Object.hasOwn(right, key) && right[key] === value);
1495
+ if (!canonical || !same(homes, canonical.homes) || !same(env, canonical.env) || Object.keys(nativeHome).length !== 2) throw new Error('native-home must contain only the absolute native store and its canonical path selectors');
1496
+ nativeHome = canonical;
1497
+ }
1320
1498
  const refused = withBoard((b) => tx(b, (db) => {
1321
1499
  const row = db.prepare('SELECT r.task_id, r.ended_at, r.metadata, t.current_run_id FROM task_runs r JOIN tasks t ON t.id = r.task_id WHERE r.id = ?').get(runId);
1322
1500
  const saved = json(row?.metadata, {})?.supercode ?? {};
1323
1501
  if (!row || row.task_id !== card || row.ended_at != null || Number(row.current_run_id) !== runId || saved.surface !== 'headless' || !saved.launch_secret
1324
- || saved.launch_secret !== createHash('sha256').update(launch).digest('hex') || (saved.session_id && saved.session_id !== rest[1])) return `run ${runId} is not a launch this worker holds; nothing was recorded`;
1325
- engine.setRunSession(db, runId, { ...saved, session_id: rest[1], harness });
1502
+ || saved.launch_secret !== createHash('sha256').update(launch).digest('hex') || (sessionId && saved.session_id && saved.session_id !== sessionId)) return `run ${runId} is not a launch this worker holds; nothing was recorded`;
1503
+ if (nativeHome && saved.native_home && JSON.stringify(saved.native_home) !== JSON.stringify(nativeHome)) return `run ${runId} already names another native home; nothing was recorded`;
1504
+ assertHeadlessBinding(b, card, runId, harness, sessionId ?? saved.session_id ?? null);
1505
+ engine.setRunSession(db, runId, { ...saved, ...(sessionId ? { session_id: sessionId, launch_pending: false } : {}), harness, ...(nativeHome ? { native_home: nativeHome } : {}) });
1326
1506
  return null;
1327
1507
  }));
1328
1508
  if (refused) throw new Error(refused);
1329
- say(args, `run ${runId}: session ${rest[1]}`, { ok: true }); return 0;
1509
+ say(args, `run ${runId}: ${sessionId ? `session ${sessionId}` : 'native home recorded'}`, { ok: true }); return 0;
1330
1510
  }
1331
1511
  case 'dispatching': {
1332
1512
  // the board's dispatching switch (its owner's controller): `dispatching` says it, `dispatching on|off` sets it
1333
1513
  if (id && !['on', 'off'].includes(id)) throw new Error('dispatching takes on or off');
1334
1514
  // switching it is the manager's: a caller outside any session, or a session the workflow's params.managers names
1335
- if (id && sid && !withBoard((b) => rolesFor(b.db, '', sid, workflowFor(root).params)).includes('manager')) throw new Error(`switching dispatching is the manager's act; ${callerAddress()} is not a manager of this board (params.managers)`);
1515
+ if (id && sid && !withBoard((b) => rolesFor(b.db, '', sid, workflowFor(root).params)).includes('manager')) {
1516
+ refuseUnreadManager();
1517
+ throw new Error(`switching dispatching is the manager's act; ${callerAddress()} is not a manager of this board (params.managers)`);
1518
+ }
1336
1519
  const state = id ? setDispatching(root, slug, id === 'on', args.reason ?? rest.slice(1).join(' ')) : dispatchingOf(root, slug);
1337
1520
  say(args, `board ${slug}: dispatching ${state.on ? 'on' : 'off'}${state.at ? ` since ${state.at}` : ''}${state.by ? ` by ${state.by}` : ''}${state.reason ? ` (${state.reason})` : ''}`, { board: slug, ...state });
1338
1521
  return 0;
1339
1522
  }
1340
1523
  case 'pause': {
1341
1524
  if (id) {
1342
- await withBoardAsync((b) => engine.act(b, id, 'pause', { payload: { reason: args.reason ?? rest.slice(1).join(' ') } }));
1525
+ await withBoardAsync((b) => engine.act(b, id, 'pause', { payload: { reason: args.reason ?? rest.slice(1).join(' '), until: args.until } }));
1343
1526
  say(args, `${id} paused`, { ok: true }); return 0;
1344
1527
  }
1345
1528
  // Hermes's emergency stop: new starts stop on every board of the home; running sessions go on
1346
- writeFileSync(join(root, 'ESTOP'), `${JSON.stringify({ engaged_at: new Date().toISOString(), reason: rest.join(' ') || null })}\n`);
1529
+ writeFileSync(join(root, 'ESTOP'), `${JSON.stringify({ engaged_at: new Date().toISOString(), reason: args.reason ?? (rest.join(' ') || null) })}\n`);
1347
1530
  say(args, `New starts paused (${join(root, 'ESTOP')}); \`supercode workflow resume\` lifts it`, { ok: true, paused: join(root, 'ESTOP') });
1348
1531
  return 0;
1349
1532
  }
1533
+ case 'backlog': case 'plan': {
1534
+ need(1, 'a card id');
1535
+ await withBoardAsync(b => engine.act(b, id, verb, { payload: { reason: args.reason ?? null, ...(args['pause-revision'] ? { pause_revision: args['pause-revision'] } : {}) } }));
1536
+ say(args, `${id} ${verb === 'backlog' ? 'placed in backlog' : 'planned into Todo'}`, { ok: true }); return 0;
1537
+ }
1350
1538
  case 'resume': {
1351
1539
  if (id) {
1352
1540
  await withBoardAsync((b) => engine.act(b, id, 'resume'));
@@ -1368,7 +1556,7 @@ export async function runVerb(argv) {
1368
1556
  const person = args.to ?? (principalOf()?.startsWith('person:') ? principalOf() : null);
1369
1557
  const detached = withBoard(b => {
1370
1558
  const roles = rolesFor(b.db, id, sid, workflowFor(root).params);
1371
- if (!roles.includes('manager') && !(roles.includes('creator') && principalOf()?.startsWith('person:') && person === principalOf())) throw new Error('Only the manager or the signed-in person who created the card detaches its session.');
1559
+ if (!current().glass && !roles.includes('manager') && !(roles.includes('creator') && principalOf()?.startsWith('person:') && person === principalOf())) throw new Error('Only the manager or the signed-in person who created the card detaches its session.');
1372
1560
  return detachSession(b, id, person);
1373
1561
  });
1374
1562
  say(args, `${id}: ${detached.session.address} belongs to ${detached.person}; the dispatcher no longer owns it`, { ok: true, detached });
@@ -1392,27 +1580,29 @@ export async function runVerb(argv) {
1392
1580
  }
1393
1581
  case 'adopt': {
1394
1582
  need(2, 'a card id and a session');
1395
- withBoard(b => { if (!rolesFor(b.db, id, sid, workflowFor(root).params).includes('manager')) throw new Error('Only the manager adopts an existing session.'); });
1583
+ // a card's session that broke glass passed this same guard in authorize (its board-verb clause), recorded
1584
+ withBoard(b => { if (!current().glass && !rolesFor(b.db, id, sid, workflowFor(root).params).includes('manager')) throw new Error('Only the manager adopts an existing session.'); });
1396
1585
  if (args.side) {
1397
1586
  withBoard((b) => attachSideSession(b, id, rest[1]));
1398
1587
  say(args, `${rest[1]} is a side session of ${id}: it works ${id}'s subtasks and comments on ${id}; ${id}'s run and state stay its main session's`, { ok: true, side: rest[1] });
1399
1588
  return 0;
1400
1589
  }
1401
- withBoard((b) => requestAdoption(b, id, rest[1], { workspace: args.workspace ?? null, assignee: args.assignee ?? null, role: args.role ?? null }));
1590
+ await withBoardAsync((b) => requestAdoption(b, id, rest[1], { workspace: args.workspace ?? null, assignee: args.assignee ?? null, role: args.role ?? null }));
1402
1591
  say(args, `${id} is held for ${rest[1]}; the dispatcher verifies and binds it on its next tick (session_adopted, or adoption_failed with the reason)`, { ok: true, requested: rest[1] });
1403
1592
  return 0;
1404
1593
  }
1405
1594
  case 'release': {
1406
1595
  need(2, 'a card id and its side session');
1407
- withBoard(b => { if (!rolesFor(b.db, id, sid, workflowFor(root).params).includes('manager')) throw new Error('Only the manager releases a side session.'); });
1596
+ withBoard(b => { if (!current().glass && !rolesFor(b.db, id, sid, workflowFor(root).params).includes('manager')) throw new Error('Only the manager releases a side session.'); });
1408
1597
  withBoard((b) => releaseSideSession(b, id, rest[1]));
1409
1598
  say(args, `${rest[1]} is no longer a side session of ${id}`, { ok: true, released: rest[1] });
1410
1599
  return 0;
1411
1600
  }
1412
1601
  case 'goto': {
1413
1602
  need(2, 'a card id and the status to take it to');
1414
- const { _: _words, root: _r, board: _b, json: _j, at: _a, ...fields } = args;
1603
+ const { _: _words, root: _r, board: _b, json: _j, at: _a, 'break-glass': _g, ...fields } = args;
1415
1604
  const moved = await withBoardAsync((b) => engine.goto(b, id, rest[1], { payload: { ...fields, reason: args.reason ?? (rest.slice(2).join(' ') || null) } }));
1605
+ if (moved.accepted) { say(args, '', null); return 0; }
1416
1606
  say(args, moved.event ? `${id} → ${moved.to} (${moved.event.replace(/_/g, '-')})` : `${id} is already ${moved.to}`, { ok: true, ...moved }); return 0;
1417
1607
  }
1418
1608
  case 'close': {
@@ -1426,11 +1616,8 @@ export async function runVerb(argv) {
1426
1616
  // any other event the board's workflow declares is a verb of its own: `<event> <id>... [reason] [--<field> value]`,
1427
1617
  // the flags its payload (the doors a session is told name these)
1428
1618
  const name = verb.replace(/-/g, '_');
1429
- const { workflow } = workflowFor(root);
1430
- const declared = Object.values(workflow.statuses).some((s) => s.on?.[name]) || Boolean(workflow.on?.[name] || workflow.verbs?.[name] || workflow.run?.on?.[name]);
1431
- if (!declared) throw new Error(`no workflow verb ${verb}; \`supercode workflow help\` lists them`);
1432
1619
  need(1, 'a card id');
1433
- const { _: _words, root: _r, board: _b, json: _j, at: _a, ids: _i, ...fields } = args;
1620
+ const { _: _words, root: _r, board: _b, json: _j, at: _a, 'break-glass': _g, ids: _i, ...fields } = args;
1434
1621
  const payload = { ...fields, reviewed_sha: args['reviewed-sha'] ?? fields.reviewed_sha, pr_head: args['pr-head'] ?? fields.pr_head, waiting_on: args['waiting-on'] ?? fields.waiting_on, reason: args.reason ?? (name === 'request_state' ? null : rest.slice(1).join(' ') || null) };
1435
1622
  const results = [];
1436
1623
  if (name === 'request_state') results.push(...await withBoardAsync(board => engine.requestStateMany(board, [...rest, ...(args.ids ?? [])], payload)));
@@ -1553,13 +1740,17 @@ function scrub(db) {
1553
1740
  // Only the transfer copy is lowered. Keep supercode_cards.paused (actor/reason)
1554
1741
  // and add Hermes's ordinary blocked event so its own tools can explain/unblock it.
1555
1742
  function lowerHermesHolds(db) {
1556
- const rows = db.prepare(`SELECT t.id,c.paused FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id
1557
- WHERE t.status = 'paused' OR (t.status NOT IN ('done','archived','cancelled') AND c.paused IS NOT NULL AND c.paused != 'null')`).all();
1743
+ const rows = db.prepare(`SELECT t.id,c.paused,c.backlog FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id
1744
+ WHERE t.status = 'paused' OR (t.status NOT IN ('done','archived','cancelled') AND (
1745
+ (c.paused IS NOT NULL AND c.paused != 'null') OR (c.backlog IS NOT NULL AND c.backlog != 'null')))` ).all();
1558
1746
  for (const row of rows) {
1559
1747
  const pause = json(row.paused, {}) ?? {};
1748
+ const backlog = json(row.backlog, null);
1560
1749
  db.prepare("UPDATE tasks SET status='blocked',block_kind='needs_input' WHERE id=?").run(row.id);
1561
1750
  db.prepare('INSERT INTO task_events(task_id,kind,payload,created_at) VALUES (?,?,?,?)').run(row.id, 'blocked',
1562
- JSON.stringify({ kind: 'needs_input', reason: pause.reason ?? 'Paused on the native board', by: pause.by ?? null, source: 'native_pause_export' }), now());
1751
+ JSON.stringify({ kind: 'needs_input', reason: backlog?.reason ?? pause.reason ?? 'Paused on the native board', by: backlog?.by ?? pause.by ?? null,
1752
+ source: backlog ? 'native_backlog_export' : 'native_pause_export', loss: ['backlog and bounded pause are native flags; Hermes receives a needs_input block'],
1753
+ native: { paused: json(row.paused, null), backlog } }), now());
1563
1754
  }
1564
1755
  }
1565
1756
 
@@ -1720,33 +1911,55 @@ function ownCards(db, sessionId) {
1720
1911
 
1721
1912
  const READS = new Set(['list', 'ls', 'show', 'context', 'brief', 'runs', 'log', 'tail', 'watch', 'stats', 'assignees', 'diagnostics', 'diag', 'attachments', 'attachment-read', 'github-usage', 'notify-list', 'capacity', 'help', 'init']);
1722
1913
 
1723
- /** The main session of each agent the workflow names a manager (`sc:<machine>:agent:<name>`), asked of this machine's
1914
+ /** The main session of each agent the workflow names a manager (`sc:<machine>:agent:<name>`), asked of that machine's
1724
1915
  * daemon, which keeps agents' records (mail step 1c), once per call before its verb runs: a manager named by its agent
1725
- * address is that agent's main session on the board. Only a positive not-declared answer means no main; an unread
1726
- * identity stops the call before authorization, rather than denying a role it could not establish. */
1916
+ * address is that agent's main session on the board. Only a positive not-declared answer means no main. An unread
1917
+ * manager identity decides nothing: it is recorded for this call, unread, and the caller's roles from its own run, its
1918
+ * creator record or its arc are computed without it. A call those roles allow proceeds; only a call that would be
1919
+ * refused for want of a role, asked by a session that might be that manager, stops with `manager_identity_unavailable`
1920
+ * (call.mjs refuseUnreadManager), and is never granted by guess. */
1727
1921
  async function readAgentMains(root) {
1728
1922
  // A lookup belongs to this call, including a refusal. A startup miss must not become the resident owner's
1729
1923
  // permanent answer, and a replacement main must revoke the old main's manager role on the next call.
1730
- const agentMains = current().agentMains = new Map();
1924
+ const call = current(), agentMains = call.agentMains;
1731
1925
  const managers = [(await loadWorkflow(root).catch(() => null))?.params?.managers ?? []].flat().map(String);
1732
- for (const address of managers) {
1733
- const name = /^sc:[^:\s]+:agent:([^:\s]+)$/.exec(address)?.[1];
1734
- if (!name || agentMains.has(address)) continue;
1735
- try {
1736
- const answer = await machineCall(null, 'harness.v1.agent.show', { name, records_only: true, claim: { pid: process.pid, ppid: process.ppid, session: null, named: null, owner: null } }, { timeoutMs: 5_000 });
1737
- // EXIT_UNKNOWN is the daemon's explicit answer that this named agent is not declared.
1738
- if (answer?.code === 3) { agentMains.set(address, null); continue; }
1739
- const agent = answer?.agents?.[0];
1740
- if (answer?.code !== 0 || answer.agents?.length !== 1 || agent?.name !== name ||
1741
- typeof agent.main_session !== 'string' || !/^sc:[^:\s]+:[^:\s]+:[^:\s]+$/.test(agent.main_session))
1742
- throw new Error(answer?.text ?? 'the daemon returned no valid named agent and main session');
1743
- agentMains.set(address, agent.main_session);
1744
- } catch (cause) {
1745
- throw Object.assign(new Error(`Could not read who the caller is: manager ${address} could not be looked up (${cause.message ?? cause}). No role decision was made.`, { cause }), { code: 'manager_identity_unavailable' });
1746
- }
1747
- }
1926
+ call.managerDeadline ??= performance.now() + 5_000;
1927
+ await Promise.all([...new Set(managers)].map(async address => {
1928
+ const matched = /^sc:([^:\s]+):agent:([^:\s]+)$/.exec(address);
1929
+ if (!matched || agentMains.has(address)) return agentMains.get(address);
1930
+ const [, machine, name] = matched;
1931
+ const local = normalMachine(machine) === localMachine();
1932
+ const reading = (async () => {
1933
+ try {
1934
+ const timeoutMs = call.managerDeadline - performance.now();
1935
+ if (timeoutMs <= 0) throw new Error('the call\'s manager identity allowance expired before this lookup');
1936
+ const answer = await machineCall(local ? null : machine, 'harness.v1.agent.show', { name, records_only: true,
1937
+ ...(local ? { claim: { pid: process.pid, ppid: process.ppid, session: null, named: null, owner: null } } : {}) }, { timeoutMs });
1938
+ // EXIT_UNKNOWN is the daemon's explicit answer that this named agent is not declared.
1939
+ if (answer?.code === 3) return null;
1940
+ const agent = answer?.agents?.[0];
1941
+ if (answer?.code !== 0 || answer.agents?.length !== 1 || agent?.name !== name ||
1942
+ typeof agent.main_session !== 'string' || !/^sc:[^:\s]+:[^:\s]+:[^:\s]+$/.test(agent.main_session))
1943
+ throw new Error(answer?.text ?? 'the daemon returned no valid named agent and main session');
1944
+ return agent.main_session;
1945
+ } catch (cause) {
1946
+ // a caller outside any session is no agent's main, so an unread main cannot be its identity
1947
+ if (callerSession()) call.managerUnread ??= Object.assign(new Error(`Could not read who the caller is: manager ${address} could not be looked up (${cause.message ?? cause}). No role decision was made.`, { cause }), { code: 'manager_identity_unavailable' });
1948
+ return UNREAD;
1949
+ }
1950
+ })();
1951
+ agentMains.set(address, reading);
1952
+ agentMains.set(address, await reading);
1953
+ }));
1954
+ }
1955
+ const UNREAD = Object.freeze({ unread: true });
1956
+ // an unread main is no main for granting: it names no session
1957
+ function agentMain(address) { const main = current().agentMains.get(address); return typeof main === 'string' ? main : null; }
1958
+
1959
+ function managerSession(sessionId, params = {}, resolved = true) {
1960
+ const managers = Array.isArray(params.managers) ? params.managers.map(String) : [];
1961
+ return managers.some(m => m === sessionId || m.endsWith(`:${sessionId}`) || (resolved && agentMain(m)?.endsWith(`:${sessionId}`)));
1748
1962
  }
1749
- function agentMain(address) { return current().agentMains.get(address) ?? null; }
1750
1963
 
1751
1964
  /**
1752
1965
  * Who a caller is on card `cardId`, in the workflow's roles: a signed-in terminal person retains their creator role;
@@ -1756,8 +1969,7 @@ function agentMain(address) { return current().agentMains.get(address) ?? null;
1756
1969
  */
1757
1970
  function rolesFor(db, cardId, sessionId, params = {}) {
1758
1971
  // a session the workflow names as the board's manager (params.managers: its addresses) is the manager on every card
1759
- const managers = Array.isArray(params.managers) ? params.managers.map(String) : [];
1760
- if (managers.some((m) => m === sessionId || m.endsWith(`:${sessionId}`) || agentMain(m)?.endsWith(`:${sessionId}`))) return ['manager'];
1972
+ if (managerSession(sessionId, params)) return ['manager'];
1761
1973
  const task = engine.getTask(db, cardId);
1762
1974
  if (!task) return [];
1763
1975
  const roles = [];
@@ -1768,36 +1980,54 @@ function rolesFor(db, cardId, sessionId, params = {}) {
1768
1980
  if (current?.session_id === sessionId) roles.push(current.role ?? 'implementer');
1769
1981
  else if (engine.lastSession(db, cardId)?.session_id === sessionId) roles.push('implementer');
1770
1982
  if (task.subtask_of) {
1983
+ // the arc's working session stays its own while another run (its reviewer's) holds the arc, so it still closes the
1984
+ // arc's subtasks, whoever filed them (a reviewer's finding included)
1771
1985
  const parent = engine.getTask(db, task.subtask_of);
1772
- const worker = parent?.current_run_id ? engine.currentRun(db, parent.id)?.metadata?.supercode : engine.lastSession(db, task.subtask_of);
1773
- if (worker?.session_id === sessionId || sideSessions(db, task.subtask_of).some((s) => s.session === sessionId)) roles.push('arc');
1986
+ const running = parent?.current_run_id ? engine.currentRun(db, parent.id)?.metadata?.supercode : null;
1987
+ const working = running?.session_id === sessionId || engine.lastSession(db, task.subtask_of)?.session_id === sessionId;
1988
+ if (working || sideSessions(db, task.subtask_of).some((s) => s.session === sessionId)) roles.push('arc');
1774
1989
  }
1775
1990
  return roles;
1776
1991
  }
1777
1992
 
1993
+ /** The same card-scope guard for an ordinary write and local service admission. Manager authority is resolved by
1994
+ * each caller before it applies this refusal; a service can first discover that no such authority is needed. */
1995
+ function scopedSessionGuard(board, { root, slug, verb, rest, args, sid }) {
1996
+ const own = { ...ownCards(board.db, sid), side: sideArcs(board.db, sid) };
1997
+ if (!own.worked.size && !own.side.size) return null;
1998
+ const copyServe = isServiceVerb(verb) && Boolean(boardCopyOf(board)) && listBoards(root).every(board => !dispatchingOf(root, board).on);
1999
+ const refused = authorize(board.db, verb, rest, args, own, { copyServe });
2000
+ return refused.length ? { refused, scope: { verb, args, own, slug, sid } } : null;
2001
+ }
2002
+
1778
2003
  /**
1779
2004
  * What a session that works a card, or is a side session of an arc (`own.side`), may name: its own cards and their
1780
2005
  * subtasks, and the subtasks of the arcs it is a side session of, and those arcs themselves only to comment on. What it
1781
- * may do to them is the workflow's (rolesFor).
2006
+ * may do to them is the workflow's (rolesFor). Answers every guard the write passes outside that, each as
2007
+ * `{ why, card }` (the card it names, or null for a board verb); none is an ordinary write. A guarded write is not
2008
+ * forbidden: given on purpose with a reason it goes ahead and is recorded (breakGlassOrRefuse).
1782
2009
  */
1783
- function authorize(db, verb, rest, args, own, { copyServe = false } = {}) {
2010
+ function authorize(db, verb, rest, args, own, { copyServe }) {
1784
2011
  const cards = [...new Set([...own.worked, ...own.side])];
1785
- const named = cards.join(', ');
1786
- const refuse = (why) => { throw new Error(`the session working ${named} ${why}`); };
1787
- if (verb === 'serve' && copyServe) return;
2012
+ const refused = [];
2013
+ const refuse = (why, card = null) => { if (!refused.some((r) => r.why === why)) refused.push({ why, card }); };
2014
+ if (copyServe) return refused;
1788
2015
  if (verb === 'create') {
1789
- if (!own.worked.has(args['subtask-of']) && !own.side.has(args['subtask-of'])) refuse(`files subtasks of its own card only (--subtask-of ${cards.join(' or ')})`);
1790
- return;
2016
+ // a subtask of its own card's subtask is inside its card: the engine answers it (subtasks are one level deep), not this guard
2017
+ const named = args['subtask-of'] ? engine.getTask(db, args['subtask-of']) : null;
2018
+ const ownArc = (id) => own.worked.has(id) || own.side.has(id);
2019
+ if (!ownArc(args['subtask-of']) && !(named?.subtask_of && ownArc(named.subtask_of))) refuse(`files subtasks of its own card only (--subtask-of ${cards.join(' or ')})`, args['subtask-of'] ?? null);
2020
+ return refused;
1791
2021
  }
1792
- if (verb === 'boards') { if (!['list', 'ls', 'show', 'current', undefined].includes(rest[0])) refuse('does not change the home\'s boards'); return; }
2022
+ if (verb === 'boards') { if (!['list', 'ls', 'show', 'current', undefined].includes(rest[0])) refuse('does not change the home\'s boards'); return refused; }
1793
2023
  // verbs that act on the board rather than on a card are the manager's and the board's, never a card's session's
1794
2024
  // (what a session may do to a card is its workflow's: each transition's `by`)
1795
- if ((['pause', 'resume'].includes(verb) && !rest[0]) || ['swarm', 'dispatch', 'daemon', 'serve', 'gc', 'compact', 'repair', 'adopt', 'release', 'detach', 'close', 'claim', 'attach-rm', 'migrate'].includes(verb)) refuse(`does not ${verb}; the fleet's manager does`);
1796
- if (verb === 'set' && !(own.worked.has(rest[0]) && rest[1] === 'every')) refuse("sets only Every on its own auditor card; other properties are the manager's");
2025
+ if ((['pause', 'resume'].includes(verb) && !rest[0]) || ['swarm', 'dispatch', 'daemon', 'serve', 'gc', 'compact', 'repair', 'adopt', 'release', 'detach', 'close', 'claim', 'attach-rm', 'migrate'].includes(verb)) refuse(`does not ${verb}; the fleet's manager does`, rest[0] ?? null);
2026
+ if (verb === 'set' && !(own.worked.has(rest[0]) && rest[1] === 'every')) refuse("sets only Every on its own auditor card; other properties are the manager's", rest[0] ?? null);
1797
2027
  if (args.all) refuse(`does not ${verb} every card; only its own`);
1798
2028
  if (['notify-subscribe', 'notify-unsubscribe'].includes(verb) && args['for-card']) {
1799
- if (!own.worked.has(args['for-card'])) refuse(`subscribes only its own arc (--for-card ${[...own.worked].join(' or ')})`);
1800
- return;
2029
+ if (!own.worked.has(args['for-card'])) refuse(`subscribes only its own arc (--for-card ${[...own.worked].join(' or ')})`, args['for-card']);
2030
+ return refused;
1801
2031
  }
1802
2032
  const targets = verb === 'request-state' ? [...rest, ...(args.ids ?? [])] : [rest[0], ...(args.ids ?? [])];
1803
2033
  for (const target of targets) {
@@ -1807,9 +2037,62 @@ function authorize(db, verb, rest, args, own, { copyServe = false } = {}) {
1807
2037
  if (verb === 'comment' && isLatestReviewer(db, card.id, actorOf())) continue;
1808
2038
  // the card it names is the one it addresses: any card it holds, or a subtask of one it works
1809
2039
  // a side session names its arc only to comment on it; the arc's other verbs are its main session's
1810
- if (own.side.has(card.id) && !own.held.has(card.id) && verb !== 'comment') refuse(`only comments on ${card.id}, which it is a side session of; the arc's other verbs are its main session's and the manager's`);
1811
- if (!own.held.has(card.id) && !own.worked.has(card.subtask_of) && !own.side.has(card.id) && !own.side.has(card.subtask_of)) refuse(`writes only its own card${cards.length > 1 ? 's' : ''} and ${cards.length > 1 ? 'their' : 'its'} subtasks, not ${card.id}`);
2040
+ if (own.side.has(card.id) && !own.held.has(card.id) && verb !== 'comment') refuse(`only comments on ${card.id}, which it is a side session of; the arc's other verbs are its main session's and the manager's`, card.id);
2041
+ if (!own.held.has(card.id) && !own.worked.has(card.subtask_of) && !own.side.has(card.id) && !own.side.has(card.subtask_of)) refuse(`writes only its own card${cards.length > 1 ? 's' : ''} and ${cards.length > 1 ? 'their' : 'its'} subtasks, not ${card.id}`, card.id);
2042
+ }
2043
+ return refused;
2044
+ }
2045
+
2046
+ /** Why a card's session's writes outside its own card are guarded, said with every refusal (owner, VO-3793: "the
2047
+ * purpose of this is to prevent the agents from adopting bad behaviors - so what we should do is allow it to be done
2048
+ * but require a reason flag and if that's not provided we complain instead of doing it - explaining why"). */
2049
+ const OUTSIDE_OWN_CARD = 'A card\'s session reports to the fleet\'s manager and writes its own card; a write outside it is '
2050
+ + 'reserved so agents do not drift into the habit of steering other cards and the board around the manager. If this write '
2051
+ + 'is needed, repeat it on purpose with --break-glass "<why it is needed>": this guard then lets it through and records it '
2052
+ + 'with this session, what it wrote and the reason. The workflow\'s own rules for who may make each transition still apply.';
2053
+
2054
+ /**
2055
+ * A card's session's write that authorize() guards: with `--break-glass "<reason>"` it passes this guard (and the
2056
+ * manager checks adopt, release and detach repeat), recorded as one entry in the machine's break-glass journal
2057
+ * (@volter/teams/break-glass, the record every other supercode guard's deliberate act keeps) naming the session, the
2058
+ * cards or verb it wrote outside its own card, every guard it passed and the reason. Answers that entry; runVerb follows
2059
+ * it with what became of the act when it was not simply done. Without a reason nothing is written, and the refusal
2060
+ * says what was refused, why that write is guarded and how to give the reason. An empty reason is no reason; the flag
2061
+ * alone opens it, never an inherited environment variable. The workflow's `by` roles are not this guard: a transition
2062
+ * they refuse is refused whatever the reason.
2063
+ */
2064
+ function breakGlassOrRefuse(refused, { verb, args, own, slug, sid }) {
2065
+ const named = [...new Set([...own.worked, ...own.side])].join(', ');
2066
+ const guards = refused.map((r) => `the session working ${named} ${r.why}`);
2067
+ const given = typeof args['break-glass'] === 'string' ? args['break-glass'].trim() : '';
2068
+ if (!given) {
2069
+ refuseUnreadManager();
2070
+ throw guardRefusal(`the session working ${named} ${refused.map((r) => r.why).join('; ')}. Nothing was done`, { refused: refused.map((r) => r.why) }, OUTSIDE_OWN_CARD);
1812
2071
  }
2072
+ const reason = breakGlassReason(given, {});
2073
+ const cards = [...new Set(refused.map((r) => r.card).filter(Boolean))];
2074
+ // the journal is this machine's; the agent is the caller's own (its facts), never the board owner's environment
2075
+ const env = { ...process.env };
2076
+ delete env.SUPERCODE_AGENT_ID;
2077
+ const entry = recordBreakGlass({ act: `workflow ${verb}${cards.length ? ` ${cards.join(' ')}` : ''}`,
2078
+ actor: { agent: current().facts?.SUPERCODE_AGENT_ID || null, session: callerAddress() ?? sid },
2079
+ actedFor: cards.length === 1 ? { kind: 'card', id: cards[0] } : cards.length ? { kind: 'cards', id: cards.join(',') } : { kind: 'board', id: slug },
2080
+ reason, detail: { board: slug, own_cards: named, guards } }, { env, stderr: { write: (text) => current().err(text) } });
2081
+ return { entry, env, created: null };
2082
+ }
2083
+
2084
+ /** What became of a break-glass act, appended to its journal entry's file: refused or failed (with why), or for a card
2085
+ * filed through it, done with the card's id. An act simply done adds nothing. */
2086
+ function followBreakGlass({ entry, env, created }, failure, code) {
2087
+ const outcome = failure
2088
+ ? (failure.code === 'forbidden' || /\bonly\b[^.]*\b(?:manager|implementer|creator|reviewer)|not authori[sz]ed|may not/i.test(failure.message ?? '') ? 'refused' : 'failed')
2089
+ : code ? 'failed' : 'done';
2090
+ if (outcome === 'done' && !created) return;
2091
+ const follow = { at: new Date().toISOString(), follows: entry.at, act: entry.act, actor: entry.actor, acted_for: entry.acted_for, outcome,
2092
+ ...(failure ? { error: String(failure.message ?? failure).slice(0, 500) } : code ? { exit: code } : {}), ...(created ? { created } : {}) };
2093
+ try { appendFileSync(breakGlassLog(env), `${JSON.stringify(follow)}\n`, { mode: 0o600 }); }
2094
+ catch (error) { current().err(`supercode: break glass: the outcome of ${entry.act} (${outcome}) could not be recorded: ${error.message}\n`); }
2095
+ if (outcome !== 'done') current().err(`supercode: break glass: ${entry.act} was ${outcome}; recorded in ${breakGlassLog(env)}\n`);
1813
2096
  }
1814
2097
 
1815
2098
  /** Close the live sessions of a card that is not running (its worker's and its reviewers'), freeing its machine's place. */
@@ -1877,7 +2160,11 @@ async function answerVerbs(root, log) {
1877
2160
  // naming the lock or socket as its input is refused (owner-files.mjs)
1878
2161
  let lock;
1879
2162
  try { lock = await holdLock(lockPath, { onLost: (error) => { log(`${error.message}: this home has no owner now; stopping`); process.exit(1); } }); }
1880
- catch (error) { throw new Error(`the boards of ${root} already have their owner (${lockPath} is held: ${error.message}); one serve per home`); }
2163
+ catch (error) {
2164
+ if (error?.code !== 'EHELD') throw new Error(`the boards of ${root} cannot be owned: ${lockPath} could not be locked (${error.message})`, { cause: error });
2165
+ throw new Error(`the boards of ${root} already have their owner (${lockPath} is held: ${error.message}); one serve per home`);
2166
+ }
2167
+ try {
1881
2168
  ownHome(home);
1882
2169
  // the lock held, this owner takes its fencing epoch and fences every board, before it answers anything (fence.mjs)
1883
2170
  const epoch = takeEpoch(root, () => listBoards(root).map((slug) => openBoard(root, slug)));
@@ -1888,11 +2175,21 @@ async function answerVerbs(root, log) {
1888
2175
  log(`owner epoch ${epoch}`);
1889
2176
  const path = boardOwnerSocket(root);
1890
2177
  try { unlinkSync(path); } catch { /* none left */ }
1891
- let closeDoor;
1892
- try { closeDoor = await openOwnerDoor(root, { machine: localMachine(), answer: (line) => answerOne(root, line), log }); }
1893
- catch (error) { lock.release(); throw error; }
2178
+ const closeDoor = await openOwnerDoor(root, { machine: localMachine(), answer: (line) => answerOne(root, line), log });
1894
2179
  log(`answering the card verbs of ${root} at ${path}`);
1895
- return () => { closeDoor(); try { unlinkSync(path); } catch { /* gone */ } lock.release(); };
2180
+ let closing;
2181
+ return () => closing ??= (async () => {
2182
+ const errors = [];
2183
+ try { await closeDoor(); } catch (error) { errors.push(error); }
2184
+ try { unlinkSync(path); } catch (error) { if (error.code !== 'ENOENT') errors.push(error); }
2185
+ try { await lock.release(); } catch (error) { errors.push(error); }
2186
+ if (errors.length) throw new AggregateError(errors, 'board command door shutdown failed', { cause: errors[0] });
2187
+ })();
2188
+ } catch (error) {
2189
+ try { await lock.release(); }
2190
+ catch (cleanup) { throw new AggregateError([error, cleanup], 'board door startup and lock cleanup failed', { cause: error }); }
2191
+ throw error;
2192
+ }
1896
2193
  }
1897
2194
 
1898
2195
  /** One request's own steps and the separate process activity during its wait.
@@ -1904,6 +2201,7 @@ export async function answerOne(root, line, { readOnly = false } = {}) {
1904
2201
  }
1905
2202
 
1906
2203
  async function answerOneAdmitted(root, line, { readOnly = false } = {}) {
2204
+ return measureWork("door", "board.command", async () => {
1907
2205
  const scoped = typeof slowLog.withStepScope === 'function';
1908
2206
  // The owner's receipt includes asynchronous custody preparation too: it is part of the caller's wait.
1909
2207
  const began = performance.now(), before = slowLog.processCounters?.() ?? null;
@@ -1945,7 +2243,10 @@ async function answerOneAdmitted(root, line, { readOnly = false } = {}) {
1945
2243
  }
1946
2244
  };
1947
2245
  return scoped ? slowLog.withStepScope(guarded) : guarded();
1948
- }
2246
+
2247
+
2248
+ });
2249
+ }
1949
2250
 
1950
2251
  /** One request answered in a call of its own; on the read door (`readOnly`) a listing only, and `{ byOwner: true }` for
1951
2252
  * one the owner is to answer (listing.mjs byOwner). */
@@ -1955,22 +2256,43 @@ async function answerRequest(root, line, naming, { readOnly = false } = {}) {
1955
2256
  const tool = request?.tool && typeof request.tool.name === 'string' ? request.tool : null;
1956
2257
  const argv = tool ? ['tool'] : Array.isArray(request?.argv) && request.argv.every((arg) => typeof arg === 'string') ? request.argv : null;
1957
2258
  if (request?.v !== 1 || !argv?.length) return { code: 1, stdout: '', stderr: 'supercode workflow: a request names a verb\n' };
1958
- const verb = parse(argv)._[0];
2259
+ const args = parse(argv, { contextOnly: true }), verb = args._[0];
1959
2260
  if (readOnly && (tool || !['list', 'ls'].includes(verb))) return { byOwner: true };
1960
- if (BOARD_OPERATION_VERBS.has(verb)) return { code: 1, stdout: '', stderr: `supercode workflow: ${verb} is run on the board's machine as its own act, not sent to the owner\n` };
1961
2261
  const session = request.session && typeof request.session.id === 'string' && ['claude-code', 'codex'].includes(request.session.harness) ? request.session : null;
1962
2262
  const caller = typeof request.caller === 'string' ? request.caller : session ? `sc:${localMachine()}:${session.harness}:${session.id}` : null;
1963
2263
  naming(tool ? `tool ${tool.name}` : verb, caller);
1964
2264
  const facts = Object.fromEntries(Object.entries(request.facts ?? {}).filter(([key, value]) => CALLER_FACTS.includes(key) && typeof value === 'string'));
1965
- // a caller names its Teams context (the CLI and the daemon always do): one that names none is refused before anything
1966
- // is done, never answered with the owner's
1967
- if (!facts.SUPERCODE_CONTEXT) return { code: 1, stdout: '', stderr: 'supercode workflow: the request names no Teams context (facts.SUPERCODE_CONTEXT); nothing was done\n' };
2265
+ // Route a contextless request under its own caller, without borrowing the owner's context.
1968
2266
  const cwd = typeof request.cwd === 'string' && request.cwd ? request.cwd : root;
1969
2267
  const stdin = typeof request.stdin === 'string' ? request.stdin : null;
1970
2268
  const stdout = [];
1971
2269
  const stderr = [];
1972
2270
  let code, byOwner = false;
1973
2271
  await within({ caller, facts, cwd, stdin, readOnly, out: (text) => { stdout.push(text); return true; }, err: (text) => { stderr.push(text); return true; } }, async () => {
2272
+ if (!tool && isServiceVerb(verb)) {
2273
+ let malformed;
2274
+ try { parse(argv); } catch (error) { malformed = error; }
2275
+ if (!malformed) throw localServiceDoorError();
2276
+ const named = typeof request.board === 'string' && request.board ? request.board : boardOf(root, {});
2277
+ current().boardCommand = { root, slug: named };
2278
+ code = await retainUnappliedRequest(argv, malformed);
2279
+ return;
2280
+ }
2281
+ const ownerOperation = BOARD_OPERATION_VERBS.has(verb) && !(verb === 'dispatch' && args['dry-run']);
2282
+ if (!facts.SUPERCODE_CONTEXT || ownerOperation) {
2283
+ if (readOnly) { byOwner = true; return; }
2284
+ await loadWorkflow(root);
2285
+ const named = typeof request.board === 'string' && request.board ? request.board : DEFAULT_BOARD;
2286
+ const board = openBoard(root, isView(root, named) ? DEFAULT_BOARD : named);
2287
+ try {
2288
+ setActor(caller ?? 'unresolved board caller');
2289
+ await engine.routeRequest(board, { name: tool?.name ?? verb, payload: tool ? { tool, argv } : { argv }, reason: ownerOperation
2290
+ ? 'This operation needs the board machine to settle its running owner before it can act.'
2291
+ : 'The caller context needs to be resolved before the requested act can run.' });
2292
+ say(args, '', null); code = 0;
2293
+ } finally { board.close(); }
2294
+ return;
2295
+ }
1974
2296
  // a worker's board tool (mcp/server.mjs), answered by the board's tools in this call (board/tools.mjs)
1975
2297
  if (tool) { stdout.push(await boardTool(root, tool.name, tool.args && typeof tool.args === 'object' ? tool.args : {}, Object.fromEntries(Object.entries(tool.env ?? {}).filter(([key, value]) => /^HERMES_(KANBAN_TASK|KANBAN_RUN_ID|KANBAN_BOARD|PROFILE)$/.test(key) && typeof value === 'string')))); code = 0; return; }
1976
2298
  try { code = await runVerb([...argv, '--root', root, ...(typeof request.board === 'string' && request.board ? ['--board', request.board] : [])]); }
@@ -1988,32 +2310,59 @@ async function answerRequest(root, line, naming, { readOnly = false } = {}) {
1988
2310
 
1989
2311
  // Prepared children import and read their declarations without writer authority. Activation and parking are ordered
1990
2312
  // on one control chain, so a late park acknowledgement can never park a generation after rollback resumed it.
1991
- export async function serveBoardGeneration(root, args = {}, argv = ['serve', '--root', root]) {
2313
+ export async function serveBoardGeneration(root, argv) {
1992
2314
  if (!inBoardGeneration) throw new Error('a board generation requires its stable owner IPC connection');
2315
+ const args = parse(argv);
2316
+ assertSourceReceipt(args);
2317
+ if (!['serve', 'daemon'].includes(args._[0]) || homeOf(args) !== root)
2318
+ throw new Error('a board generation starts only its stable owner\'s admitted home');
2319
+ // Watch before the preparatory read and keep that watch across activation. The serving loop attaches its own
2320
+ // callbacks to this same watch, so its first stats read reuses the prepared home unless an actual file changed.
2321
+ const unfollow = followHome(root, { onLost: error => {
2322
+ process.stderr.write(`board generation: the home's files can no longer be watched (${error.message})\n`);
2323
+ process.exit(1);
2324
+ } });
2325
+ try { return await serveWatchedBoardGeneration(root, args); }
2326
+ finally { unfollow(); }
2327
+ }
2328
+
2329
+ async function serveWatchedBoardGeneration(root, args) {
1993
2330
  // The direct parent has already resolved the machine identity. An unavailable daemon must not consume a second
1994
2331
  // fifteen-second lookup before this child can report preparation through its eight-second owner door.
1995
2332
  await knowLocalMachine(process.env.SUPERCODE_BOARD_GENERATION_MACHINE);
1996
2333
  // readHome owns an SDK transport startup and one orchestration.load, each with its existing 30-second bound.
1997
2334
  tellOwner({ type: 'startup-step', step: 'reading the home through its orchestration door', budget_ms: 65000 });
1998
2335
  await readHome(root);
2336
+ const restingAdmission = await prepareBoardRestingAdmission(root);
1999
2337
  tellOwner({ type: 'startup-step', step: 'reading board storage without writer authority', budget_ms: 8000 });
2338
+ // These substeps name the work inside the same eight-second preparation boundary. They carry no budget,
2339
+ // so progress cannot re-arm that boundary and turn one bounded read into several allowances.
2340
+ const preparedStep = (step, began) => {
2341
+ const ms = performance.now() - began;
2342
+ noteStep(`board preparation: ${step}`, ms);
2343
+ process.stdout.write(`${new Date().toISOString()} board generation ${process.pid}: preparation: ${step} ${Math.round(ms)}ms\n`);
2344
+ };
2000
2345
  await within({ cwd: root, readOnly: true, out: () => {}, err: () => {} }, async () => {
2001
2346
  for (const slug of listBoards(root)) {
2347
+ const step = `reading board ${slug} storage without writer authority`, began = performance.now();
2348
+ tellOwner({ type: 'startup-step', step });
2002
2349
  let board;
2003
2350
  try { board = openBoard(root, slug); board.db.prepare('SELECT 1').get(); }
2004
2351
  catch (error) { if (error?.code !== READ_BY_OWNER) throw error; }
2005
- finally { board?.close(); }
2352
+ finally { try { board?.close(); } finally { preparedStep(step, began); } }
2006
2353
  }
2007
2354
  });
2008
2355
  // Hermes's install is located once per process (hermes-cli.mjs), a read of PATH and its files: here, before this
2009
2356
  // generation is prepared, so its activation does not spend it while the public door routes nowhere.
2357
+ const hermesBegan = performance.now();
2358
+ tellOwner({ type: 'startup-step', step: 'locating Hermes for this board generation' });
2010
2359
  try { (await import('../hermes-cli.mjs')).hermesInstall(); } catch { /* none installed */ }
2360
+ finally { preparedStep('locating Hermes for this board generation', hermesBegan); }
2011
2361
  // Each activation and park step in the owner's log (the generation's output is that log), so a reading accounts
2012
2362
  // for every second the public door routes nowhere (ADR 0080, "Board ownership").
2013
2363
  const say = text => process.stdout.write(`${new Date().toISOString()} board generation ${process.pid}: ${text}\n`);
2014
2364
  let begin, started = false, controls = Promise.resolve();
2015
2365
  const activation = new Promise(done => { begin = done; });
2016
- const unwatchHealth = watchOwnerHealth(value => tellOwner({ type: 'health', ...value }));
2017
2366
  const unwatchWorkflow = watchWorkflows((at, { workflow, params, profiles }) => tellOwner({ type: 'workflow', root: at, entry: { workflow, params, profiles } }));
2018
2367
  const receive = frame => {
2019
2368
  if (frame?.type === 'request') {
@@ -2051,7 +2400,9 @@ export async function serveBoardGeneration(root, args = {}, argv = ['serve', '--
2051
2400
  tellOwner({ type: 'parked' });
2052
2401
  say('park: parked');
2053
2402
  } else {
2403
+ restingAdmission.preflight();
2054
2404
  grantGenerationWriter();
2405
+ await restingAdmission.activateWriter();
2055
2406
  if (!started) {
2056
2407
  say('activation: the writer granted; the native board dispatcher starts');
2057
2408
  tellOwner({ type: 'startup-step', phase: 'serving', step: 'starting the native board dispatcher', budget_ms: 65000 });
@@ -2076,15 +2427,21 @@ export async function serveBoardGeneration(root, args = {}, argv = ['serve', '--
2076
2427
  process.on('message', receive); process.once('disconnect', disconnected);
2077
2428
  // A transition cannot restore database files over committed card writes: retained generations must write the same
2078
2429
  // storage format (storage-contract.mjs), and a format change waits for a compatible migration with its own protocol.
2079
- tellOwner({ type: 'prepared', ...storageContract() });
2080
- try { await activation; return await runVerb(argv); }
2081
- finally { process.off('message', receive); process.off('disconnect', disconnected); unwatchHealth(); unwatchWorkflow(); }
2430
+ const contractBegan = performance.now();
2431
+ tellOwner({ type: 'startup-step', step: 'computing the board storage contract' });
2432
+ let contract;
2433
+ try { contract = storageContract({ restingAdmission: restingAdmission.firstReader ? 'first-reader' : restingAdmission.compatible ? 'compatible-reader' : 'legacy-reader' }); }
2434
+ finally { preparedStep('computing the board storage contract', contractBegan); }
2435
+ tellOwner({ type: 'prepared', ...contract, ...restingAdmission.contract });
2436
+ try { await activation; await loadWorkflow(root); return await serve(root, args); }
2437
+ finally { process.off('message', receive); process.off('disconnect', disconnected); unwatchWorkflow(); }
2082
2438
  }
2083
2439
 
2084
2440
  let generationServingDue = true;
2085
2441
  let wakeGeneration = () => {};
2086
2442
  let registerGenerationOwner = async () => {};
2087
- async function serve(root, args = {}) {
2443
+ async function serve(root, args) {
2444
+ registerWorkService({role:'board-generation',scope:{kind:'install',id:root}});
2088
2445
  // Hermes's daemon flags: --interval seconds, --max starts per tick, --failure-limit, --pidfile, --verbose
2089
2446
  const override = Number(args.interval) > 0 ? { interval: Number(args.interval) } : {};
2090
2447
  // --max and --failure-limit are params of every tick, read by the workflow as its own
@@ -2117,7 +2474,7 @@ async function serve(root, args = {}) {
2117
2474
  const watchRounds = async (reports, began) => {
2118
2475
  if (reports.every((r) => !r.error)) {
2119
2476
  // A publication the team refused holds this home's publishing where it is (nothing after it is skipped): a fact of
2120
- // the same kind as rounds that stopped, so it goes where every board verb's error line reads it. Only a refusal
2477
+ // the same kind as rounds that stopped, so it goes to the held dispatcher diagnostics. Only a refusal
2121
2478
  // in hand counts: the team's 4xx answer, which the daemon records with the publication (from supercode-teams
2122
2479
  // 0.3.44). An attempt still being retried, or a daemon that records no answer, says nothing here.
2123
2480
  const held = (await boardPublishing(root))?.failing;
@@ -2184,7 +2541,8 @@ async function serve(root, args = {}) {
2184
2541
  const boardMailboxes = () => ['default', ...(existsSync(join(root, 'kanban', 'boards')) ? readdirSync(join(root, 'kanban', 'boards')) : [])].map((slug) => boardAddress(boardLabel(root, slug)));
2185
2542
  // Installed daemons bound an ordinary node request at 30s: finish a quiet poll before that server deadline.
2186
2543
  const mailWaiter = async () => (await machineCall(null, 'harness.v1.mail.board_wait', { boards: boardMailboxes(), timeout: 20 }, { timeoutMs: 90_000 }))?.woken ?? [];
2187
- events = watchBoard({ root, binary: supercodeBin(), env: commandEnv(), log, mailWaiter, onEvent: (key, { uncertain = false, watched, id, board = null } = {}) => {
2544
+ events = watchBoard({ root, binary: supercodeBin(), env: commandEnv(), log, mailWaiter,
2545
+ ownWrite: (key, board, path) => Boolean(board) && ownBoardWrite(root, board, key, path), onEvent: (key, { uncertain = false, watched, id, board = null } = {}) => {
2188
2546
  if (key === 'board-registry') { retireHeldBoards(); refreshNow(); return; }
2189
2547
  if (key === 'board-config') {
2190
2548
  if (watched !== undefined && board) heldConfigWatched(board, id ?? key, watched);
@@ -2214,18 +2572,43 @@ async function serve(root, args = {}) {
2214
2572
  // lock. It names it again to each daemon that starts while it serves: the owner a restarted daemon restores from its
2215
2573
  // file stands only if the kernel probe at its start agreed, and an owner it dropped lost the board's authority over the
2216
2574
  // panes the board launched (a close refused as "not started by this command").
2217
- const registerOwner = async () => {
2218
- let named = null, refusal = null;
2219
- try { named = await machineCall(null, 'harness.v1.boards.register', { root, claim: { pid: process.pid, ppid: process.ppid } }, { timeoutMs: inBoardGeneration ? 2000 : 30_000 }); } catch (error) { refusal = error.message; }
2575
+ // A registration that did not land is made again on a later round until one does (registerDue), as an owner the
2576
+ // daemon never took has every board close refused, final: the daemon read nothing of who is registering (`unavailable`:
2577
+ // its peer proof or lock reading did not answer; the home is published, no owner taken), this call's own bound ended
2578
+ // first (REQUEST_TIMEOUT, 504: a generation waits 2 s, under the daemon's 5 s proofs, so whether it took the owner is not
2579
+ // known), no daemon answered (machine_offline, ECONNRESET), or the daemon was not serving yet (503: preparing its mail
2580
+ // at its start, or stopping, as on the reopen after a restart), or it answered but took no owner (`owner: false`: its
2581
+ // proof read the lock and found this process not holding it). An answer that says nothing of an owner (where the
2582
+ // daemon cannot read the lock or prove the peer, as on Windows) is taken as it always was, and never asked again for
2583
+ // that. Any other refusal lasts and is not asked again until the daemon reopens. A retry waits first (registerAt),
2584
+ // 15 s doubling to 5 min, and the wait resets when one lands: a retry under
2585
+ // slowness costs its round the generation's 2 s or a daemon's proofs, so it is never paid on every round. A retry says
2586
+ // its outcome only when it changed. A reopen registers at once, after any call in flight (which went to the daemon
2587
+ // that closed), its wait reset. Meanwhile the daemon takes the owner from the home's lock on the board's own calls
2588
+ // (live-sessions.mjs boardOwnerOf), so this is the second way back, not the first.
2589
+ let registerDue = false, registerAt = 0, registerWait = 0, registering = null, registerSaid = null;
2590
+ const registerOwner = (retry = false) => registering ??= (async () => {
2591
+ let named = null, refusal = null, code = null;
2592
+ try { named = await machineCall(null, 'harness.v1.boards.register', { root, claim: { pid: process.pid, ppid: process.ppid } }, { timeoutMs: inBoardGeneration ? 2000 : 30_000 }); } catch (error) { refusal = error.message; code = error?.code ?? null; }
2593
+ const ownerless = refusal === null && named?.owner === false;
2594
+ registerDue = ownerless || (refusal !== null && ['unavailable', 'REQUEST_TIMEOUT', 504, 'machine_offline', 'ECONNRESET', 503].includes(code));
2595
+ registerWait = registerDue ? Math.min(registerWait ? registerWait * 2 : 15_000, 300_000) : 0;
2596
+ registerAt = Date.now() + registerWait;
2597
+ const again = registerDue ? '; registering again on a later round (15 s, doubling to 5 min)' : '';
2220
2598
  const refused = named?.refused ?? null;
2221
- log(refusal ? `board events: not published; the machine daemon did not take this home: ${refusal}`
2599
+ const line = code === 'unavailable' ? `board ownership: this home is published, but the machine daemon could not read which process is registering, so it took no owner for it (${refusal})${again}`
2600
+ : code === 'REQUEST_TIMEOUT' || code === 504 ? `board ownership: the machine daemon did not answer the registration in time (${refusal}); whether it published this home and took this owner is not known${again}`
2601
+ : refusal ? `board events: not published; the machine daemon did not take this home: ${refusal}${again}`
2602
+ : ownerless ? `board ownership: this home is registered, but the machine daemon did not find this process holding its board lock, so it took no owner for it${again}`
2222
2603
  : refused ? `board events: registered, but the team refused this machine's credential (${refused.status}); nothing is published until it is re-enrolled`
2223
2604
  : named?.published === false ? `board ownership: registered locally; this machine has no team connector to publish events`
2224
- : `board events: this home is published by the machine's connector`);
2225
- };
2605
+ : `board events: this home is published by the machine's connector`;
2606
+ if (retry !== true || line !== registerSaid) log(line);
2607
+ registerSaid = line;
2608
+ })().finally(() => { registering = null; });
2226
2609
  registerGenerationOwner = registerOwner;
2227
2610
  await registerOwner();
2228
- onLocalReopen(registerOwner);
2611
+ onLocalReopen(async () => { await registering; registerWait = 0; registerAt = 0; return registerOwner(); });
2229
2612
  activationSaid('registered with the machine daemon');
2230
2613
  // Its writer epoch taken, work admitted and the home registered, this generation answers verbs: its owner routes
2231
2614
  // public requests to it now, and its first clean round below gates the activation, never the routing.
@@ -2233,14 +2616,15 @@ async function serve(root, args = {}) {
2233
2616
  let listenerRecords = {}, listenerReloading = null, listenerReloadDue = false, listenerReading = null;
2234
2617
  const listenerErrors = new Set();
2235
2618
  const listenerLog = text => { if (!listenerErrors.has(text)) { if (listenerErrors.size >= 128) listenerErrors.clear(); listenerErrors.add(text); log(text); } };
2619
+ const listenerSteps = (name, work) => timed(name, work, { scope: 'listener' });
2236
2620
  const scanListeners = async ({ waiting = true } = {}) => {
2237
2621
  if (stopped || !fleetListeners.enabled || !listenerBootstrapped || !fleetRecordsCurrent()) return;
2238
2622
  const revision = listenerRevision;
2239
2623
  try {
2240
- await ownCall(function listenerScan() {
2624
+ await listenerSteps('board listener scan', () => ownCall(function listenerScan() {
2241
2625
  if (stopped || revision !== listenerRevision || !fleetListeners.enabled || !fleetRecordsCurrent()) return;
2242
2626
  observeRoleFleet(root, fleetListeners, listenerRecords, listenerLog, { waiting });
2243
- });
2627
+ }));
2244
2628
  } catch (error) {
2245
2629
  // A scan its stopping generation refused never ran, and this generation scans nothing again: said as that.
2246
2630
  if (error?.code === GENERATION_STOPPED) { listenerLog(`fleet listener scan not run: ${error.message}`); return; }
@@ -2252,7 +2636,7 @@ async function serve(root, args = {}) {
2252
2636
  const readListenerRecords = () => {
2253
2637
  if (!fleetListeners.enabled || stopped || listenerReading) return listenerReading;
2254
2638
  const revision = listenerRevision;
2255
- listenerReading = readFleetRecords().then(records => {
2639
+ listenerReading = listenerSteps('board listener records', () => readFleetRecords()).then(records => {
2256
2640
  if (revision !== listenerRevision || !fleetListeners.enabled || stopped) return;
2257
2641
  listenerRecords = records;
2258
2642
  if (!listenerBootstrapped) { fleetListeners.bootstrap(records); listenerBootstrapped = true; scanListeners(); }
@@ -2262,17 +2646,17 @@ async function serve(root, args = {}) {
2262
2646
  return listenerReading;
2263
2647
  };
2264
2648
  fleetListeners = new FleetListenerRouter({ root, log: listenerLog,
2265
- file: (to, body, key) => ownCall(() => fileFleetNotice(root, to, body, key), 'a fleet notice'),
2649
+ file: (to, body, key) => listenerSteps('board listener filing', () => ownCall(() => fileFleetNotice(root, to, body, key), 'a fleet notice')),
2266
2650
  // The scan/flush prepares custody once; enqueue needs the event synchronously before committing its cursor.
2267
2651
  enrich: event => enrichFleetEvent(root, event),
2268
- work: fn => ownCall(fn, 'the listener flush'),
2652
+ work: fn => listenerSteps('board listener flush', () => ownCall(fn, 'the listener flush')),
2269
2653
  retry: () => scanListeners({ waiting: false }),
2270
2654
  });
2271
2655
  const unfollowRecords = onRecordsChanged(records => { listenerRecords = records; scanListeners(); });
2272
2656
  reloadListeners = () => {
2273
2657
  listenerReloadDue = true;
2274
2658
  if (listenerReloading || stopped) return listenerReloading;
2275
- listenerReloading = (async () => {
2659
+ listenerReloading = listenerSteps('board listener configuration', async () => {
2276
2660
  while (listenerReloadDue && !stopped) {
2277
2661
  listenerReloadDue = false;
2278
2662
  const revision = listenerRevision;
@@ -2291,7 +2675,7 @@ async function serve(root, args = {}) {
2291
2675
  listenerReloadDue = true; break;
2292
2676
  }
2293
2677
  }
2294
- })().finally(() => { listenerReloading = null; });
2678
+ }).finally(() => { listenerReloading = null; });
2295
2679
  return listenerReloading;
2296
2680
  };
2297
2681
  if (inBoardGeneration && generationServingDue) tellOwner({ type: 'startup-step', phase: 'serving', step: 'reading the listener configuration through its orchestration door', budget_ms: 65000 });
@@ -2311,8 +2695,11 @@ async function serve(root, args = {}) {
2311
2695
  // a board whose cards name an assignee that is no profile of this home is said once, until it has none
2312
2696
  const laneSaid = new Set();
2313
2697
  let wakeReason = null;
2698
+ let failure, failed = false;
2314
2699
  try {
2315
2700
  while (!stopped) {
2701
+ // an owner not yet taken is registered before a round once its wait is over (registerOwner)
2702
+ if (registerDue && Date.now() >= registerAt) await registerOwner(true);
2316
2703
  if (listenerReloadDue) void reloadListeners();
2317
2704
  if (fleetListeners.enabled) scanListeners();
2318
2705
  // A serve never ticks on code older than the installed orchestrator: after its round it stops, and its keeper
@@ -2322,6 +2709,7 @@ async function serve(root, args = {}) {
2322
2709
  pausedSaid = Boolean(paused(root)) && pausedSaid;
2323
2710
  let interval = 60;
2324
2711
  let nextMailAt = Infinity;
2712
+ let nextPauseAt = Infinity;
2325
2713
  const began = Date.now();
2326
2714
  if (inBoardGeneration && generationServingDue) {
2327
2715
  tellOwner({ type: 'startup-step', phase: 'serving', step: 'completing the first native board round', budget_ms: 65000 });
@@ -2330,6 +2718,7 @@ async function serve(root, args = {}) {
2330
2718
  if (wakeReason) log(`dispatch wake ${wakeReason.sources.join(', ')}; event age ${began - wakeReason.at}ms`);
2331
2719
  wakeReason = null;
2332
2720
  const loopStep = async (name, fn) => { const at = performance.now(); try { return await fn(); } finally { noteStep(`owner loop: ${name}`, performance.now() - at); } };
2721
+ const runRound = async () => {
2333
2722
  try {
2334
2723
  const state = await loopStep('lane state', () => ownCall(() => laneState(root), 'the lane state'));
2335
2724
  const settings = { ...state.settings, ...override };
@@ -2344,7 +2733,7 @@ async function serve(root, args = {}) {
2344
2733
  // with the step's budget, so a round slow only on another machine's answers is not ended for its length.
2345
2734
  const progress = inBoardGeneration && generationServingDue
2346
2735
  ? (step) => tellOwner({ type: 'startup-step', phase: 'serving', step: `completing the first native board round (${step})`, budget_ms: 65000 }) : null;
2347
- const reports = dispatching.length ? await ownCall(() => tick({ root, lanes: state.lanes, settings, params, slugs: dispatching, deploymentReceipt: true, progress }), 'the dispatch round') : [];
2736
+ const reports = dispatching.length ? await loopStep('dispatch', () => ownCall(() => tick({ root, lanes: state.lanes, settings, params, slugs: dispatching, deploymentReceipt: true, progress }), 'the dispatch round')) : [];
2348
2737
  if (inBoardGeneration && generationServingDue && !reports.cut && reports.every(report => !report.error)) {
2349
2738
  activationSaid(`the first native board round is clean (${dispatching.length} dispatching board(s))`);
2350
2739
  generationServingDue = false;
@@ -2355,16 +2744,18 @@ async function serve(root, args = {}) {
2355
2744
  const resting = listBoards(root).filter((slug) => !dispatching.includes(slug));
2356
2745
  const closes = resting.length && !stopped ? await loopStep('close pass', () => ownCall(() => closeEnded({ root, slugs: resting, settings }), 'the close pass')) : [];
2357
2746
  for (const r of closes) {
2747
+ nextPauseAt = Math.min(nextPauseAt, r.next_pause_at == null ? Infinity : r.next_pause_at * 1000);
2358
2748
  if (r.error || r.closed.length || args.verbose) log(summary(r));
2359
2749
  for (const [t, to, why] of r.undelivered) log(` close of ${to} for ${t} waits: ${why}`);
2360
2750
  }
2361
2751
  if (dispatching.length) {
2362
2752
  round += 1;
2363
2753
  // what the round recorded is delivered by a pass of its own, which this loop never waits for
2364
- if (!stopped) startDelivery(root, { slugs: dispatching, log: (level, line) => { if (level === 'warn' || args.verbose) log(line); } });
2365
- if (!stopped) await watchRounds(reports, began);
2754
+ if (!stopped) await loopStep('round health', () => watchRounds(reports, began));
2366
2755
  }
2756
+ if (!stopped) startDelivery(root, { slugs: [...dispatching, ...resting], log: (level, line) => { if (level === 'warn' || args.verbose) log(line); } });
2367
2757
  for (const r of reports) {
2758
+ nextPauseAt = Math.min(nextPauseAt, r.next_pause_at == null ? Infinity : r.next_pause_at * 1000);
2368
2759
  nextMailAt = Math.min(nextMailAt, r.next_mail_at ?? Infinity);
2369
2760
  const said = summary(r);
2370
2761
  if (r.deployment) log(`runtime deployed ${r.deployment.repository}@${r.deployment.head}; workflow ${r.deployment.workflow_hash}; tick ${r.deployment.completed_at}`);
@@ -2389,15 +2780,15 @@ async function serve(root, args = {}) {
2389
2780
  if (!stopped) await watchRounds([{ board: '*', error: `tick failed: ${error.message}` }], began);
2390
2781
  }
2391
2782
  }
2392
- // each round is a line of its own in the slow log, with the steps it spent its time on
2393
- recordSlow({ kind: 'call', name: 'dispatch round', ms: Date.now() - began, slowest: null, extra: { steps: takeSteps() } });
2783
+ };
2784
+ await timed('dispatch round', runRound, { scope: 'round' });
2394
2785
  // a newer install is looked for after each tick and through the wait before the next
2395
2786
  const newer = () => { const now = installed(); return running && now && now !== running ? now : null; };
2396
2787
  let moved = newer();
2397
2788
  // the next tick is due an interval after this one began (a long tick is not followed by a full interval's wait)
2398
2789
  await loopStep('wake refresh', () => refreshNow());
2399
2790
  if (!stopped && !moved) {
2400
- wakeReason = await events.wait(Math.max(0, Math.min(interval * 1000 - (Date.now() - began), nextMailAt - Date.now())));
2791
+ wakeReason = await events.wait(Math.max(0, Math.min(interval * 1000 - (Date.now() - began), nextMailAt - Date.now(), nextPauseAt - Date.now())));
2401
2792
  // A busy event stream is coalesced to at most one tick per second.
2402
2793
  const remaining = 1000 - (Date.now() - began);
2403
2794
  if (remaining > 0 && !stopped) await new Promise(done => setTimeout(done, remaining));
@@ -2405,19 +2796,33 @@ async function serve(root, args = {}) {
2405
2796
  }
2406
2797
  if (moved && !inBoardGeneration) { log(`orchestrator ${moved} is installed (this serve runs ${running}); stopping, for its keeper to start the new one`); break; }
2407
2798
  }
2799
+ } catch (error) {
2800
+ failed = true; failure = error; throw error;
2408
2801
  } finally {
2409
- fleetListeners.suspend(); clearInterval(listenerTimer); unfollowRecords(); unfollowPanes();
2410
- await listenerReloading;
2411
- await listenerReading;
2802
+ const errors = [];
2803
+ const attempt = async work => { try { await work(); } catch (error) { errors.push(error); } };
2804
+ await attempt(() => fleetListeners.suspend());
2805
+ clearInterval(listenerTimer);
2806
+ await attempt(() => unfollowRecords());
2807
+ await attempt(() => unfollowPanes());
2808
+ await attempt(() => listenerReloading);
2809
+ await attempt(() => listenerReading);
2412
2810
  try { await fleetListeners.close(); } catch (error) { log(`fleet listener shutdown: retained filing outcome unknown (${String(error.message).slice(0, 300)})`); }
2413
2811
  // a fetch in flight lands, and what it brought is merged, while this owner still holds the home's lock
2414
2812
  try { const { settleDocuments } = await import('./git-document.mjs'); await settleDocuments(); } catch { /* nothing in flight */ }
2415
- unwatchLoop?.(); releaseHeldBoards(); closeVerbs();
2416
- unfollowHome();
2417
- await closeDoors();
2418
- events?.close();
2813
+ await attempt(() => unwatchLoop?.());
2814
+ await attempt(() => releaseHeldBoards());
2815
+ await attempt(() => closeVerbs());
2816
+ await attempt(() => unfollowHome());
2817
+ await attempt(() => closeDoors());
2818
+ // The home reader owns a harness process too. Its idle timer cannot join that child's exit after this
2819
+ // generation checkpoints and exits; close its shared transport while observation is still open.
2820
+ await attempt(async () => { const { closeWorld } = await import('../orchestration.mjs'); await closeWorld(); });
2821
+ await attempt(() => events?.close());
2419
2822
  for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) process.off(signal, stop);
2420
2823
  if (args.pidfile) { try { unlinkSync(args.pidfile); } catch { /* gone */ } }
2824
+ await closeWorkLedger();
2825
+ if (errors.length) throw new AggregateError([...(failed ? [failure] : []), ...errors], 'board dispatcher shutdown failed', { cause: failed ? failure : errors[0] });
2421
2826
  }
2422
2827
  log(stopped ? 'stopped (a signal)' : 'stopped');
2423
2828
  return 0;