@volter/supercode-orchestrator 0.4.149 → 0.4.151

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/board/ci.mjs CHANGED
@@ -6,6 +6,43 @@ import { dirname, join, relative } from 'node:path';
6
6
  import { createHash } from 'node:crypto';
7
7
  import { branchCheckouts, checkoutOf, resolveWorkspace } from './workspace.mjs';
8
8
 
9
+ // GitHub is read through its REST API with Node's own fetch, not the gh binary: Node takes a World's trust bundle and
10
+ // injector (NODE_EXTRA_CA_CERTS, NODE_OPTIONS), so a review World's GitHub twin answers it, where gh (Go) trusts only
11
+ // the system keychain. The read runs in a child Node so the callers stay synchronous.
12
+ const GITHUB_READ = `
13
+ const [path, all] = process.argv.slice(1);
14
+ const base = (process.env.GITHUB_API_URL || 'https://api.github.com').replace(/\\/$/, '');
15
+ const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
16
+ const headers = { accept: 'application/vnd.github+json', 'user-agent': 'supercode-board', ...(token ? { authorization: 'Bearer ' + token } : {}) };
17
+ let url = base + path, out = [];
18
+ for (let page = 0; url && page < 10; page += 1) {
19
+ const r = await fetch(url, { headers });
20
+ if (!r.ok) { process.stderr.write('GitHub ' + r.status + ' for ' + path + ': ' + (await r.text()).slice(0, 300)); process.exit(1); }
21
+ const body = await r.json();
22
+ if (all !== '1') { process.stdout.write(JSON.stringify(body)); process.exit(0); }
23
+ out = out.concat(body);
24
+ url = /<([^>]+)>;\\s*rel="next"/.exec(r.headers.get('link') ?? '')?.[1] ?? null;
25
+ }
26
+ process.stdout.write(JSON.stringify(out));`;
27
+ let githubToken;
28
+ /** One GitHub REST read (`all`: every page of a list). The token is the environment's, else gh's stored login. */
29
+ function github(path, all = false) {
30
+ if (githubToken === undefined) {
31
+ githubToken = process.env.GH_TOKEN || process.env.GITHUB_TOKEN || null;
32
+ if (!githubToken) try { githubToken = run('gh', ['auth', 'token']) || null; } catch { githubToken = null; }
33
+ }
34
+ const env = githubToken ? { ...process.env, GH_TOKEN: githubToken } : process.env;
35
+ const result = spawnSync(process.execPath, ['--input-type=module', '-e', GITHUB_READ, path, all ? '1' : '0'], { env, encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 });
36
+ if (result.status !== 0) throw new Error(`GitHub: ${(result.stderr || result.error?.message || 'read failed').trim().slice(-500)}`);
37
+ return JSON.parse(result.stdout);
38
+ }
39
+ const repositoryOf = (url) => new URL(url).pathname.split('/').slice(1, 3).join('/');
40
+ const numberOf = (url) => Number(new URL(url).pathname.split('/')[4]);
41
+ /** A PR as the board reads it (gh's field names). */
42
+ const pullRequest = (repository, p) => ({ url: p.html_url, number: p.number, state: p.merged_at ? 'MERGED' : String(p.state).toUpperCase(), headRefOid: p.head.sha,
43
+ headRefName: p.head.ref, baseRefName: p.base.ref, isCrossRepository: p.head.repo?.full_name?.toLowerCase() !== repository.toLowerCase(), isDraft: Boolean(p.draft) });
44
+ const readPullRequest = (url) => pullRequest(repositoryOf(url), github(`/repos/${repositoryOf(url)}/pulls/${numberOf(url)}`));
45
+
9
46
  const observations = new Map();
10
47
  const key = (board, id) => `${board.root}\0${board.slug}\0${id}`;
11
48
  const run = (program, args, cwd) => {
@@ -66,14 +103,28 @@ function repositoryWorkspace(board, id) {
66
103
 
67
104
  const pullRequestLists = new Map();
68
105
 
69
- function branchCheckout(board, id, repository) {
70
- const task = board.db.prepare('SELECT t.branch_name, c.machine FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.id=?').get(id);
71
- try { return checkoutOf(task?.machine, repository, task?.branch_name ?? `wt/${id}`)?.path ?? null; } catch { return null; }
106
+ function branchCheckout(board, id, repository, branch) {
107
+ const task = board.db.prepare('SELECT c.machine FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.id=?').get(id);
108
+ try { return checkoutOf(task?.machine, repository, branch)?.path ?? null; } catch { return null; }
109
+ }
110
+
111
+ /** The sibling checkouts an arc registered: each subtask (and the card itself) that declares a repository
112
+ * workspace and a branch, e.g. `create "<what>" --subtask-of <arc> --workspace worktree:<repository> --branch <name>`.
113
+ * Their PRs are the arc's whatever the branch is named. */
114
+ function registeredBranches(board, id) {
115
+ const rows = board.db.prepare(`SELECT t.branch_name, t.workspace_kind, c.workspace_anchor FROM tasks t JOIN supercode_cards c ON c.task_id=t.id
116
+ WHERE (t.id=? OR c.subtask_of=?) AND t.status != 'cancelled' AND t.workspace_kind IN ('worktree','dir') AND t.branch_name IS NOT NULL`).all(id, id);
117
+ const out = [];
118
+ for (const row of rows) {
119
+ if (!row.workspace_anchor || /^(\/|~|[A-Za-z]:[\\/])/.test(row.workspace_anchor)) continue;
120
+ out.push({ repository: row.workspace_anchor.replace(/^github\.com\//i, '').replace(/\.git$/, ''), branch: row.branch_name });
121
+ }
122
+ return out;
72
123
  }
73
124
 
74
125
  /** Every opened PR of the arc: each open PR whose head is the card's branch or a `wt/<card id>` branch, in the
75
- * card's own repository and in every repository this machine keeps a checkout of. One review covers them all;
76
- * the caller decides the transition. */
126
+ * card's own repository and in every repository this machine keeps a checkout of, and each open PR on a sibling
127
+ * checkout the arc registered (`registeredBranches`). One review covers them all; the caller decides the transition. */
77
128
  export function openReviewPullRequests(board, id) {
78
129
  const own = repositoryWorkspace(board, id);
79
130
  if (!own) return null;
@@ -81,20 +132,23 @@ export function openReviewPullRequests(board, id) {
81
132
  const branch = task?.branch_name ?? run('git', ['branch', '--show-current'], own);
82
133
  const ownRepository = originOf(own);
83
134
  if (!ownRepository) return null;
84
- const mine = (head) => head === branch || [`wt/${id}`, `wt/${id.replace(/^t_/, 't-')}`].some((prefix) => head === prefix || head.startsWith(`${prefix}-`) || head.startsWith(`${prefix}/`));
135
+ const registered = registeredBranches(board, id);
136
+ const mine = (repository, head) => head === branch || [`wt/${id}`, `wt/${id.replace(/^t_/, 't-')}`].some((prefix) => head === prefix || head.startsWith(`${prefix}-`) || head.startsWith(`${prefix}/`))
137
+ || registered.some((r) => r.repository === repository && r.branch === head);
85
138
  const prs = [];
86
- for (const repository of new Set([ownRepository, ...knownRepositories()])) {
139
+ for (const repository of new Set([ownRepository, ...registered.map((r) => r.repository), ...knownRepositories()])) {
87
140
  // Discovery is shared per repository for one minute; each selected PR/head is re-read before it authorizes a move.
88
141
  let list = pullRequestLists.get(repository);
89
142
  if (!list || Date.now() - list.at >= 60_000) {
90
143
  let items = [];
91
- try { items = JSON.parse(run('gh', ['pr', 'list', '--repo', repository, '--state', 'open', '--limit', '1000', '--json', 'url,headRefOid,headRefName,baseRefName,isCrossRepository'])); }
92
- catch (error) { if (repository === ownRepository) throw error; } // another repository unreachable here is not this arc's
144
+ try { items = github(`/repos/${repository}/pulls?state=open&per_page=100`, true).map((p) => pullRequest(repository, p)); }
145
+ catch (error) { if (repository === ownRepository || registered.some((r) => r.repository === repository)) throw error; } // another repository unreachable here is not this arc's
93
146
  list = { at: Date.now(), items };
94
147
  if (pullRequestLists.size >= 64) pullRequestLists.delete(pullRequestLists.keys().next().value);
95
148
  pullRequestLists.set(repository, list);
96
149
  }
97
- for (const pr of list.items) if (!pr.isCrossRepository && mine(pr.headRefName)) prs.push({ url: pr.url, head: pr.headRefOid });
150
+ // a draft is not handed over yet: its review starts when it is marked ready
151
+ for (const pr of list.items) if (!pr.isCrossRepository && !pr.isDraft && mine(repository, pr.headRefName)) prs.push({ url: pr.url, head: pr.headRefOid });
98
152
  }
99
153
  if (!prs.length) return null;
100
154
  return { pr: prs.map((p) => p.url), pr_head: prs.map((p) => p.head), branch };
@@ -105,17 +159,35 @@ function knownRepositories() {
105
159
  try { return [...new Set(branchCheckouts(null, '*').map((row) => row.repository))]; } catch { return []; }
106
160
  }
107
161
 
108
- /** Whether the arc's open PRs (`[{ url, head }]`) call for its review. Before any verdict: while some head has none.
109
- * After a verdict: only once a PR that verdict judged shows a new head. A PR that joins the arc, or one that merged or
110
- * closed, does not by itself bring the arc back: after changes are requested the next run is the implementer's. */
162
+ /** Whether the arc's open PRs (`[{ url, head }]`) call for its review: some head no verdict judged that is new work.
163
+ * Before any verdict every unjudged head is. After a verdict, a PR it judged with a new head is, and so is a head
164
+ * committed after that verdict (a new PR carrying the fixes, or any PR after a verdict that named none). A PR that
165
+ * joins with a head committed before the verdict is not: after changes are requested the next run is the
166
+ * implementer's until it pushes. */
111
167
  export function reviewDue(board, id, prs) {
112
- const unjudged = prs.some((p) => !judgedHead(board, id, p.head));
113
- const last = board.db.prepare(`SELECT r.payload FROM task_events r WHERE r.task_id = ? AND r.kind = 'review_requested'
168
+ const unjudged = prs.filter((p) => !judgedHead(board, id, p.head));
169
+ if (!unjudged.length) return false;
170
+ const last = board.db.prepare(`SELECT r.payload, (SELECT MIN(v.created_at) FROM task_events v WHERE v.task_id = r.task_id
171
+ AND v.kind IN ('changes_requested', 'review_pass') AND v.id > r.id) AS verdict_at
172
+ FROM task_events r WHERE r.task_id = ? AND r.kind = 'review_requested'
114
173
  AND EXISTS (SELECT 1 FROM task_events v WHERE v.task_id = r.task_id AND v.kind IN ('changes_requested', 'review_pass') AND v.id > r.id)
115
174
  ORDER BY r.id DESC LIMIT 1`).get(id);
116
- if (!last) return unjudged;
175
+ if (!last) return true;
117
176
  const judged = candidatePullRequests(JSON.parse(last.payload)?.ci);
118
- return unjudged && judged.some((j) => prs.some((p) => p.url === j.pr.url && p.head !== j.commit));
177
+ if (judged.some((j) => prs.some((p) => p.url === j.pr.url && p.head !== j.commit))) return true;
178
+ return unjudged.some((p) => (commitTime(p) ?? 0) > Number(last.verdict_at));
179
+ }
180
+
181
+ const commitTimes = new Map();
182
+ /** When a PR's head was committed (seconds), read from GitHub once per head; null when it cannot be read. */
183
+ function commitTime({ url, head }) {
184
+ if (commitTimes.has(head)) return commitTimes.get(head);
185
+ let at = null;
186
+ try {
187
+ at = Math.floor(Date.parse(github(`/repos/${repositoryOf(url)}/commits/${head}`).commit.committer.date) / 1000) || null;
188
+ } catch { /* unreadable: not counted as new */ }
189
+ commitTimes.set(head, at);
190
+ return at;
119
191
  }
120
192
 
121
193
  /** The verdict already recorded on `head` of the card's PR: a changes-requested or pass that answered
@@ -146,7 +218,7 @@ export function inspectCompletionEvidence(board, id, entry, payload = {}, stage
146
218
  const wanted = candidatePullRequests(candidate);
147
219
  if (wanted.length !== passed.length || wanted.some((w) => !passed.some((p) => p.pr.url === w.pr.url && p.commit === w.commit))) throw new Error('The current PR heads need their independent verdict before acceptance.');
148
220
  for (const w of wanted) {
149
- const pr = JSON.parse(run('gh', ['pr', 'view', w.pr.url, '--repo', w.repository, '--json', 'state,headRefOid']));
221
+ const pr = readPullRequest(w.pr.url);
150
222
  if (!['OPEN', 'MERGED'].includes(pr.state) || pr.headRefOid !== w.commit) throw new Error(`The head of ${w.pr.url} changed after its verdict; request a new review.`);
151
223
  }
152
224
  Object.assign(state, { ...candidate, ready: true, reason: null });
@@ -192,11 +264,12 @@ function verifyPullRequest(board, id, entry, own, url, supplied, stage) {
192
264
  if (typeof url !== 'string' || !/^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/[1-9][0-9]*\/?$/.test(url)) throw new Error('Name the opened PR URL with --pr.');
193
265
  const repository = new URL(url).pathname.split('/').slice(1, 3).join('/');
194
266
  // An arc may change several repositories. A PR is read from GitHub; its pushed head is the reviewed one. The
195
- // card's own checkout, and this machine's checkout of another repository on the card's branch when it keeps one,
196
- // must also hold that head with nothing uncommitted.
197
- const path = originOf(own) === repository ? own : branchCheckout(board, id, repository);
198
- const observed = JSON.parse(run('gh', ['pr', 'view', url, '--repo', repository, '--json', 'url,number,state,headRefOid,headRefName,baseRefName,isCrossRepository']));
267
+ // card's own checkout, and this machine's checkout of the PR's branch when it keeps one, must also hold that head
268
+ // with nothing uncommitted.
269
+ const observed = readPullRequest(url);
270
+ const path = originOf(own) === repository && run('git', ['branch', '--show-current'], own) === observed.headRefName ? own : branchCheckout(board, id, repository, observed.headRefName);
199
271
  if (observed.state !== 'OPEN' || observed.isCrossRepository) throw new Error(`Source review needs an open PR from its repository (${url}).`);
272
+ if (observed.isDraft) throw new Error(`${url} is a draft; its review starts when it is marked ready for review.`);
200
273
  const head = supplied ?? observed.headRefOid;
201
274
  if (!/^[a-f0-9]{40}$/.test(head)) throw new Error('Name the full PR head commit with --pr-head (reviewer: --reviewed-sha).');
202
275
  if (observed.headRefOid !== head) throw new Error(`The head of ${url} changed to ${observed.headRefOid}; request review of that head before recording a verdict.`);
@@ -206,8 +279,8 @@ function verifyPullRequest(board, id, entry, own, url, supplied, stage) {
206
279
  if (stage === 'request_review' && path) {
207
280
  run('git', ['check-ref-format', '--branch', branch], path);
208
281
  if (run('git', ['rev-parse', 'HEAD'], path) !== head) throw new Error(`The head named for ${url} must be its worktree head.`);
209
- run('git', ['diff', '--quiet', 'HEAD'], path);
210
- if (run('git', ['ls-files', '--others', '--exclude-standard'], path)) throw new Error(`Commit the untracked work in ${path} before requesting review.`);
282
+ const uncommitted = run('git', ['status', '--porcelain', '--untracked-files=normal'], path).split('\n').filter(Boolean);
283
+ if (uncommitted.length) throw new Error(`Commit the work in ${path} before requesting review: ${uncommitted.length} uncommitted path(s): ${uncommitted.slice(0, 5).map((l) => l.replace(/^\s*\S+\s+/, '')).join(', ')}${uncommitted.length > 5 ? ', …' : ''}`);
211
284
  const pushed = run('git', ['ls-remote', '--heads', 'origin', `refs/heads/${branch}`], path).split(/\s+/)[0];
212
285
  if (pushed !== head) throw new Error(`Push the exact head of ${url} before requesting review.`);
213
286
  }
package/board/cli.mjs CHANGED
@@ -7,13 +7,13 @@
7
7
  import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
8
8
  import { spawnSync } from 'node:child_process';
9
9
  import { basename, join, resolve } from 'node:path';
10
- import { tmpdir } from 'node:os';
10
+ import { tmpdir, userInfo } from 'node:os';
11
11
  import { read } from '../orchestration.mjs';
12
12
  import { actorOf, view, backingPaths, layOut, backingOf, boardExists, boardPath, DEFAULT_BOARD, event, json, listBoards, logsDir, now, openBoard, setActor, tx } from './store.mjs';
13
13
  import { isLocalWorkspace } from './workspace.mjs';
14
14
  import { openZtrackBoard } from './ztrack.mjs';
15
15
  import * as engine from './engine.mjs';
16
- import { adoptSession, boardAddress, boardLabel, boardSettings, callerSession, commandEnv, reconcileCallerLaunch, closeRequested, lanesOf, localMachine, observeFleet, paused, plan, startCard, supercodeBin, tick } from './dispatch.mjs';
16
+ import { requestAdoption, boardAddress, boardLabel, boardSettings, callerSession, commandEnv, reconcileCallerLaunch, closeRequested, lanesOf, localMachine, observeFleet, paused, plan, startCard, supercodeBin, tick } from './dispatch.mjs';
17
17
  import { watchBoard } from './wake.mjs';
18
18
  import { completionOwnerOf, isLatestReviewer, loadWorkflow, requestSessionClose, scopeOf, statusOf, useWorkflow, workflowEntry, workflowFor } from './workflow.mjs';
19
19
  import { evaluate } from './expr.mjs';
@@ -29,14 +29,14 @@ Hermes Kanban's verbs, with their flags:
29
29
  [--model] [--provider] [--goal|--no-goal] [--goal-max-turns] [--initial-status blocked|running]
30
30
  swarm <goal> --worker PROFILE:TITLE[:SKILL,SKILL]... --verifier P --synthesizer P
31
31
  list [--mine] [--assignee] [--status] [--tenant] [--session] [--archived] [--sort] show <id> context <id> runs <id>
32
- assign <id> <profile|none> reassign <id> <profile> [--reclaim] [--reason] set-model <id> [model] [--provider] set-goal <id>... [--goal-max-turns N] [--off]
32
+ set <id> <assignee|machine|model|every> <value|none> [--provider] [--ids ...] assign <id> <profile|none> set-model <id> [model] [--provider] set-goal <id>... [--goal-max-turns N] [--off]
33
33
  claim <id> [--ttl] reclaim <id> [--reason] heartbeat <id> [--note] [--until <time|30m>] link|unlink <parent> <child>
34
34
  comment <id> <text> [--author] [--max-len] attach <id> <path> [--name] [--content-type] attachments <id> attach-rm <n>
35
35
  complete <id>... [--result] [--summary] [--metadata JSON] edit <id> --result [--summary] [--metadata]
36
36
  block <id> [reason] (--on <card> | --waiting-on <message id>) [--ids ...] schedule <id> [reason] [--ids ...]
37
- pause <id> --reason <why> resume <id> cancel <id> set-every <id> <interval|none>
37
+ pause <id> --reason <why> resume <id> cancel <id>
38
38
  request-state <id> --state block|pause|done|cancelled --reason <why> [--on <card>|--waiting-on <message>] [--evidence <pointers>]
39
- unblock <id>... [--reason] request-review <id> (--pr URL --pr-head SHA)... [--summary] [--reviewer] [--force] request-changes <id> <reason> reopen-review <id>... reopen <id>... [--reason]
39
+ unblock <id>... --on <blocker> --reason <what arrived> request-review <id> (--pr URL --pr-head SHA)... [--summary] [--reviewer] [--force] request-changes <id> <reason> [--finding <finding>]... reopen-review <id>... reopen <id>... [--reason]
40
40
  promote <id> [reason] [--ids ...] [--force] [--dry-run] archive <id>... [--rm <id>...]
41
41
  specify <id> [--title --body] [--workspace scratch|worktree:<repository>|dir:<repository>] [--branch NAME] [--all]
42
42
  decompose <id> [--child "<title>[@<profile>]"]... [--all]
@@ -71,7 +71,7 @@ supercode's own:
71
71
  workflow declares who may block, pause or finish a card; request-state mails its manager without changing state.
72
72
  `;
73
73
 
74
- const MULTI = new Set(['parent', 'skill', 'worker', 'child', 'ids', 'rm', 'pr', 'pr-head', 'reviewed-sha']);
74
+ const MULTI = new Set(['parent', 'skill', 'worker', 'child', 'ids', 'rm', 'pr', 'pr-head', 'reviewed-sha', 'finding']);
75
75
  const FLAGS = new Set(['json', 'triage', 'no-notify', 'no-start', 'all', 'force', 'help', 'mine', 'archived', 'reclaim', 'dry-run',
76
76
  'goal', 'no-goal', 'off', 'switch', 'delete', 'no-attachments', 'include-logs', 'verbose']);
77
77
  // flags that take every following word up to the next flag (Hermes's nargs='+')
@@ -264,8 +264,8 @@ export async function runWorkflow(argv) {
264
264
  if (verb === 'comment' && isLatestReviewer(db, cardId, actorOf()) && !roles.includes('reviewer')) roles.push('reviewer');
265
265
  return roles;
266
266
  });
267
- // a verb with no session behind it (a person at the CLI, the board file's sync) is the manager's, as its roles are
268
- if (!sid && verb !== 'help') { try { setActor([].concat(workflowFor(root).params?.managers ?? [])[0] ?? null); } catch { /* no workflow */ } }
267
+ // a verb with no session behind it is recorded as the OS user's; it holds no role on any card
268
+ if (!sid && verb !== 'help') setActor(`user:${userInfo().username}`);
269
269
 
270
270
  switch (verb) {
271
271
  case 'migrate': {
@@ -314,9 +314,8 @@ export async function runWorkflow(argv) {
314
314
  let answer = { id: created.id, existing: created.existing };
315
315
  if (args.adopt) {
316
316
  withBoard(b => { if (!rolesFor(b.db, created.id, sid, workflowFor(root).params).includes('manager')) throw new Error('Only the manager adopts an existing session.'); });
317
- const { settings } = await laneState(root);
318
- const adopted = withBoard(b => adoptSession(b, created.id, args.adopt, { ttl: settings.ttl }));
319
- answer = { ...answer, started: true, session: adopted.address, mode: adopted.mode };
317
+ withBoard(b => requestAdoption(b, created.id, args.adopt));
318
+ answer = { ...answer, adoption_requested: args.adopt };
320
319
  }
321
320
  // Starting a card is the dispatcher's: this write's wake hint reaches it, and the card's
322
321
  // thread records the start. A create never runs a dispatch tick inline.
@@ -326,7 +325,7 @@ export async function runWorkflow(argv) {
326
325
  const task = withBoard((b) => view(b, (db) => engine.getTask(db, created.id)));
327
326
  const recipients = withBoard(b => engine.subscriptions(b.db, created.id)).filter(s => s.platform === 'supercode').map(s => s.chat_id);
328
327
  if (args.json) { say(args, '', { ...cardDict(task), ...answer, notify: address ?? null, recipients }); return answer.failed ? 1 : 0; }
329
- const said = answer.started ? `; started session ${answer.session} (${answer.mode})` : answer.failed ? `; did not start: ${answer.failed}` : answer.waits ? `; waits: ${answer.waits}` : '';
328
+ const said = answer.adoption_requested ? `; held for ${answer.adoption_requested}, which the dispatcher binds on its next tick` : answer.started ? `; started session ${answer.session} (${answer.mode})` : answer.failed ? `; did not start: ${answer.failed}` : answer.waits ? `; waits: ${answer.waits}` : '';
330
329
  say(args, `${created.existing ? 'Existing' : 'Created'} ${created.id} (${task.status}, assignee=${task.assignee || '-'})${said}${recipients.length ? `; notices go to ${recipients.join(', ')}` : ''}`, null);
331
330
  return answer.failed ? 1 : 0;
332
331
  }
@@ -466,17 +465,18 @@ export async function runWorkflow(argv) {
466
465
  return 0;
467
466
  }
468
467
 
469
- case 'assign': need(2, 'a card id and a profile (or none)'); await withBoardAsync((b) => engine.assign(b, id, rest[1] === 'none' ? null : rest[1])); say(args, `Assigned ${id} to ${rest[1] === 'none' ? '(nobody)' : rest[1]}`, { ok: true }); return 0;
470
- case 'reassign': {
471
- need(2, 'a card id and a profile');
472
- await withBoardAsync(async (b) => {
473
- const task = engine.getTask(b.db, id);
474
- if (task?.current_run_id != null && args.reclaim) await engine.reclaim(b, id, args.reason ?? 'reassign');
475
- await engine.assign(b, id, rest[1] === 'none' ? null : rest[1]);
476
- });
477
- say(args, `Reassigned ${id} to ${rest[1]}`, { ok: true }); return 0;
468
+ // One door sets a card's properties; the workflow's `set` rules say who may and what follows (a live run
469
+ // whose session must change is ended, and the dispatcher starts the next itself). `assign`/`reassign` are
470
+ // Hermes's names for setting the assignee, and `set-model` its name for setting the model.
471
+ case 'set': {
472
+ need(3, 'a card id, a property (assignee, machine, model or every) and its value (or none)');
473
+ const value = rest[2] === 'none' ? null : rest[2];
474
+ const cards = ids([id]);
475
+ for (const t of cards) await withBoardAsync((b) => engine.set(b, t, rest[1], value, { provider: args.provider ?? null }));
476
+ say(args, cards.map((t) => `${t}: ${rest[1]} ${value ?? '(none)'}`).join('\n'), { ok: true }); return 0;
478
477
  }
479
- case 'set-model': need(1, 'a card id'); await withBoardAsync((b) => engine.setModel(b, id, rest[1] ?? null, args.provider ?? null)); say(args, rest[1] ? `${id} runs on model ${rest[1]}${args.provider ? ` (${args.provider})` : ''} from its next start` : `${id} uses its profile's model`, { ok: true }); return 0;
478
+ case 'set-model': need(1, 'a card id'); await withBoardAsync((b) => engine.set(b, id, 'model', rest[1] ?? null, { provider: args.provider ?? null })); say(args, rest[1] ? `${id} runs on model ${rest[1]}${args.provider ? ` (${args.provider})` : ''} from its next start` : `${id} uses its profile's model`, { ok: true }); return 0;
479
+ case 'assign': case 'reassign': need(2, 'a card id and a profile (or none)'); await withBoardAsync((b) => engine.set(b, id, 'assignee', rest[1] === 'none' ? null : rest[1])); say(args, `Assigned ${id} to ${rest[1] === 'none' ? '(nobody)' : rest[1]}`, { ok: true }); return 0;
480
480
  case 'set-goal': {
481
481
  // a card into goal mode (or out of it with --off) from its next idle turn: the heartbeat keeps its session going
482
482
  need(1, 'a card id');
@@ -485,7 +485,6 @@ export async function runWorkflow(argv) {
485
485
  for (const t of ids()) await withBoardAsync((b) => engine.setGoal(b, t, { on: !args.off, maxTurns }));
486
486
  say(args, ids().map((t) => args.off ? `${t} is no longer a goal card` : `${t} is a goal card${maxTurns ? ` (${maxTurns} turns)` : ''}`).join('\n'), { ok: true }); return 0;
487
487
  }
488
- case 'move': need(2, 'a card id and a machine (or none)'); await withBoardAsync((b) => engine.setMachine(b, id, rest[1] === 'none' ? null : rest[1])); say(args, `${id} runs on ${rest[1]}`, { ok: true }); return 0;
489
488
 
490
489
  case 'claim': {
491
490
  need(1, 'a card id');
@@ -571,13 +570,21 @@ export async function runWorkflow(argv) {
571
570
  case 'unblock': {
572
571
  need(1, 'a card id');
573
572
  const results = [];
574
- for (const t of ids()) results.push([t, (await withBoardAsync((b) => engine.unblock(b, t, { reason: args.reason ?? null, on: args.on ?? null }))).to]);
573
+ 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]);
575
574
  // Hermes records the reason as a comment on each card it unblocks
576
575
  if (args.reason) for (const [t] of results) await withBoardAsync((b) => engine.comment(b, t, callerName(), `UNBLOCK: ${args.reason}`));
577
576
  say(args, results.map(([t, s]) => `Unblocked ${t} → ${s}`).join('\n'), { ok: true, results }); return 0;
578
577
  }
579
578
  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, ci_run: args['ci-run'] ?? null, branch: args.branch ?? null, pr: args.pr ?? null, pr_head: args['pr-head'] ?? args.head ?? args.commit ?? null, force: Boolean(args.force), expectedRunId: workerRun(b.db, id, sid) })); say(args, `${id} → review`, { ok: true }); return 0;
580
- case 'request-changes': need(2, 'a card id and the reason'); await withBoardAsync((b) => engine.requestChanges(b, id, { reason: rest.slice(1).join(' '), reviewer: args.reviewer ?? null })); say(args, `${id} sent back for changes`, { ok: true }); return 0;
579
+ case 'request-changes': {
580
+ need(2, 'a card id and the reason');
581
+ // each finding is filed as a task on the arc first, so the arc stays open until a pushed head fixes it
582
+ const findings = [].concat(args.finding ?? []).map((f) => String(f).trim()).filter(Boolean);
583
+ const filed = [];
584
+ for (const finding of findings) filed.push((await withBoardAsync((b) => engine.createCard(b, { title: finding, subtaskOf: id }))).id);
585
+ await withBoardAsync((b) => engine.requestChanges(b, id, { reason: rest.slice(1).join(' '), reviewer: args.reviewer ?? null }));
586
+ say(args, `${id} sent back for changes${filed.length ? `; findings filed as ${filed.join(', ')}` : ''}`, { ok: true, findings: filed }); return 0;
587
+ }
581
588
  case 'reopen': {
582
589
  // a done card back to work; the cards that built on it are invalidated with it (Hermes's reopen)
583
590
  need(1, 'a card id');
@@ -805,11 +812,6 @@ export async function runWorkflow(argv) {
805
812
  return report.status === 'corrupt' ? 1 : 0;
806
813
  }
807
814
 
808
- case 'set-every': {
809
- need(1, 'a card id');
810
- await withBoardAsync((b) => engine.act(b, id, 'set_every', { payload: { every: rest[1] === 'none' ? null : rest[1] } }));
811
- say(args, `${id} recurrence updated`, { ok: true }); return 0;
812
- }
813
815
  case 'cancel': {
814
816
  need(1, 'a card id');
815
817
  await withBoardAsync((b) => engine.act(b, id, 'cancel', { payload: { reason: args.reason ?? rest.slice(1).join(' ') } }));
@@ -844,9 +846,8 @@ export async function runWorkflow(argv) {
844
846
  case 'adopt': {
845
847
  need(2, 'a card id and a session');
846
848
  withBoard(b => { if (!rolesFor(b.db, id, sid, workflowFor(root).params).includes('manager')) throw new Error('Only the manager adopts an existing session.'); });
847
- const { settings } = await laneState(root);
848
- const got = withBoard((b) => adoptSession(b, id, rest[1], { workspace: args.workspace ?? null, ttl: settings.ttl, assignee: args.assignee ?? null, role: args.role ?? null }));
849
- say(args, `${id} is worked by ${got.address} (pane ${got.pane ?? '?'} in ${got.workspace ?? 'its card\'s directory'})`, { ok: true, ...got });
849
+ withBoard((b) => requestAdoption(b, id, rest[1], { workspace: args.workspace ?? null, assignee: args.assignee ?? null, role: args.role ?? null }));
850
+ 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] });
850
851
  return 0;
851
852
  }
852
853
  case 'goto': {
@@ -1093,12 +1094,12 @@ function ownCard(db, sessionId) {
1093
1094
  const READS = new Set(['list', 'ls', 'show', 'context', 'runs', 'log', 'tail', 'watch', 'stats', 'assignees', 'diagnostics', 'diag', 'attachments', 'notify-list', 'help', 'init']);
1094
1095
 
1095
1096
  /**
1096
- * Who a caller is on card `cardId`, in the workflow's roles: a person or the fleet's manager at the CLI (no session)
1097
- * is `manager`; the session of the card's current run has that run's role; the card's working session between runs
1097
+ * Who a caller is on card `cardId`, in the workflow's roles: a caller with no session behind it has no role (the
1098
+ * manager acts from its own session, named in params.managers); the session of the card's current run has that run's role; the card's working session between runs
1098
1099
  * is its `implementer`; the session working a subtask's arc is `arc`; its recorded creator also has the creator role; any other session is nobody on it.
1099
1100
  */
1100
1101
  function rolesFor(db, cardId, sessionId, params = {}) {
1101
- if (!sessionId) return ['manager', 'owner'];
1102
+ if (!sessionId) return [];
1102
1103
  // a session the workflow names as the board's manager (params.managers: its addresses) is the manager on every card
1103
1104
  const managers = Array.isArray(params.managers) ? params.managers.map(String) : [];
1104
1105
  if (managers.some((m) => m === sessionId || m.endsWith(`:${sessionId}`))) return ['manager'];
@@ -1127,7 +1128,7 @@ function authorize(db, verb, rest, args, own) {
1127
1128
  if (verb === 'boards') { if (!['list', 'ls', 'show', 'current', undefined].includes(rest[0])) refuse('does not change the home\'s boards'); return; }
1128
1129
  // 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
1129
1130
  // (what a session may do to a card is its workflow's: each transition's `by`)
1130
- if ((['pause', 'resume'].includes(verb) && !rest[0]) || ['swarm', 'dispatch', 'daemon', 'serve', 'gc', 'repair', 'adopt', 'close', 'move', 'claim', 'attach-rm', 'migrate'].includes(verb)) refuse(`does not ${verb}; the fleet's manager does`);
1131
+ if ((['pause', 'resume'].includes(verb) && !rest[0]) || ['swarm', 'dispatch', 'daemon', 'serve', 'gc', 'repair', 'adopt', 'close', 'set', 'claim', 'attach-rm', 'migrate'].includes(verb)) refuse(`does not ${verb}; the fleet's manager does`);
1131
1132
  if (args.all) refuse(`does not ${verb} every card; only its own`);
1132
1133
  if (['notify-subscribe', 'notify-unsubscribe'].includes(verb) && args['for-card']) {
1133
1134
  if (args['for-card'] !== own.id) refuse(`subscribes only its own arc (--for-card ${own.id})`);
@@ -1205,13 +1206,21 @@ async function serve(root, args = {}) {
1205
1206
  // An installer owns this operational window, not a card pause. Finish the
1206
1207
  // current tick, acknowledge the request, then leave every run untouched.
1207
1208
  // A replacement serve sees the same request before its first tick.
1209
+ // A request whose installer is gone (its pane closed, or it was killed before clearing it) is void.
1208
1210
  const installation = join(root, 'installation-window.json');
1209
- if (existsSync(installation)) {
1210
- const request = JSON.parse(readFileSync(installation, 'utf8'));
1211
+ const readJson = (path) => { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; } };
1212
+ const request = existsSync(installation) ? readJson(installation) : null;
1213
+ const installerAlive = (pid) => { try { process.kill(Number(pid), 0); return true; } catch (error) { return error.code === 'EPERM'; } };
1214
+ if (request?.token && request.pid && !installerAlive(request.pid)) {
1215
+ log(`installation window ${request.token} of ${request.owner} is void: its installer ${request.pid} is gone`);
1216
+ for (const path of [installation, join(root, 'installation-window.ack.json')]) if (readJson(path)?.token === request.token) try { unlinkSync(path); } catch { /* gone */ }
1217
+ } else if (request?.token) {
1211
1218
  const acknowledgment = join(root, 'installation-window.ack.json');
1212
- const previous = existsSync(acknowledgment) ? JSON.parse(readFileSync(acknowledgment, 'utf8')) : null;
1219
+ const previous = readJson(acknowledgment);
1213
1220
  if (previous?.token !== request.token || previous?.pid !== process.pid) {
1214
- writeFileSync(acknowledgment, JSON.stringify({ token: request.token, pid: process.pid, at: Date.now() }));
1221
+ const temporary = `${acknowledgment}.${process.pid}`;
1222
+ writeFileSync(temporary, JSON.stringify({ token: request.token, pid: process.pid, at: Date.now() }));
1223
+ renameSync(temporary, acknowledgment);
1215
1224
  log(`installation window held by ${request.owner}; dispatch waits`);
1216
1225
  }
1217
1226
  await new Promise(resolve => setTimeout(resolve, 250));
@@ -19,13 +19,13 @@ import { inspectCompletionEvidence, openReviewPullRequests, reviewDue, withCompl
19
19
  import { createRequire } from 'node:module';
20
20
  import { spawn as spawnChild, spawnSync } from 'node:child_process';
21
21
  import { existsSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
22
- import { hostname, tmpdir } from 'node:os';
22
+ import { hostname, tmpdir, totalmem } from 'node:os';
23
23
  import { basename, dirname, join } from 'node:path';
24
24
  import { createHash, randomUUID } from 'node:crypto';
25
25
  import { fileURLToPath } from 'node:url';
26
- import { view, boardPath, claimer, event, json, listBoards, logsDir, now, openBoard, setActor, tx } from './store.mjs';
26
+ import { view, boardCopyOf, boardPath, claimer, copyRefusal, event, json, listBoards, logsDir, now, openBoard, setActor, tx } from './store.mjs';
27
27
  import { doors, fire, loadWorkflow, resumeOf, runSpec, runWorkingSeconds, requestSessionClose, retireSupersededRuns, refreshCardInputs, refreshRelationAnswers, transitionsFor, scopeOf, statusOf, templateValue, workflowFor } from './workflow.mjs';
28
- import { checkWorkspace, assertWorkspaceOwner, resolveWorkspace } from './workspace.mjs';
28
+ import { checkWorkspace, assertWorkspaceOwner, notePresence, resolveWorkspace } from './workspace.mjs';
29
29
  import { evaluate, holds, render } from './expr.mjs';
30
30
  import { identifyNotice } from './notices.mjs';
31
31
  import { pendingRequestMail } from './request-mail.mjs';
@@ -123,6 +123,8 @@ export function observeFleet() {
123
123
  const presence = machines.code === 0 ? lastJson(machines.stdout) : null;
124
124
  const online = new Set([localMachine()]);
125
125
  for (const m of presence?.items ?? []) if (m.linked) online.add(normalMachine(m.name));
126
+ presenceReading = { known: presence?.connected === true, online };
127
+ notePresence(presenceReading);
126
128
  return { ok: true, sessions, alive: new Set(sessions.map((s) => s.address).filter(Boolean)), online,
127
129
  presenceKnown: presence?.connected === true, machineEvents: presence,
128
130
  error: presence?.connected ? null : 'machine event stream unavailable; retaining existing runs until availability is known' };
@@ -143,6 +145,7 @@ const sessionAlive = (fleet, session) => fleet.native?.has(addressOf(session)) ?
143
145
 
144
146
  /** Native activity comes from the machine's harness discovery, not catalogue age. */
145
147
  function nativeSessions(machine, harness) {
148
+ if (offlineMachine(machine)) throw unavailable(`native liveness unavailable: machine ${normalMachine(machine)} is offline`);
146
149
  const result = sc(['discover', '--on', machine, '--harness', harness, '--limit', '2000'], { timeout: 120_000 });
147
150
  const page = result.code === 0 ? lastJson(result.stdout) : null;
148
151
  if (!Array.isArray(page?.sessions)) throw unavailable(`native liveness unavailable on ${machine}: ${(result.stderr || result.stdout).trim().slice(-300)}`);
@@ -173,13 +176,12 @@ function pidAlive(pid) {
173
176
 
174
177
  /**
175
178
  * Hermes's default for kanban.max_in_progress when none is set: one worker per 512 MiB of the host's memory, between
176
- * two and eight, from /proc/meminfo; none where that cannot be read (which caps nothing).
179
+ * two and eight. The memory is the host's own reading (os.totalmem: /proc/meminfo on Linux, hw.memsize on macOS), so
180
+ * the cap holds on every host, not only where /proc exists.
177
181
  */
178
182
  function derivedMaxInProgress() {
179
- try {
180
- const total = Number(/^MemTotal:\s+(\d+)/m.exec(readFileSync('/proc/meminfo', 'utf8'))?.[1]);
181
- return Number.isFinite(total) && total > 0 ? Math.max(2, Math.min(Math.floor(total / 1024 / 512), 8)) : null;
182
- } catch { return null; }
183
+ const total = totalmem();
184
+ return Number.isFinite(total) && total > 0 ? Math.max(2, Math.min(Math.floor(total / 1024 / 1024 / 512), 8)) : null;
183
185
  }
184
186
 
185
187
  /**
@@ -219,6 +221,17 @@ export function lanesOf(profiles = {}) {
219
221
  }
220
222
 
221
223
  const onMachine = machine => normalMachine(machine ?? localMachine()) === localMachine() ? [] : ['--machine', machine];
224
+ // The latest presence reading (observeFleet). A machine it reads as offline is not asked: each remote call would
225
+ // wait out its timeout, and one offline machine held every close and launch on this one.
226
+ let presenceReading = null;
227
+ const offlineMachine = (machine) => {
228
+ const m = normalMachine(machine ?? localMachine());
229
+ return m !== localMachine() && presenceReading?.known === true && !presenceReading.online.has(m);
230
+ };
231
+ /** `supercode <args>` for `machine`: on another machine through its Teams server, answered at once when it is offline. */
232
+ const scOn = (machine, args, options) => offlineMachine(machine)
233
+ ? { code: 1, stdout: '', stderr: `machine ${normalMachine(machine)} is offline` }
234
+ : sc([...args, ...onMachine(machine)], options);
222
235
  const unavailable = message => Object.assign(new Error(message), { launchDeferred: true });
223
236
 
224
237
  // ------------------------------------------------------------------ supervise
@@ -242,7 +255,7 @@ function supervise(board, fleet, settings, report, entry, effects) {
242
255
  for (const { id } of rows) {
243
256
  const s = currentRun(board.db, id)?.metadata?.supercode;
244
257
  if (!s?.pane || observed.has(s.machine)) continue;
245
- const r = sc(['teams', 'panes', 'ls', ...onMachine(s.machine)], { timeout: 60_000 });
258
+ const r = scOn(s.machine, ['teams', 'panes', 'ls'], { timeout: 60_000 });
246
259
  const list = r.code === 0 ? json(r.stdout.trim(), null) : null;
247
260
  observed.set(s.machine, Array.isArray(list) ? new Set(list.filter(p => !p.dead).map((p) => p.pane)) : null);
248
261
  if (!Array.isArray(list)) report.failed.push([id, `pane liveness unavailable; retaining run: ${(r.stderr || r.stdout).trim().slice(-200)}`]);
@@ -357,7 +370,17 @@ function supervise(board, fleet, settings, report, entry, effects) {
357
370
  if (session?.session_id) {
358
371
  if(exits.has(id)) {
359
372
  const exit=exits.get(id);
360
- observe('exited',{code:exit.failed?1:0,error:`native session ${session.session_id} ended: ${exit.how}`,session_id:session.session_id,pane:session.pane},'crashed');
373
+ // an interactive session that ended cleanly was closed (by a person, or by itself): the workflow says what
374
+ // follows, and it is not a crash to restart
375
+ const ended = { code: exit.failed ? 1 : 0, error: `native session ${session.session_id} ended: ${exit.how}`, session_id: session.session_id, pane: session.pane };
376
+ if (!exit.failed) {
377
+ try {
378
+ const r = fire(db, board, entry, id, 'session_closed', { roles: ['platform'], payload: ended });
379
+ effects.push(...r.effects); (report.closed_by_hand ??= []).push(id);
380
+ return;
381
+ } catch (error) { if (!error.noTransition) { report.failed.push([id, `session_closed: ${String(error.message).slice(-200)}`]); return; } }
382
+ }
383
+ observe('exited', ended, 'crashed');
361
384
  return;
362
385
  }
363
386
  if (session.pane && paneLive === null) return; // no machine reading, no loss verdict
@@ -405,6 +428,7 @@ function limitsOf(db, board, entry, task, session) {
405
428
 
406
429
  function stopSession(board, session) {
407
430
  if (!session) return;
431
+ if (boardCopyOf()) throw copyRefusal('closes no session');
408
432
  if (session.surface === 'headless' && pidAlive(session.pid)) {
409
433
  // SIGTERM to the worker's process group, then SIGKILL if it is still there after a grace, as Hermes stops its own
410
434
  const signal = (sig) => { try { process.kill(-session.pid, sig); } catch { try { process.kill(session.pid, sig); } catch { /* gone */ } } };
@@ -413,6 +437,7 @@ function stopSession(board, session) {
413
437
  if (pidAlive(session.pid)) signal('SIGKILL');
414
438
  return;
415
439
  }
440
+ if (session.surface !== 'headless' && offlineMachine(session.machine)) throw new Error(`close deferred: machine ${normalMachine(session.machine)} is offline`);
416
441
  if (!session.pane && session.surface === 'native') {
417
442
  const r = sc(['close', addressOf(session)], { timeout: 120_000, as: boardAddress(board.label) });
418
443
  if (r.code !== 0) throw new Error(`closing native session ${addressOf(session)}: ${(r.stderr || r.stdout).trim().slice(-500)}`);
@@ -527,7 +552,8 @@ function startCards(board, fleet, lanes, settings, report, entry, effects, { onl
527
552
  if (why) { note(task.id, why); report.queued.push([task.id, why]); continue; }
528
553
  if (dry) {
529
554
  report.would.push({ id: task.id, status: plan.target, role: plan.runSpec?.role ?? null, assignee: plan.profile, machine: task.machine ?? null, surface: plan.lane.surface });
530
- for (const l of limits) if (holds(l.counts, scope)) { const g = JSON.stringify(evaluate(l.group, scope)); l.tally.set(g, (l.tally.get(g) ?? 0) + 1); }
555
+ // a card this tick starts counts toward its groups (its scope was read before the start, with no run yet)
556
+ for (const l of limits) { const g = JSON.stringify(evaluate(l.group, scope)); l.tally.set(g, (l.tally.get(g) ?? 0) + 1); }
531
557
  continue;
532
558
  }
533
559
  let moved, checked;
@@ -549,7 +575,7 @@ function startCards(board, fleet, lanes, settings, report, entry, effects, { onl
549
575
  continue;
550
576
  }
551
577
  effects.push(...moved.effects);
552
- for (const l of limits) if (holds(l.counts, cardScope.get(task.id) ?? scope)) { const g = JSON.stringify(evaluate(l.group, scope)); l.tally.set(g, (l.tally.get(g) ?? 0) + 1); }
578
+ for (const l of limits) { const g = JSON.stringify(evaluate(l.group, scope)); l.tally.set(g, (l.tally.get(g) ?? 0) + 1); }
553
579
  try {
554
580
  const session = launchRun(board, entry, task.id, plan.lane, fleet, settings);
555
581
  if (session) {
@@ -690,20 +716,17 @@ export function closeRequested(board, report, { taskId = null, caller = null } =
690
716
  if (reading.alive) {
691
717
  const protectedNow = active.some(r => addressOf(json(r.metadata, {})?.supercode) === addressOf(session));
692
718
  if (protectedNow) throw new Error('close deferred: native session is owned by an unended run');
693
- if (noticeHoldsClose(session, null)) throw new Error(`close deferred: lifecycle notice ${session.lifecycle_notice_pending} has not been filed`);
694
719
  stopSession(board, session);
695
720
  }
696
721
  } else {
697
722
  if (!panes.has(machine)) {
698
- const r = sc(['teams', 'panes', 'ls', ...onMachine(machine)], { timeout: 60_000, as: boardAddress(board.label) });
723
+ const r = scOn(machine, ['teams', 'panes', 'ls'], { timeout: 60_000, as: boardAddress(board.label) });
699
724
  const list = r.code === 0 ? json(r.stdout.trim(), null) : null;
700
725
  panes.set(machine, Array.isArray(list) ? { live: new Set(list.filter(p => !p.dead).map((p) => p.pane)) } : { error: `cannot read panes on ${machine}: ${(r.stderr || r.stdout).trim().slice(-300)}` });
701
726
  }
702
727
  const observed = panes.get(machine);
703
728
  if (observed.error) throw new Error(observed.error);
704
729
  if (observed.live.has(session.pane)) {
705
- const pendingNotice = view(board, (db) => db.prepare("SELECT id FROM supercode_effects WHERE json_extract(effect, '$.kind')='session_notice' AND json_extract(effect, '$.runId')=? LIMIT 1").get(row.id));
706
- if (noticeHoldsClose(session, pendingNotice)) throw new Error(`close deferred: lifecycle notice ${pendingNotice?.id ?? session.lifecycle_notice_pending} has not been filed: ${session.lifecycle_notice_error ?? 'pending'}`);
707
730
  const protectedNow = view(board, (db) => db.prepare(`SELECT metadata FROM task_runs r WHERE r.ended_at IS NULL OR EXISTS (SELECT 1 FROM tasks t WHERE t.current_run_id = r.id)`).all())
708
731
  .some((r) => { const s = json(r.metadata, {})?.supercode; return s?.pane === session.pane && normalMachine(s.machine) === normalMachine(session.machine); });
709
732
  if (protectedNow) throw new Error(`close deferred: pane ${session.pane} is owned by an unended run`);
@@ -800,9 +823,10 @@ export async function sessionNotice(board, e) {
800
823
  const prior = view(board, (db) => json(db.prepare('SELECT metadata FROM task_runs WHERE id=?').get(e.runId)?.metadata, {})?.supercode);
801
824
  if (prior?.close_confirmed_at) {
802
825
  tx(board, (db) => {
826
+ // A closed session has nothing to be told; its next run starts with its own prompt.
803
827
  const unissued = unissuedLaunch(prior.launch_cancellation, prior);
804
- event(db, e.id, unissued ? 'session_notice_not_required' : 'session_notice_failed', { address: e.address, verb: e.verb, notice: e.durableId,
805
- error: unissued ? 'machine confirmed launch was not issued; no session exists to notify' : 'session closure already confirmed; notice cannot be delivered', retry: false,
828
+ event(db, e.id, 'session_notice_not_required', { address: e.address, verb: e.verb, notice: e.durableId,
829
+ reason: unissued ? 'machine confirmed launch was not issued; no session exists to notify' : 'session closed',
806
830
  ...(unissued ? { receipt: prior.launch_cancellation } : {}) }, e.runId);
807
831
  if (prior.lifecycle_notice_pending === e.durableId) setRunSession(db, e.runId, { lifecycle_notice_pending: null });
808
832
  });
@@ -820,19 +844,13 @@ export async function sessionNotice(board, e) {
820
844
  tx(board, (db) => {
821
845
  const session = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(e.runId)?.metadata, {})?.supercode;
822
846
  if (session) setRunSession(db, e.runId, { ...session,
823
- ...(error ? { lifecycle_notice_error: error, lifecycle_notice_failures: Number(session.lifecycle_notice_failures ?? 0) + 1 }
824
- : { lifecycle_notice_error: null, lifecycle_notice_failures: 0, ...(session.lifecycle_notice_pending === e.durableId ? { lifecycle_notice_pending: null } : {}) }) });
847
+ ...(error ? { lifecycle_notice_error: error }
848
+ : { lifecycle_notice_error: null, ...(session.lifecycle_notice_pending === e.durableId ? { lifecycle_notice_pending: null } : {}) }) });
825
849
  if (!error || session?.lifecycle_notice_error !== error) event(db, e.id, error ? 'session_notice_failed' : receipt?.delivered ? 'session_notice_delivered' : 'session_notice_queued', { address: e.address, verb: e.verb, source_event: e.sourceEvent, notice: e.durableId, receipt, ...(error ? { error, retry: true } : {}) }, e.runId);
826
850
  });
827
851
  return !error;
828
852
  }
829
853
 
830
- // A close waits for its run's lifecycle notice to be filed, but not past this many failed
831
- // attempts: an unreachable session must not hold its pane, and its slot's next launch, forever.
832
- const NOTICE_ATTEMPTS_BEFORE_CLOSE = 3;
833
- const noticeHoldsClose = (session, pending) => Boolean(pending || session.lifecycle_notice_pending)
834
- && Number(session.lifecycle_notice_failures ?? 0) < NOTICE_ATTEMPTS_BEFORE_CLOSE;
835
-
836
854
  /** A session effect a committed move asked for: a message to the card's current actor, or closing a slot's pane. */
837
855
  export async function sessionEffect(board, e) {
838
856
  if (e.kind === 'send_to_actor') {
@@ -898,8 +916,16 @@ const BLOCKER = /\b(quota|rate[\s_-]?limit|429|403|auth\w*|unauthorized|forbidde
898
916
  const PR_URL = /https?:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/\d+/i;
899
917
 
900
918
 
901
- /** This machine's memory pressure as Hermes reads it (Linux's MemAvailable; `unknown` elsewhere, which restricts nothing). */
919
+ /**
920
+ * This machine's memory pressure: on Linux as Hermes reads it (MemAvailable); on macOS the kernel's own level
921
+ * (kern.memorystatus_vm_pressure_level: 1 normal, 2 warning, 4 critical); `unknown` elsewhere, which restricts nothing.
922
+ */
902
923
  function memoryPressure() {
924
+ if (process.platform === 'darwin') {
925
+ const r = spawnSync('sysctl', ['-n', 'kern.memorystatus_vm_pressure_level'], { encoding: 'utf8', timeout: 5_000 });
926
+ const level = Number(String(r.stdout ?? '').trim());
927
+ return level >= 4 ? 'critical' : level >= 2 ? 'elevated' : level === 1 ? 'ok' : 'unknown';
928
+ }
903
929
  try {
904
930
  const info = Object.fromEntries(readFileSync('/proc/meminfo', 'utf8').split('\n').map((l) => /^(\w+):\s+(\d+)/.exec(l)).filter(Boolean).map((m) => [m[1], Number(m[2])]));
905
931
  const avail = info.MemAvailable;
@@ -937,7 +963,7 @@ const getTaskNow = (board, id) => view(board, (db) => getTask(db, id));
937
963
 
938
964
  /** A transport error is not a negative launch receipt. The key remains owned by its run. */
939
965
  function launchReceipt(board, session) {
940
- const r = sc(['teams', 'launches', 'get', session.launch_key, ...onMachine(session.machine)],
966
+ const r = scOn(session.machine, ['teams', 'launches', 'get', session.launch_key],
941
967
  { timeout: 30_000, as: boardAddress(board.label) });
942
968
  if (r.code !== 0) throw unavailable(`launch receipt unavailable on ${session.machine}: install the matching Teams CLI and machine capability (teams launches get): ${(r.stderr || r.stdout).trim().slice(-300)}`);
943
969
  if (r.stdout.trim() === 'null') return null;
@@ -947,7 +973,7 @@ function launchReceipt(board, session) {
947
973
  }
948
974
 
949
975
  function cancelUnissuedLaunch(board, session) {
950
- const result = sc(['teams', 'launches', 'cancel', session.launch_key, ...onMachine(session.machine)],
976
+ const result = scOn(session.machine, ['teams', 'launches', 'cancel', session.launch_key],
951
977
  { timeout: 30_000, as: boardAddress(board.label) });
952
978
  if (result.code !== 0) throw unavailable(`launch cancellation unavailable on ${session.machine}; retain ${session.launch_key}: ${(result.stderr || result.stdout).trim().slice(-300)}`);
953
979
  const receipt = lastJson(result.stdout);
@@ -1112,7 +1138,7 @@ function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'wor
1112
1138
  if (prev?.session_id && !prev.forgotten && (prev.harness ?? 'claude-code') === harness && prev.machine === machine) {
1113
1139
  const fresh = observeFleet();
1114
1140
  if (!fresh.ok) throw unavailable(`cannot check for a manual continuation of ${prev.session_id}: ${fresh.error}`);
1115
- const r = sc(['teams', 'panes', 'ls', ...onMachine(machine)], { timeout: 60_000 });
1141
+ const r = scOn(machine, ['teams', 'panes', 'ls'], { timeout: 60_000 });
1116
1142
  const listed = r.code === 0 ? json(r.stdout.trim(), null) : null;
1117
1143
  if (!Array.isArray(listed)) throw unavailable(`cannot read panes before continuing ${prev.session_id}: ${(r.stderr || r.stdout).trim().slice(-300)}`);
1118
1144
  const pane = continuationPane(fresh, prev, new Set(listed.filter(p => !p.dead).map((p) => p.pane)));
@@ -1245,7 +1271,7 @@ function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'wor
1245
1271
 
1246
1272
  /** A machine reading, not a missing catalog entry, establishes whether a pane is alive. */
1247
1273
  function panePresent(machine, pane) {
1248
- const r = sc(['teams', 'panes', 'ls', ...onMachine(machine)], { timeout: 60_000 });
1274
+ const r = scOn(machine, ['teams', 'panes', 'ls'], { timeout: 60_000 });
1249
1275
  const panes = r.code === 0 ? json(r.stdout.trim(), null) : null;
1250
1276
  if (!Array.isArray(panes)) throw unavailable(`cannot establish pane ${pane} liveness on ${machine}: ${(r.stderr || r.stdout).trim().slice(-300)}`);
1251
1277
  return panes.some((p) => p.pane === pane && !p.dead);
@@ -1322,14 +1348,40 @@ function resolveLocalWorkspace(board, task) {
1322
1348
  * never as the session describes itself. From then on the board watches it, resumes it when lost, and tells it of
1323
1349
  * its card. `who` is an address, a session id, or a `name@machine` the fleet lists.
1324
1350
  */
1325
- export function adoptSession(board, id, who, { workspace = null, ttl, assignee = null, role = null } = {}) {
1326
- // Hold before discovery or any other process I/O: a failed attachment remains
1327
- // visible and cannot race a dispatcher into launching a replacement worker.
1351
+ /**
1352
+ * The manager asks the board to adopt session `who` for card `id`: the card is held at once (no replacement can be
1353
+ * launched), and the dispatcher's next tick verifies the session from the fleet reading it already takes and binds
1354
+ * it (`completeAdoptions`). The verb does no process I/O, so it answers as fast as any board write.
1355
+ */
1356
+ export function requestAdoption(board, id, who, { workspace = null, assignee = null, role = null } = {}) {
1357
+ if (!who) throw new Error('name the session to adopt');
1358
+ if (boardCopyOf()) throw copyRefusal('adopts no session');
1328
1359
  tx(board, db => {
1329
1360
  if (!getTask(db, id)) throw new Error(`no card ${id}`);
1330
- event(db, id, 'dispatch_held', { session: who, reason: 'adoption requested' });
1361
+ event(db, id, 'dispatch_held', { session: who, reason: 'adoption requested', workspace, assignee, role });
1331
1362
  });
1332
- const fleet = observeFleet();
1363
+ }
1364
+
1365
+ /** Adoptions the manager requested and no tick has answered yet, completed from this tick's fleet reading. */
1366
+ export function completeAdoptions(board, fleet, settings, report) {
1367
+ const pending = view(board, db => db.prepare(`SELECT e.task_id, e.payload FROM task_events e WHERE e.kind = 'dispatch_held'
1368
+ AND json_extract(e.payload, '$.reason') = 'adoption requested'
1369
+ AND e.id = (SELECT MAX(id) FROM task_events l WHERE l.task_id = e.task_id AND l.kind IN ('dispatch_held','dispatch_released','session_adopted','adoption_failed'))`).all());
1370
+ for (const row of pending) {
1371
+ const asked = json(row.payload, {}) ?? {};
1372
+ try {
1373
+ const got = adoptSession(board, row.task_id, asked.session, { workspace: asked.workspace ?? null, assignee: asked.assignee ?? null, role: asked.role ?? null, ttl: settings.ttl, fleet: fleet?.ok ? fleet : null });
1374
+ (report.adopted ??= []).push([row.task_id, got.address]);
1375
+ } catch (error) {
1376
+ const message = String(error.message).slice(-400);
1377
+ tx(board, db => event(db, row.task_id, 'adoption_failed', { session: asked.session, error: message }));
1378
+ report.failed.push([row.task_id, `adoption of ${asked.session}: ${message}`]);
1379
+ }
1380
+ }
1381
+ }
1382
+
1383
+ function adoptSession(board, id, who, { workspace = null, ttl, assignee = null, role = null, fleet = null } = {}) {
1384
+ fleet ??= observeFleet();
1333
1385
  if (!fleet.ok) throw new Error(`supercode could not read the fleet: ${fleet.error}`);
1334
1386
  const found = fleet.sessions.find((s) => s.address === who || s.address.endsWith(`:${who}`) || s.name === who);
1335
1387
  if (!found) throw new Error(`the fleet lists no live session ${who}`);
@@ -1392,6 +1444,7 @@ export function adoptSession(board, id, who, { workspace = null, ttl, assignee =
1392
1444
 
1393
1445
  // a notice sent within a transaction's caller that cannot await (the launcher): the same door, synchronously
1394
1446
  function deliverNow(board, to, body, key) {
1447
+ if (boardCopyOf()) throw copyRefusal('sends no notice to sessions');
1395
1448
  const request = { op: 'send', from: boardAddress(board.label), from_name: `${board.label} board`, to, queue: false, id: key, subject: String(body).split('\n')[0].slice(0, 160),
1396
1449
  body: `[Board notice from the ${board.label} board] ${body}\n\n(An automated notice from the board's dispatcher; replies to this address go nowhere.)` };
1397
1450
  const r = sc(['message', 'remote-deliver'], { input: JSON.stringify(request), timeout: 120_000 });
@@ -1401,6 +1454,7 @@ function deliverNow(board, to, body, key) {
1401
1454
  }
1402
1455
 
1403
1456
  async function send(from, label, to, body, key, { queue = false, ask = false, subject } = {}) {
1457
+ if (boardCopyOf()) throw copyRefusal('sends no mail to sessions or managers');
1404
1458
  const request = {
1405
1459
  op: 'send', from, from_name: `${label} board`, to, queue, id: key, subject: String(subject ?? String(body).split('\n')[0]).slice(0, 160),
1406
1460
  // a question the board waits on is answered by a reply; a notice is not
@@ -1432,7 +1486,7 @@ export async function sendBoardMessages(board, report, taskId = null) {
1432
1486
  const task = view(board, db => getTask(db, first.task_id));
1433
1487
  const subject = mail.requester ? `State requests · ${mail.members.length} request(s) · ${task?.title || first.task_id}` : `${task?.title || first.task_id} · ${mail.message_id ? 'Question' : 'Update'}`;
1434
1488
  const receipt = await send(boardAddress(board.label), board.label, mail.to, mail.text, mail.message_id ?? `board-message-${first.id}`, { ask: Boolean(mail.message_id), subject });
1435
- if (!receipt.delivered) throw new Error(receipt.reason ?? 'queued; native transcript confirmation pending');
1489
+ if (!receipt.delivered && !receipt.queued) throw new Error(receipt.reason ?? 'message was not filed');
1436
1490
  tx(board, db => {
1437
1491
  for (const member of mail.members) event(db, member.task_id, 'board_message_sent', { event: member.id, to: mail.to, receipt, ...(mail.message_id ? { message_id: mail.message_id, request_id: member.message_id ?? mail.message_id } : {}) });
1438
1492
  });
@@ -1510,11 +1564,23 @@ async function notify(board, fleet, report, entry) {
1510
1564
  if (!events.length) return;
1511
1565
  // the card as the workflow names it (its status the workflow's: triaging, reviewing, stopped; `stored_as` kanban.db's)
1512
1566
  const task = view(board, (db) => { const t = getTask(db, sub.task_id); return t ? { ...t, status: statusOf(db, t), stored_as: t.status } : t; });
1567
+ // a worker whose run has ended is not woken by a notice (a second session would start working the card beside
1568
+ // the one the next run gets): the notice is filed in its mailbox, read when it next works
1569
+ const working = role !== 'worker' || view(board, (db) => addressOf(currentRun(db, sub.task_id)?.metadata?.supercode) === sub.chat_id);
1570
+ // An idle Claude session cannot hold a notice in its mailbox (it starts a turn for every message it is given), so
1571
+ // a notice that must not wake it waits, unsent, for the session's next work.
1572
+ if (!working && fleet.ok && fleet.sessions.some((s) => s.address === sub.chat_id && s.harness === 'claude-code' && s.status === 'idle')) {
1573
+ report.notices_waiting = (report.notices_waiting ?? 0) + 1;
1574
+ return;
1575
+ }
1513
1576
  let cursor = Number(sub.last_event_id);
1514
- let failed = null, pending = false;
1577
+ let failed = null;
1515
1578
  try {
1579
+ // an event older than the workflow's notice_max_age_seconds is history, not news: it is passed over, never mailed
1580
+ const maxAge = Number(entry.params.notice_max_age_seconds) || null;
1516
1581
  for (const ev of events) {
1517
1582
  const p = json(ev.payload, {}) ?? {};
1583
+ if (maxAge && now() - Number(ev.created_at) > maxAge) { cursor = ev.id; (report.stale_notices_passed ??= 0); report.stale_notices_passed += 1; continue; }
1518
1584
  // nobody is told of their own act
1519
1585
  if (p.by && p.by === sub.chat_id) { cursor = ev.id; continue; }
1520
1586
  let text;
@@ -1525,9 +1591,11 @@ async function notify(board, fleet, report, entry) {
1525
1591
  } else text = noticeOf(entry, role, ev.kind, task, p);
1526
1592
  if (text) {
1527
1593
  const body = ['worker', 'dependency'].includes(role) ? identifyNotice(ev, text) : text;
1528
- const receipt = await send(from, board.label, sub.chat_id, body, `board-${sub.task_id}-${ev.id}-${sub.chat_id.slice(-12)}`, { queue: !entry.workflow.run && sub.delivery_mode === 'notify', subject: `${ev.kind.replaceAll('_', ' ')} · run ${ev.run_id ?? 'none'} · event ${ev.id} · ${task?.title || sub.task_id}` });
1529
- if (!receipt.delivered) { pending = Boolean(receipt.queued); throw new Error(receipt.reason ?? 'native delivery confirmation pending'); }
1530
- tx(board, (db) => event(db, sub.task_id, 'notice_delivered', { source_event: ev.id, kind: ev.kind, address: sub.chat_id, receipt }));
1594
+ const receipt = await send(from, board.label, sub.chat_id, body, `board-${sub.task_id}-${ev.id}-${sub.chat_id.slice(-12)}`, { queue: !working || (!entry.workflow.run && sub.delivery_mode === 'notify'), subject: `${ev.kind.replaceAll('_', ' ')} · run ${ev.run_id ?? 'none'} · event ${ev.id} · ${task?.title || sub.task_id}` });
1595
+ // A notice the recipient's mailbox took is filed: the machine delivers it at the session's next turn, so the
1596
+ // board does not send it again.
1597
+ if (!receipt.delivered && !receipt.queued) throw new Error(receipt.reason ?? 'notice was not filed');
1598
+ tx(board, (db) => event(db, sub.task_id, receipt.delivered ? 'notice_delivered' : 'notice_filed', { source_event: ev.id, kind: ev.kind, address: sub.chat_id, receipt }));
1531
1599
  report.notified.push([sub.task_id, ev.kind, sub.chat_id]);
1532
1600
  }
1533
1601
  cursor = ev.id;
@@ -1535,10 +1603,11 @@ async function notify(board, fleet, report, entry) {
1535
1603
  } catch (error) {
1536
1604
  failed = String(error.message).slice(-200);
1537
1605
  tx(board, (db) => {
1538
- const kind = pending ? 'notice_queued' : 'notice_failed';
1606
+ const kind = 'notice_failed';
1539
1607
  const previous = db.prepare('SELECT payload FROM task_events WHERE task_id=? AND kind=? ORDER BY id DESC LIMIT 1').get(sub.task_id, kind);
1540
1608
  const payload = { after_event: cursor, address: sub.chat_id, error: failed, retry: true };
1541
- if (JSON.stringify(json(previous?.payload, {})) !== JSON.stringify(payload)) event(db, sub.task_id, kind, payload);
1609
+ const { by: _, ...said } = json(previous?.payload, {}) ?? {};
1610
+ if (JSON.stringify(said) !== JSON.stringify(payload)) event(db, sub.task_id, kind, payload);
1542
1611
  });
1543
1612
  report.undelivered.push([sub.task_id, sub.chat_id, failed]);
1544
1613
  }
@@ -1547,7 +1616,7 @@ async function notify(board, fleet, report, entry) {
1547
1616
  const where = "WHERE task_id = ? AND platform = 'supercode' AND chat_id = ? AND thread_id = ?";
1548
1617
  const identity = [sub.task_id, sub.chat_id, sub.thread_id ?? ''];
1549
1618
  db.prepare(`UPDATE kanban_notify_subs SET last_event_id = ? ${where} AND last_event_id < ?`).run(cursor, ...identity, cursor);
1550
- const failures = failed && !pending ? Number(sub.delivery_metadata?.failures ?? 0) + 1 : 0;
1619
+ const failures = failed ? Number(sub.delivery_metadata?.failures ?? 0) + 1 : 0;
1551
1620
  if (failures >= MAX_SEND_FAILURES) { db.prepare(`DELETE FROM kanban_notify_subs ${where}`).run(...identity); return; }
1552
1621
  if (failures !== Number(sub.delivery_metadata?.failures ?? 0)) db.prepare(`UPDATE kanban_notify_subs SET delivery_metadata = ? ${where}`).run(JSON.stringify({ ...sub.delivery_metadata, failures }), ...identity);
1553
1622
  if (task?.status === 'archived' && cursor >= events.at(-1).id) db.prepare(`DELETE FROM kanban_notify_subs ${where}`).run(...identity);
@@ -1805,11 +1874,16 @@ async function activatePullRequestReviews(board, entry, report, effects, { only
1805
1874
  const ready = view(board, eligible);
1806
1875
  if (!ready) continue;
1807
1876
  if (ready !== true) { report.queued.push([id, `review waits: ${ready.length} open task(s) in the card's Tasks: ${ready[0].id} ${ready[0].title}`]); continue; }
1877
+ let payload = null;
1808
1878
  try {
1809
- const payload = openReviewPullRequests(board, id);
1879
+ payload = openReviewPullRequests(board, id);
1810
1880
  // The arc's open PRs ask for one review only while some head has no verdict: after changes are
1811
1881
  // requested the next run is the implementer's, until a new head is pushed.
1812
- if (!payload || !reviewDue(board, id, payload.pr.map((url, index) => ({ url, head: payload.pr_head[index] })))) continue;
1882
+ if (!payload) { report.queued.push([id, 'review waits: no open PR on its branch, a wt/<card> branch or a registered checkout']); continue; }
1883
+ if (!reviewDue(board, id, payload.pr.map((url, index) => ({ url, head: payload.pr_head[index] })))) {
1884
+ report.queued.push([id, `review waits: every open PR head (${payload.pr.join(', ')}) has a verdict or predates the last one; push a commit that answers it`]);
1885
+ continue;
1886
+ }
1813
1887
  const evidence = inspectCompletionEvidence(board, id, entry, payload, 'request_review');
1814
1888
  if (!evidence.ready) throw new Error(evidence.reason);
1815
1889
  refreshCardInputs(board, id, { commits: true });
@@ -1824,7 +1898,7 @@ async function activatePullRequestReviews(board, entry, report, effects, { only
1824
1898
  effects.push(...moved.effects);
1825
1899
  (report.review_requested ??= []).push({ id, prs: payload.pr, heads: payload.pr_head });
1826
1900
  }
1827
- } catch (error) { report.queued.push([id, `PR review waits: ${error.message}`]); }
1901
+ } catch (error) { report.queued.push([id, `PR review waits${payload ? ` (${payload.pr.join(', ')})` : ''}: ${error.message}`]); }
1828
1902
  }
1829
1903
  }
1830
1904
 
@@ -1856,6 +1930,8 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
1856
1930
  const path = boardPath(root, slug);
1857
1931
  const marker = `${path}.corrupt-seen`;
1858
1932
  if (existsSync(marker) && readFileSync(marker, 'utf8').trim() === fingerprint(path)) { report.error = 'skipped: the board file was corrupt and has not changed since (`supercode workflow repair`)'; out.push(report); continue; }
1933
+ // a copy of a home dispatches nothing: its runs, sessions and managers are the original's
1934
+ if (boardCopyOf()) { report.error = `skipped: ${copyRefusal('starts, supervises, adopts and mails nothing').message}`; out.push(report); continue; }
1859
1935
  let board;
1860
1936
  try {
1861
1937
  board = openBoard(root, slug);
@@ -1872,7 +1948,7 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
1872
1948
  readMailbox(board, boardAddress(board.label), report); phase('mailbox');
1873
1949
  await refreshRelationAnswers(board); phase('answers');
1874
1950
  const effects = [];
1875
- reconcileLaunches(board, fleet, report, entry, effects, settings, lanes); supervise(board, fleet, settings, report, entry, effects); await runEffects(board, effects.splice(0), { retryNotices: true }); closeRequested(board, report); phase('supervise');
1951
+ completeAdoptions(board, fleet, settings, report); reconcileLaunches(board, fleet, report, entry, effects, settings, lanes); supervise(board, fleet, settings, report, entry, effects); await runEffects(board, effects.splice(0), { retryNotices: true }); closeRequested(board, report); phase('supervise');
1876
1952
  report.unsettled = [];
1877
1953
  report.promoted = recomputeReady(board, effects, report.unsettled);
1878
1954
  if (dispatch) await activatePullRequestReviews(board, entry, report, effects, { only });
package/board/engine.mjs CHANGED
@@ -214,6 +214,11 @@ export function createCard(board, spec) {
214
214
  if (arc?.subtask_of) throw new Error(`${arc.id} is itself a subtask of ${arc.subtask_of}; a subtask belongs to an arc`);
215
215
  let id = newTaskId();
216
216
  while (one(db, 'SELECT 1 FROM tasks WHERE id = ?', id)) id = newTaskId();
217
+ // where a card's blocks are relations, a card created blocked names what it waits on: one that names nothing is
218
+ // ready at once (board-ir-2 started six minutes after it was created "blocked")
219
+ if (initial === 'blocked' && !entry.workflow.statuses?.blocked && !parents.length) {
220
+ throw new Error('A card created blocked names what it waits on: --parent <card> (it starts when that card is done); or create it and block it with --waiting-on <message>.');
221
+ }
217
222
  const creationRoles = workflowFor(board.root).params.create_dependencies_by;
218
223
  if ((parents.length || initial === 'blocked') && creationRoles && !callerRoles(db, arc?.id ?? id).some(role => creationRoles.includes(role))) {
219
224
  throw new Error('Creating a dependency or initially blocked card requires a permitted manager role; file the task without a blocker and use request-state.');
@@ -279,8 +284,20 @@ function wouldCycle(db, parent, child) {
279
284
 
280
285
 
281
286
 
282
- /** The machine a card's session runs on. Moving it is a decision: a running card is reclaimed first. */
283
- export const setMachine = (board, id, machine, { roles } = {}) => act(board, id, 'move', { roles, payload: { machine: machine || null } });
287
+ /**
288
+ * Set one of a card's properties (`assignee`, `machine`, `model`, `every`) through the workflow's `set` verb, whose
289
+ * rules say who may and what follows: a change to how its session runs ends a live run and lets the dispatcher start
290
+ * the next one itself. A workflow without `set` (Hermes's own) has a verb per property.
291
+ */
292
+ export const SETTABLE = ['assignee', 'machine', 'model', 'every'];
293
+ export function set(board, id, field, value, { provider = null, roles } = {}) {
294
+ if (!SETTABLE.includes(field)) throw new Error(`set takes one of ${SETTABLE.join(', ')}, not ${field}`);
295
+ if (provider && (field !== 'model' || !value)) throw new Error('a provider goes with a model');
296
+ value = value || null;
297
+ if (workflowFor(board.root).workflow.verbs?.set) return act(board, id, 'set', { roles, payload: { field, value, provider } });
298
+ const verb = { assignee: ['assign', { assignee: value }], machine: ['move', { machine: value }], model: ['set_model', { model: value, provider }], every: ['set_every', { every: value }] }[field];
299
+ return act(board, id, verb[0], { roles, payload: verb[1] });
300
+ }
284
301
 
285
302
  // ------------------------------------------------------------------ lifecycle
286
303
 
@@ -545,12 +562,7 @@ export async function specify(board, id, { title = null, body = null, workspace
545
562
  }
546
563
  export const editResult = (board, id, { result, summary = null, metadata = undefined, roles } = {}) => act(board, id, 'edit', { roles, payload: { result, summary, metadata } });
547
564
  export const heartbeat = (board, id, note = null, { roles, until = null } = {}) => act(board, id, 'heartbeat', { roles, payload: { note, until } });
548
- export const setModel = (board, id, model = null, provider = null, { roles } = {}) => {
549
- if (provider && !model) throw new Error('a provider needs a model');
550
- return act(board, id, 'set_model', { roles, payload: { model: model || null, provider: provider || null } });
551
- };
552
565
  export const setGoal = (board, id, { on = true, maxTurns = null, roles } = {}) => act(board, id, 'set_goal', { roles, payload: { goal: Boolean(on), max_turns: on ? maxTurns : null } });
553
- export const assign = (board, id, profile, { roles } = {}) => act(board, id, 'assign', { roles, payload: { assignee: profile || null } });
554
566
 
555
567
  /** A triage card fans out into children it waits on (Hermes's decomposer); answers their ids. */
556
568
  export async function decompose(board, id, children, { author = 'user', roles } = {}) {
package/board/store.mjs CHANGED
@@ -10,7 +10,7 @@
10
10
  // are in `engine.mjs`. Hermes's own tools may write a `kanban.db` at the same time: every write there takes SQLite's
11
11
  // native transaction boundary and retries an optimistic conflict.
12
12
  import { randomBytes } from 'node:crypto';
13
- import { existsSync, mkdirSync, readdirSync, readFileSync } from 'node:fs';
13
+ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
14
14
  import { hostname } from 'node:os';
15
15
  import { dirname, join } from 'node:path';
16
16
  import { DatabaseSync } from 'node:sqlite';
@@ -142,7 +142,23 @@ export function layOut(db, { fresh }) {
142
142
  * Open (creating when `create`, in `backing`) board `slug`: `{ db, root, slug, path, backing, tx, close }`. `db` answers
143
143
  * SQLite's statement API (`prepare(sql).get|all|run`, `exec`) whatever the backing.
144
144
  */
145
+ // A home records where it was made (`.board-origin` at its root). A copy of the home carries that record elsewhere and
146
+ // is a copy: it may be read and written, but it addresses no session and no manager, which are the original's.
147
+ let copyOfHome = null;
148
+ /** The original home's path when the board opened last is a copy of it; null for a home of its own. */
149
+ export const boardCopyOf = () => copyOfHome;
150
+ export const copyRefusal = (what) => new Error(`this board is a copy of the home at ${copyOfHome}; it ${what}, which belong to the original`);
151
+ function noteOrigin(root) {
152
+ if (!existsSync(root)) return;
153
+ const here = realpathSync(root), file = join(root, '.board-origin');
154
+ let origin = null;
155
+ try { origin = JSON.parse(readFileSync(file, 'utf8')).root ?? null; } catch { /* none yet */ }
156
+ if (!origin) { writeFileSync(file, `${JSON.stringify({ root: here })}\n`); origin = here; }
157
+ copyOfHome = origin === here ? null : origin;
158
+ }
159
+
145
160
  export function openBoard(root, slug = DEFAULT_BOARD, { create = false, backing = null, document = null } = {}) {
161
+ noteOrigin(root);
146
162
  const kept = backingOf(root, slug);
147
163
  if (!kept && !create) throw new Error(`no board ${slug} in ${root} (${boardPath(root, slug)})`);
148
164
  if (kept && backing && kept !== backing) throw new Error(`board ${slug} in ${root} is kept by the ${kept} backing, not ${backing}`);
package/board/tools.mjs CHANGED
@@ -132,8 +132,11 @@ export async function boardTool(root, name, args = {}, env = {}) {
132
132
  }
133
133
  case 'kanban_request_changes': {
134
134
  const stale = ownRun(); if (stale) return error(stale);
135
+ // each finding is filed as a task on the arc first, so the arc stays open until a pushed head fixes it
136
+ const findings = [].concat(args.findings ?? []).map((f) => redact(String(f)).trim()).filter(Boolean);
137
+ const filed = findings.map((finding) => engine.createCard(board, { title: finding, subtaskOf: tid }).id);
135
138
  await engine.requestChanges(board, tid, { reason: args.reason ? redact(String(args.reason)) : null, reviewer: author });
136
- return ok({ task_id: tid });
139
+ return ok({ task_id: tid, findings: filed });
137
140
  }
138
141
  case 'kanban_heartbeat': {
139
142
  const stale = ownRun(); if (stale) return error(stale);
@@ -583,7 +583,7 @@ const FORMAT = {
583
583
  commented: ({ e }) => ({ author: String(e.author ?? 'user').trim(), len: String(e.body ?? '').length }),
584
584
  archived: () => null,
585
585
  status: ({ e, before, to }) => ({ status: to, reason: e.reason ?? 'reopened', previous_status: before.stored_as }),
586
- machine_set: ({ e, before }) => ({ machine: e.machine ?? null, was: before.machine }),
586
+ machine_set: ({ e, before }) => ({ machine: e.field === 'machine' ? e.value ?? null : e.machine ?? null, was: before.machine }),
587
587
  };
588
588
  const RETRY_KINDS = new Set(['crashed', 'timed_out', 'stale', 'rate_limited', 'session_lost', 'reclaimed', 'reconciled', 'spawn_failed']);
589
589
 
@@ -806,29 +806,15 @@ const ACTIONS = {
806
806
  const name = String(typeof slot === 'object' && slot ? slot.slot ?? 'worker' : slot ?? 'worker');
807
807
  const harness = typeof slot === 'object' && slot ? ctx.entry.profiles[slot.assignee]?.worker?.harness : null;
808
808
  if (typeof slot === 'object' && slot && !harness) return;
809
- const released = new Set();
810
809
  for (const row of all(ctx.db, 'SELECT id, metadata FROM task_runs WHERE task_id = ? AND metadata IS NOT NULL ORDER BY id DESC', ctx.id)) {
811
810
  const whole = json(row.metadata, {}) ?? {};
812
811
  const session = whole.supercode;
813
812
  if ((!session?.session_id && !session?.pane && !session?.launch_key) || (name !== '*' && (session.slot ?? 'worker') !== name) || (harness && (session.harness ?? 'claude-code') === harness)) continue;
814
813
  requestSessionClose(ctx.db, ctx.id, { runId: row.id });
815
814
  const latest = json(one(ctx.db, 'SELECT metadata FROM task_runs WHERE id=?', row.id)?.metadata, {}) ?? {};
816
- if (session.session_id) released.add(session.session_id);
817
815
  latest.supercode = { ...latest.supercode, forgotten: now(), resumable: false };
818
816
  run(ctx.db, "UPDATE task_runs SET metadata=?, ended_at=COALESCE(ended_at,?), status=CASE WHEN ended_at IS NULL THEN 'superseded' ELSE status END, outcome=CASE WHEN ended_at IS NULL THEN 'superseded' ELSE outcome END, claim_lock=NULL,claim_expires=NULL,worker_pid=NULL WHERE id=?", JSON.stringify(latest), now(), row.id);
819
817
  }
820
- const gaveUp = one(ctx.db, "SELECT created_at,payload FROM task_events WHERE task_id=? AND kind='gave_up' ORDER BY id DESC LIMIT 1", ctx.id);
821
- const failures = Number(json(gaveUp?.payload, {})?.failures ?? 0);
822
- if (!failures || !released.size) return;
823
- const recent = all(ctx.db, 'SELECT outcome,metadata FROM task_runs WHERE task_id=? AND ended_at<=? ORDER BY id DESC LIMIT ?', ctx.id, gaveUp.created_at, failures);
824
- if (recent.length !== failures || !recent.every(row => {
825
- const failure = json(row.metadata, {})?.last_events?.spawn_failed?.payload;
826
- return row.outcome === 'spawn_failed' && failure?.resume_failed === true && released.has(failure.session_id);
827
- })) return;
828
- const blocks = json(one(ctx.db, 'SELECT blocked_by FROM supercode_cards WHERE task_id=?', ctx.id)?.blocked_by, []);
829
- for (const block of blocks.filter(block => block.kind === 'message' && block.reason === 'failure limit reached' && block.since === gaveUp.created_at))
830
- ACTIONS.relation(ctx, { remove: block.id, reason: 'The manager released the session responsible for these stale continuation failures.' });
831
- run(ctx.db, 'UPDATE tasks SET consecutive_failures=0,last_failure_error=NULL WHERE id=?', ctx.id);
832
818
  },
833
819
  // an event sent to other cards (`to`: an expression naming their ids, e.g. a card's open subtasks), as `as` sends it;
834
820
  // a card it does not apply to now is passed over
@@ -1012,15 +998,21 @@ function settleRelations(db, board, entry, id, effects) {
1012
998
  if (!json(one(db, 'SELECT blocked_by FROM supercode_cards WHERE task_id = ?', id)?.blocked_by, []).some((b) => b.id === block.id)) continue;
1013
999
  const dependency = block.kind === 'card' ? one(db, 'SELECT * FROM tasks WHERE id = ?', block.id) : null;
1014
1000
  const status = dependency ? statusOf(db, dependency) : null;
1015
- const answer = block.kind === 'message' ? answerTo(block.id) : null;
1001
+ // an answer is handed to the workflow once; the block stays until a verb lifts it
1002
+ const answer = block.kind === 'message' && !block.answered ? answerTo(block.id) : null;
1016
1003
  const policy = entry.workflow.relations.blocked_by;
1017
1004
  const name = answer ? policy.answered : status === 'done' ? policy.completed : status === 'cancelled' ? policy.cancelled : null;
1005
+ if (answer) {
1006
+ const current = json(one(db, 'SELECT blocked_by FROM supercode_cards WHERE task_id = ?', id)?.blocked_by, []);
1007
+ run(db, 'UPDATE supercode_cards SET blocked_by = ? WHERE task_id = ?', JSON.stringify(current.map((b) => b.id === block.id ? { ...b, answered: answer } : b)), id);
1008
+ }
1018
1009
  if (name) effects.push(...fire(db, board, entry, id, name, { roles: ['platform'], payload: { on: block.id, reason: answer ? `reply ${answer}` : `${block.id} ${status}` } }).effects);
1019
- else if (policy.aged && policy.age_seconds != null) {
1010
+ // a block that has aged is told once, not every age interval for as long as it lasts
1011
+ else if (policy.aged && policy.age_seconds != null && !block.aged_at) {
1020
1012
  const seconds = Number(evaluate(String(policy.age_seconds), { params: entry.params }));
1021
1013
  if (seconds > 0 && now() - Number(block.notified_at ?? block.since ?? now()) >= seconds) {
1022
1014
  const current = json(one(db, 'SELECT blocked_by FROM supercode_cards WHERE task_id = ?', id)?.blocked_by, []);
1023
- run(db, 'UPDATE supercode_cards SET blocked_by = ? WHERE task_id = ?', JSON.stringify(current.map((b) => b.id === block.id ? { ...b, notified_at: now() } : b)), id);
1015
+ run(db, 'UPDATE supercode_cards SET blocked_by = ? WHERE task_id = ?', JSON.stringify(current.map((b) => b.id === block.id ? { ...b, aged_at: now() } : b)), id);
1024
1016
  effects.push(...fire(db, board, entry, id, policy.aged, { roles: ['platform'], payload: { on: block.id, reason: block.reason } }).effects);
1025
1017
  }
1026
1018
  }
@@ -5,7 +5,13 @@ import { basename, dirname } from 'node:path';
5
5
  import { Workspaces, inspectCheckout, repositoryId, teamsHome } from '@volter/supercode-teams/workspaces';
6
6
  import { localMachine, normalMachine, supercodeBin, commandEnv } from './dispatch.mjs';
7
7
  export const isWorkspacePath = value => /^(\/|~|[A-Za-z]:[\\/])/.test(value ?? '');
8
+ // The dispatcher's latest presence reading (observeFleet): a machine it reads as offline is not asked.
9
+ let presence = null;
10
+ export const notePresence = (reading) => { presence = reading; };
8
11
  function workspaceDoor(machine, verb, value, options = {}) {
12
+ if (machine && presence?.known === true && normalMachine(machine) !== localMachine() && !presence.online.has(normalMachine(machine))) {
13
+ throw new Error(`workspace on ${machine}: machine ${normalMachine(machine)} is offline`);
14
+ }
9
15
  if (!machine || normalMachine(machine) === localMachine()) {
10
16
  const map = new Workspaces(teamsHome());
11
17
  if (verb === 'inspect') return inspectCheckout(value);
package/board/ztrack.mjs CHANGED
@@ -70,8 +70,13 @@ class ZtrackBoard {
70
70
  this.seed = seed; this.root = root; this.slug = slug; this.init = init; this.compatPath = compatPath;
71
71
  this.file = `${marker}.board.db`;
72
72
  mkdirSync(dirname(this.path), { recursive: true });
73
- if (!existsSync(this.file)) this.create(`${marker}.runtime.db`);
73
+ const legacy = `${marker}.runtime.db`;
74
+ // A 0-byte file is no store: a read through the sqlite3 CLI creates one at a path that has none (on 2026-10-01 a
75
+ // `sqlite3 <store> "select …"` made the empty file the next release then opened instead of importing).
76
+ if (existsSync(this.file) && statSync(this.file).size === 0) unlinkSync(this.file);
77
+ if (!existsSync(this.file)) this.create(legacy);
74
78
  this.conn = new DatabaseSync(this.file);
79
+ this.refuseUnimported(legacy);
75
80
  this.errors = new Map(); this.statements = new Map(); this.hashes = new Map();
76
81
  this.active = false; this.overlay = false; this.seen = null;
77
82
  this.prepareConnection();
@@ -146,6 +151,24 @@ class ZtrackBoard {
146
151
  }
147
152
  }
148
153
 
154
+ // A store holding fewer runs than the earlier store beside it was made without the one-time import (on 2026-10-01
155
+ // one such store, created by an opener nobody named, sent the dispatcher on to start a second session on every
156
+ // running card). The board refuses it, so nothing runs on a board without its history.
157
+ refuseUnimported(legacy) {
158
+ if (!existsSync(legacy)) return;
159
+ const here = this.conn.prepare('SELECT COUNT(*) AS n FROM main.task_runs').get().n;
160
+ let runs = 0;
161
+ const old = new DatabaseSync(legacy, { readOnly: true });
162
+ try {
163
+ if (old.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'runtime_rows'").get()) {
164
+ runs = old.prepare("SELECT COUNT(*) AS n FROM runtime_rows WHERE name = 'task_runs' AND value IS NOT NULL").get().n;
165
+ }
166
+ } finally { old.close(); }
167
+ if (here >= runs) return;
168
+ this.conn.close();
169
+ throw new Error(`${this.file} holds ${here} runs, but ${legacy} holds ${runs}: it was created without importing them. Move it aside (keep it; writes made to it are not in the earlier store) and run any board command to import.`);
170
+ }
171
+
149
172
  // A board created from the previous runtime store imports its rows once; the old file stays as it was.
150
173
  create(legacy) {
151
174
  const tmp = `${this.file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
package/cron.mjs CHANGED
@@ -750,18 +750,24 @@ function firePrompt(job) {
750
750
  return job.prompt ?? '';
751
751
  }
752
752
 
753
+ // supercode's normal machine name (session addresses spell a machine this way)
754
+ const machineName = (name) => String(name ?? '').split('.')[0].toLowerCase().replace(/[^a-z0-9_-]/g, '-');
755
+
753
756
  function startFire(state, profile, job, now, effects) {
754
757
  const fire = claimFire(profile, job, now);
755
758
  if (!fire) return;
756
759
  effects.push({ type: 'save' }); // the claim is durable before anything runs (at-most-once)
757
- if (state.runtime?.hostedJobs) {
760
+ // A job that names another machine runs each fire in a session there: the loop launches it on that machine, and the
761
+ // launch's receipt finishes the fire as a host's would.
762
+ const remote = job.machine && state.runtime?.machine && machineName(job.machine) !== state.runtime.machine ? machineName(job.machine) : null;
763
+ if (state.runtime?.hostedJobs || remote) {
758
764
  fire.started_at = now;
759
765
  fire.status = 'running';
760
766
  fire.session_id = null; // a host callback is not a fabricated native worker session
761
767
  fire.residue = { execution: 'host_task' };
762
768
  state.runtime.hostedFires[`${profile.name}:${fire.id}`] = true;
763
769
  effects.push({ type: 'save' });
764
- effects.push({ type: 'execute_job', profile: profile.name, job_id: job.id, fire_id: fire.id });
770
+ effects.push({ type: 'execute_job', profile: profile.name, job_id: job.id, fire_id: fire.id, ...(remote ? { machine: remote } : {}) });
765
771
  return;
766
772
  }
767
773
  // A gate (a script, a monitor source) runs before any model, off the event
package/loop.mjs CHANGED
@@ -23,6 +23,9 @@ import { performRemoveProfileDir } from './operator.mjs';
23
23
  import { harnessHomes } from './mcp/verbs.mjs';
24
24
  import { checkMonitor, runJobScript } from './gates.mjs';
25
25
  import { startBoard } from './board.mjs';
26
+ import { localMachine } from './board/dispatch.mjs';
27
+ import { execFile } from 'node:child_process';
28
+ import { promisify } from 'node:util';
26
29
 
27
30
  /**
28
31
  * Where a worker keeps its transcripts inside its config home (`<profile>/<harness>`), as discovery and
@@ -54,6 +57,7 @@ export class Orchestrator {
54
57
  this.hostedTasks = new Set();
55
58
  state.runtime ??= {};
56
59
  state.runtime.hostedJobs = !!executeJob;
60
+ state.runtime.machine = localMachine(); // a job naming another machine fires there (cron.mjs startFire)
57
61
  state.runtime.hostedFires = {};
58
62
  this.vault = vault;
59
63
  this.client = client;
@@ -263,12 +267,12 @@ export class Orchestrator {
263
267
  }
264
268
  }
265
269
 
266
- executeHostedJob({ profile: name, job_id, fire_id }) {
270
+ executeHostedJob({ profile: name, job_id, fire_id, machine = null }) {
267
271
  const profile = this.state.profiles[name];
268
272
  const job = structuredClone(profile.jobs[job_id]);
269
273
  const fire = structuredClone(profile.fires.find((f) => f.id === fire_id));
270
274
  // Host work never blocks the state queue; completion returns as an event.
271
- const task = Promise.resolve().then(() => this.executeJob({ profile: name, job, fire }))
275
+ const task = Promise.resolve().then(() => machine ? this.launchRemoteFire({ profile, job, fire, machine }) : this.executeJob({ profile: name, job, fire }))
272
276
  .then((receipt) => {
273
277
  if (!receipt || typeof receipt.output !== 'string' || receipt.accepted !== true)
274
278
  throw new Error('The host did not confirm this scheduled task');
@@ -279,6 +283,23 @@ export class Orchestrator {
279
283
  this.hostedTasks.add(task);
280
284
  }
281
285
 
286
+ /**
287
+ * A fire of a job pinned to another machine: a session of the profile's worker harness on that machine, in a window
288
+ * there, given the job's prompt (`supercode open --new --on`, keyed by the fire so a retry never opens a second).
289
+ * The launch is the fire's receipt; the session does the job's work and reports through its own doors.
290
+ */
291
+ async launchRemoteFire({ profile, job, fire, machine }) {
292
+ const harness = profile.worker?.harness;
293
+ const program = { 'claude-code': 'claude', codex: 'codex' }[harness];
294
+ if (!program) throw new Error(`profile ${profile.name}'s worker (${harness ?? 'none'}) cannot run a fire on ${machine}`);
295
+ const args = ['open', '--new', program, '--detach', '--window=true', '--on', machine, '--key', `cron-${fire.id}`, '--input', job.prompt ?? ''];
296
+ if (job.workdir && job.workdir.startsWith('/')) args.push('--cwd', job.workdir);
297
+ if (job.model) args.push('--', '--model', job.model);
298
+ const { stdout } = await promisify(execFile)(process.env.SUPERCODE_BIN || 'supercode', args, { timeout: 180_000, maxBuffer: 4 * 1024 * 1024 });
299
+ const pane = /"pane"\s*:\s*"([^"]+)"/.exec(stdout)?.[1] ?? null;
300
+ return { accepted: true, output: `Fire ${fire.id} of ${job.id} launched on ${machine}${pane ? ` in pane ${pane}` : ''}.`, machine, pane };
301
+ }
302
+
282
303
  /**
283
304
  * A fire's gate, off the queue (a script may run for Hermes's hour): the
284
305
  * monitor source first, then the pre-run script unless the monitor already
@@ -226,6 +226,13 @@
226
226
  "board": {
227
227
  "type": "string",
228
228
  "description": "Kanban board slug to target. When omitted, the call resolves the active board the usual way: HERMES_KANBAN_DB env \u2192 HERMES_KANBAN_BOARD env \u2192 the 'current' symlink under the kanban home \u2192 'default'. Pass an explicit slug only when the caller (e.g. a Telegram routing layer) needs to override the env-pinned active board for this one call."
229
+ },
230
+ "findings": {
231
+ "type": "array",
232
+ "items": {
233
+ "type": "string"
234
+ },
235
+ "description": "Each finding, filed as a task on the arc before the card returns; an arc with no open task is refused."
229
236
  }
230
237
  },
231
238
  "required": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-orchestrator",
3
- "version": "0.4.149",
3
+ "version": "0.4.151",
4
4
  "type": "module",
5
5
  "description": "The orchestrator runtime over the Volter Harness ontology: one typed operational model whose folder is its serialization, read and written through the harness orchestration doors (docs/ORCHESTRATOR-IR.md)",
6
6
  "exports": {