@volter/supercode-orchestrator 0.4.148 → 0.4.149

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
@@ -1,14 +1,19 @@
1
1
  // Review candidates are observed before a board mutation. No network or Git
2
2
  // process runs inside the optimistic transaction. Deployment is not a completion gate.
3
3
  import { spawnSync } from 'node:child_process';
4
- import { readFileSync, realpathSync, writeFileSync, renameSync } from 'node:fs';
4
+ import { readFileSync, realpathSync, writeFileSync, renameSync, statSync } from 'node:fs';
5
5
  import { dirname, join, relative } from 'node:path';
6
6
  import { createHash } from 'node:crypto';
7
- import { resolveWorkspace } from './workspace.mjs';
7
+ import { branchCheckouts, checkoutOf, resolveWorkspace } from './workspace.mjs';
8
8
 
9
9
  const observations = new Map();
10
10
  const key = (board, id) => `${board.root}\0${board.slug}\0${id}`;
11
11
  const run = (program, args, cwd) => {
12
+ if (cwd) {
13
+ let directory = false;
14
+ try { directory = statSync(cwd).isDirectory(); } catch { /* reported below */ }
15
+ if (!directory) throw new Error(`Repository checkout ${cwd} is unavailable; resolve the card's declared workspace before verifying its source.`);
16
+ }
12
17
  const result = spawnSync(program, args, { cwd, encoding: 'utf8', timeout: 30_000, maxBuffer: 2 * 1024 * 1024 });
13
18
  if (result.status !== 0) throw new Error(`${program}: ${(result.stderr || result.error?.message || 'command failed').trim().slice(-500)}`);
14
19
  return result.stdout.trim();
@@ -41,13 +46,17 @@ export function withCompletionEvidence(board, id, state, fn) {
41
46
  }
42
47
  }
43
48
 
44
- /** The latest explicitly requested PR is the reviewer's candidate, never the deployment tip. */
49
+ /** The latest explicitly requested PRs are the reviewer's candidate, never the deployment tip. */
45
50
  export function reviewCandidate(board, id) {
46
51
  const row = board.db.prepare("SELECT payload FROM task_events WHERE task_id=? AND kind='review_requested' ORDER BY id DESC LIMIT 1").get(id);
47
52
  const payload = row && JSON.parse(row.payload);
48
53
  return payload?.ci?.pr ? payload.ci : null;
49
54
  }
50
55
 
56
+ /** The PRs an evidence record covers: every PR of one arc review (the first is also its top-level PR). */
57
+ export const candidatePullRequests = (ci) => ci?.prs ?? (ci?.pr ? [{ repository: ci.repository, branch: ci.branch, commit: ci.commit, source_sha: ci.source_sha ?? ci.commit, pr: ci.pr }] : []);
58
+ const originOf = (path) => /(?:github\.com[:/])([^/]+\/[^/]+?)(?:\.git)?$/.exec(run('git', ['remote', 'get-url', 'origin'], path))?.[1] ?? null;
59
+
51
60
  function repositoryWorkspace(board, id) {
52
61
  const task = board.db.prepare('SELECT t.*,c.subtask_of,c.workspace_anchor,c.machine FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.id=?').get(id);
53
62
  const parent = task?.subtask_of && board.db.prepare('SELECT t.*,c.workspace_anchor,c.machine FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.id=?').get(task.subtask_of);
@@ -57,90 +66,154 @@ function repositoryWorkspace(board, id) {
57
66
 
58
67
  const pullRequestLists = new Map();
59
68
 
60
- /** Find an opened PR for this card's declared worktree branch; caller decides the transition. */
61
- export function openReviewPullRequest(board, id) {
62
- const path = repositoryWorkspace(board, id);
63
- if (!path) return null;
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; }
72
+ }
73
+
74
+ /** 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. */
77
+ export function openReviewPullRequests(board, id) {
78
+ const own = repositoryWorkspace(board, id);
79
+ if (!own) return null;
64
80
  const task = board.db.prepare('SELECT branch_name FROM tasks WHERE id=?').get(id);
65
- const branch = task?.branch_name ?? run('git', ['branch', '--show-current'], path);
66
- if (!branch) return null;
67
- const repository = /(?:github\.com[:/])([^/]+\/[^/]+?)(?:\.git)?$/.exec(run('git', ['remote', 'get-url', 'origin'], path))?.[1];
68
- if (!repository) return null;
69
- // Discovery is shared per repository for one minute; the selected PR/head is
70
- // always re-read by inspectCompletionEvidence before it authorizes a move.
71
- let list = pullRequestLists.get(repository);
72
- if (!list || Date.now() - list.at >= 60_000) {
73
- list = { at: Date.now(), items: JSON.parse(run('gh', ['pr', 'list', '--repo', repository, '--state', 'open', '--limit', '1000', '--json', 'url,headRefOid,headRefName,baseRefName,isCrossRepository'], path)) };
74
- if (pullRequestLists.size >= 32) pullRequestLists.delete(pullRequestLists.keys().next().value);
75
- pullRequestLists.set(repository, list);
81
+ const branch = task?.branch_name ?? run('git', ['branch', '--show-current'], own);
82
+ const ownRepository = originOf(own);
83
+ 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}/`));
85
+ const prs = [];
86
+ for (const repository of new Set([ownRepository, ...knownRepositories()])) {
87
+ // Discovery is shared per repository for one minute; each selected PR/head is re-read before it authorizes a move.
88
+ let list = pullRequestLists.get(repository);
89
+ if (!list || Date.now() - list.at >= 60_000) {
90
+ 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
93
+ list = { at: Date.now(), items };
94
+ if (pullRequestLists.size >= 64) pullRequestLists.delete(pullRequestLists.keys().next().value);
95
+ pullRequestLists.set(repository, list);
96
+ }
97
+ for (const pr of list.items) if (!pr.isCrossRepository && mine(pr.headRefName)) prs.push({ url: pr.url, head: pr.headRefOid });
76
98
  }
77
- const prs = list.items.filter(pr => pr.headRefName === branch && !pr.isCrossRepository);
78
- if (prs.length !== 1) return null;
79
- return { pr: prs[0].url, pr_head: prs[0].headRefOid, commit: prs[0].headRefOid, branch };
99
+ if (!prs.length) return null;
100
+ return { pr: prs.map((p) => p.url), pr_head: prs.map((p) => p.head), branch };
101
+ }
102
+
103
+ /** The repositories this machine keeps a checkout of. */
104
+ function knownRepositories() {
105
+ try { return [...new Set(branchCheckouts(null, '*').map((row) => row.repository))]; } catch { return []; }
106
+ }
107
+
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. */
111
+ 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'
114
+ 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
+ ORDER BY r.id DESC LIMIT 1`).get(id);
116
+ if (!last) return unjudged;
117
+ 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));
119
+ }
120
+
121
+ /** The verdict already recorded on `head` of the card's PR: a changes-requested or pass that answered
122
+ * a review request naming that head, before the next request. A head is reviewed once. */
123
+ export function judgedHead(board, id, head) {
124
+ if (!head) return null;
125
+ return board.db.prepare(`SELECT v.id, v.kind FROM task_events r JOIN task_events v ON v.task_id = r.task_id
126
+ AND v.kind IN ('changes_requested', 'review_pass') AND v.id > r.id
127
+ AND v.id < COALESCE((SELECT MIN(n.id) FROM task_events n WHERE n.task_id = r.task_id AND n.kind = 'review_requested' AND n.id > r.id), 9223372036854775807)
128
+ WHERE r.task_id = ? AND r.kind = 'review_requested' AND (json_extract(r.payload, '$.ci.commit') = ?
129
+ OR EXISTS (SELECT 1 FROM json_each(r.payload, '$.ci.prs') p WHERE json_extract(p.value, '$.commit') = ?)) ORDER BY v.id LIMIT 1`).get(id, head, head) ?? null;
80
130
  }
81
131
 
82
132
  export function inspectCompletionEvidence(board, id, entry, payload = {}, stage = 'completion') {
83
133
  const state = { required: false, ready: true, reason: null, phase: 'source', deployed_sha: null };
84
134
  try {
85
- const path = repositoryWorkspace(board, id);
86
135
  if (stage === 'completion' || stage === 'acceptance') {
87
136
  const supplied = payload.commit ?? payload.metadata?.commit;
88
137
  if (supplied) {
138
+ const path = repositoryWorkspace(board, id);
89
139
  if (!path) throw new Error('The named commit needs a resolved repository workspace.');
90
- const repository = /(?:github\.com[:/])([^/]+\/[^/]+?)(?:\.git)?$/.exec(run('git', ['remote', 'get-url', 'origin'], path))?.[1];
91
- Object.assign(state, { repository, commit: run('git', ['rev-parse', '--verify', `${supplied}^{commit}`], path) });
140
+ Object.assign(state, { repository: originOf(path), commit: run('git', ['rev-parse', '--verify', `${supplied}^{commit}`], path) });
92
141
  }
93
142
  const candidate = stage === 'acceptance' ? reviewCandidate(board, id) : null;
94
143
  if (candidate) {
95
144
  const row = board.db.prepare("SELECT payload FROM task_events WHERE task_id=? AND kind='review_pass' ORDER BY id DESC LIMIT 1").get(id);
96
- const pass = row && JSON.parse(row.payload)?.ci;
97
- if (pass?.commit !== candidate.commit || pass?.pr?.url !== candidate.pr.url) throw new Error('The current PR head needs its independent verdict before acceptance.');
98
- const pr = JSON.parse(run('gh', ['pr', 'view', candidate.pr.url, '--repo', candidate.repository, '--json', 'state,headRefOid'], path));
99
- if (!['OPEN', 'MERGED'].includes(pr.state) || pr.headRefOid !== candidate.commit) throw new Error('The PR head changed after its verdict; request a new review.');
145
+ const passed = candidatePullRequests(row && JSON.parse(row.payload)?.ci);
146
+ const wanted = candidatePullRequests(candidate);
147
+ 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
+ for (const w of wanted) {
149
+ const pr = JSON.parse(run('gh', ['pr', 'view', w.pr.url, '--repo', w.repository, '--json', 'state,headRefOid']));
150
+ 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
+ }
100
152
  Object.assign(state, { ...candidate, ready: true, reason: null });
101
153
  }
102
154
  return state;
103
155
  }
156
+ const own = repositoryWorkspace(board, id);
104
157
  const asked = stage === 'review_verdict' ? reviewCandidate(board, id) : null;
105
- const pr = payload.pr ?? payload.metadata?.pr ?? asked?.pr?.url;
106
- if (!path) {
107
- if (pr) throw new Error('A PR candidate needs a resolved repository workspace.');
158
+ const urls = [].concat(payload.pr ?? payload.metadata?.pr ?? candidatePullRequests(asked).map((c) => c.pr.url)).filter(Boolean);
159
+ if (!own) {
160
+ if (urls.length) throw new Error('A PR candidate needs a resolved repository workspace.');
108
161
  return state;
109
162
  }
110
- // Repository-free review-only jobs keep their own verdict contract. Ordinary
111
- // implementation reviews always name an opened PR, independent of completion_ci.
112
- if (stage === 'review_verdict' && !asked && !pr) return state;
163
+ // A verdict with no requested PR (a scratch arc) keeps its own contract. An arc's review
164
+ // always names every opened PR it changed, independent of completion_ci.
165
+ if (stage === 'review_verdict' && !asked && !urls.length) return state;
113
166
  Object.assign(state, { required: true, ready: false });
114
- if (typeof pr !== 'string' || !/^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/[1-9][0-9]*\/?$/.test(pr)) throw new Error('Name the opened PR URL with --pr.');
115
- const repository = /(?:github\.com[:/])([^/]+\/[^/]+?)(?:\.git)?$/.exec(run('git', ['remote', 'get-url', 'origin'], path))?.[1];
116
- if (!repository || new URL(pr).pathname.split('/').slice(1, 3).join('/') !== repository) throw new Error('The PR must belong to this workspace repository.');
117
- const observed = JSON.parse(run('gh', ['pr', 'view', pr, '--repo', repository, '--json', 'url,number,state,headRefOid,headRefName,baseRefName,isCrossRepository'], path));
118
- if (observed.state !== 'OPEN' || observed.isCrossRepository) throw new Error('Source review needs an open PR from this repository.');
119
- const supplied = payload.pr_head ?? payload.reviewed_sha ?? payload.commit ?? payload.metadata?.pr_head ?? payload.metadata?.commit;
120
- if (!supplied || !/^[a-f0-9]{40}$/.test(supplied)) throw new Error('Name the full PR head commit with --pr-head (reviewer: --reviewed-sha).');
121
- if (observed.headRefOid !== supplied) throw new Error(`PR head changed to ${observed.headRefOid}; request review of that head before recording a verdict.`);
122
- const branch = payload.branch ?? payload.metadata?.branch ?? observed.headRefName;
123
- const mainBranch = entry.params.completion_ci?.main_branches?.[repository] ?? 'main';
124
- if (branch !== observed.headRefName || branch === mainBranch || observed.baseRefName !== mainBranch) throw new Error('The PR must propose its named worktree branch to the repository main branch.');
167
+ if (!urls.length) throw new Error('Name each opened PR with --pr.');
168
+ const named = [].concat(stage === 'review_verdict'
169
+ ? payload.reviewed_sha ?? payload.pr_head ?? payload.commit ?? []
170
+ : payload.pr_head ?? payload.commit ?? payload.metadata?.pr_head ?? payload.metadata?.commit ?? []).filter(Boolean);
171
+ if (stage === 'request_review' && named.length !== urls.length) throw new Error('Name each PR head with --pr-head, one for each --pr, in the same order.');
172
+ if (stage === 'review_verdict' && !named.length) throw new Error('Name every reviewed PR head with --reviewed-sha.');
173
+ const prs = urls.map((url, index) => verifyPullRequest(board, id, entry, own, url, stage === 'request_review' ? named[index] : null, stage));
125
174
  if (stage === 'request_review') {
126
- run('git', ['check-ref-format', '--branch', branch], path);
127
- if (run('git', ['rev-parse', 'HEAD'], path) !== supplied) throw new Error('The named PR head must be this worktree head.');
128
- run('git', ['diff', '--quiet', 'HEAD'], path);
129
- if (run('git', ['ls-files', '--others', '--exclude-standard'], path)) throw new Error('Commit the untracked work before requesting review.');
130
- const pushed = run('git', ['ls-remote', '--heads', 'origin', `refs/heads/${branch}`], path).split(/\s+/)[0];
131
- if (pushed !== supplied) throw new Error('Push the exact PR head before requesting review.');
175
+ // A review is asked for only while some PR has a head no verdict has judged.
176
+ if (!reviewDue(board, id, prs.map((p) => ({ url: p.pr.url, head: p.commit })))) throw new Error('The PRs the last verdict judged have no new head; push a new head before requesting review.');
132
177
  } else if (asked) {
133
- if (asked.commit !== supplied || asked.pr.url !== observed.url) throw new Error('The verdict must name the requested PR and head.');
178
+ const requested = candidatePullRequests(asked);
179
+ if (requested.length !== prs.length || requested.some((r) => !prs.some((p) => p.pr.url === r.pr.url && p.commit === r.commit))) throw new Error('The verdict covers the requested PRs at their requested heads; a PR whose head moved needs a new review request.');
180
+ if (requested.some((r) => !named.includes(r.commit))) throw new Error('Name every reviewed PR head with --reviewed-sha.');
134
181
  const latest = board.db.prepare('SELECT metadata FROM task_runs WHERE task_id=? ORDER BY id DESC LIMIT 1').get(id);
135
- const bound = latest && JSON.parse(latest.metadata ?? '{}')?.supercode?.review_candidate;
136
- if (bound && (bound.commit !== supplied || bound.pr?.url !== observed.url)) throw new Error('This reviewer run was launched for a different PR head.');
182
+ const bound = candidatePullRequests(latest && JSON.parse(latest.metadata ?? '{}')?.supercode?.review_candidate);
183
+ if (bound.length && (bound.length !== requested.length || bound.some((b) => !requested.some((r) => r.pr.url === b.pr.url && r.commit === b.commit)))) throw new Error('This reviewer run was launched for different PR heads.');
137
184
  }
138
- Object.assign(state, { ready: true, repository, branch, commit: supplied, source_sha: supplied,
139
- pr: { url: observed.url, number: observed.number, head: supplied, branch, base: observed.baseRefName }, reason: null });
185
+ Object.assign(state, { ...prs[0], ready: true, reason: null, ...(prs.length > 1 ? { prs } : {}) });
140
186
  } catch (error) { Object.assign(state, { required: true, ready: false, reason: error.message }); }
141
187
  return state;
142
188
  }
143
189
 
190
+ /** One PR of an arc review, read from GitHub and, for a review request, from the card branch's checkout. */
191
+ function verifyPullRequest(board, id, entry, own, url, supplied, stage) {
192
+ 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
+ const repository = new URL(url).pathname.split('/').slice(1, 3).join('/');
194
+ // 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']));
199
+ if (observed.state !== 'OPEN' || observed.isCrossRepository) throw new Error(`Source review needs an open PR from its repository (${url}).`);
200
+ const head = supplied ?? observed.headRefOid;
201
+ if (!/^[a-f0-9]{40}$/.test(head)) throw new Error('Name the full PR head commit with --pr-head (reviewer: --reviewed-sha).');
202
+ if (observed.headRefOid !== head) throw new Error(`The head of ${url} changed to ${observed.headRefOid}; request review of that head before recording a verdict.`);
203
+ const branch = observed.headRefName;
204
+ const mainBranch = entry.params.completion_ci?.main_branches?.[repository] ?? 'main';
205
+ if (branch === mainBranch || observed.baseRefName !== mainBranch) throw new Error(`${url} must propose its worktree branch to the repository main branch.`);
206
+ if (stage === 'request_review' && path) {
207
+ run('git', ['check-ref-format', '--branch', branch], path);
208
+ 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.`);
211
+ const pushed = run('git', ['ls-remote', '--heads', 'origin', `refs/heads/${branch}`], path).split(/\s+/)[0];
212
+ if (pushed !== head) throw new Error(`Push the exact head of ${url} before requesting review.`);
213
+ }
214
+ return { repository, branch, commit: head, source_sha: head, pr: { url: observed.url, number: observed.number, head, branch, base: observed.baseRefName } };
215
+ }
216
+
144
217
  const digest = bytes => createHash('sha256').update(bytes).digest('hex');
145
218
  /** Read the tracked workflow revision the next serve tick will load. */
146
219
  export function workflowRevision(root) {
package/board/cli.mjs CHANGED
@@ -36,7 +36,7 @@ Hermes Kanban's verbs, with their flags:
36
36
  block <id> [reason] (--on <card> | --waiting-on <message id>) [--ids ...] schedule <id> [reason] [--ids ...]
37
37
  pause <id> --reason <why> resume <id> cancel <id> set-every <id> <interval|none>
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>... [--reason] request-review <id> (--pr URL --pr-head SHA)... [--summary] [--reviewer] [--force] request-changes <id> <reason> 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]
@@ -48,7 +48,7 @@ Hermes Kanban's verbs, with their flags:
48
48
  supercode's own:
49
49
  init --backing sqlite|files · boards create <slug> --backing sqlite|files
50
50
  Where the board is kept: sqlite is Hermes's kanban.db (the default); files is one JSON file per card.
51
- create … --machine M --subtask-of ARC --notify ADDRESS|--no-notify --no-start [--review-only]
51
+ create … --machine M --subtask-of ARC --notify ADDRESS|--no-notify --no-start
52
52
  With a pane lane and room, a card starts at once and answers "started" with its session; else it answers why it
53
53
  waits, and its creator is told when it starts. A subtask is worked by its arc's session and never starts alone.
54
54
  move <id> <machine|none> adopt <id> <session> [--assignee P] [--role ROLE] close <id> serve (= daemon) list --machine M
@@ -61,10 +61,9 @@ supercode's own:
61
61
  create ... --adopt <session> hold dispatch until that live session is attached
62
62
  goto <id> <status> [reason] [--<field> value]: the event the workflow says takes the card to that status, sent.
63
63
 
64
- specify <id> --review-only|--no-review-only: a manager declares a standalone independent review job.
65
64
  specify <id> --workspace dir:<repository> --branch NAME: the manager retargets a stopped card without replacing its history.
66
- request-review <id> --pr URL --pr-head SHA [--branch NAME]: hand over an open PR at its pushed head.
67
- request-state <id> --state done --reviewed-sha SHA: record the verdict for the requested PR head.
65
+ request-review <id> (--pr URL --pr-head SHA)...: hand over every open PR the arc changed, each at its pushed head; one review covers them all.
66
+ request-state <id> --state done (--reviewed-sha SHA)...: record the verdict on every requested PR head.
68
67
  request-state <id>... --state STATE --reason REASON: request several decisions in one retained manager mail.
69
68
  complete <id>: creator/manager accepts the independently reviewed arc; deployment CI is not a board gate.
70
69
  specify/decompose with no spec start a session on the triage card to write it. A card's own session may comment on,
@@ -72,9 +71,9 @@ supercode's own:
72
71
  workflow declares who may block, pause or finish a card; request-state mails its manager without changing state.
73
72
  `;
74
73
 
75
- const MULTI = new Set(['parent', 'skill', 'worker', 'child', 'ids', 'rm']);
74
+ const MULTI = new Set(['parent', 'skill', 'worker', 'child', 'ids', 'rm', 'pr', 'pr-head', 'reviewed-sha']);
76
75
  const FLAGS = new Set(['json', 'triage', 'no-notify', 'no-start', 'all', 'force', 'help', 'mine', 'archived', 'reclaim', 'dry-run',
77
- 'goal', 'no-goal', 'review-only', 'no-review-only', 'off', 'switch', 'delete', 'no-attachments', 'include-logs', 'verbose']);
76
+ 'goal', 'no-goal', 'off', 'switch', 'delete', 'no-attachments', 'include-logs', 'verbose']);
78
77
  // flags that take every following word up to the next flag (Hermes's nargs='+')
79
78
  const GREEDY = new Set(['ids', 'rm']);
80
79
 
@@ -180,11 +179,11 @@ async function laneState(root) {
180
179
  }
181
180
 
182
181
  /** The caller's own supercode address, when it is a session supercode can reach (for its card's notices). */
182
+ // The calling session's own address, as its actor is named: no fleet scan (a `message list` costs seconds).
183
183
  function callerAddress() {
184
184
  const id = callerSession();
185
185
  if (!id) return null;
186
- const fleet = observeFleet();
187
- return [...fleet.alive].find((a) => a.endsWith(`:${id}`)) ?? null;
186
+ return `sc:${localMachine()}:${id === process.env.CODEX_THREAD_ID ? 'codex' : 'claude-code'}:${id}`;
188
187
  }
189
188
 
190
189
  const callerName = () => process.env.HERMES_PROFILE || process.env.USER || 'user';
@@ -207,7 +206,7 @@ const cardDict = (t) => ({
207
206
  created_by: t.created_by, created_at: t.created_at, started_at: t.started_at, completed_at: t.completed_at, result: t.result,
208
207
  skills: t.skills ?? [], max_retries: t.max_retries, model_override: t.model_override, provider_override: t.provider_override,
209
208
  session_id: t.session_id, workflow_template_id: t.workflow_template_id, current_step_key: t.current_step_key,
210
- machine: t.machine ?? null, subtask_of: t.subtask_of ?? null, review_only: Boolean(t.review_only),
209
+ machine: t.machine ?? null, subtask_of: t.subtask_of ?? null,
211
210
  });
212
211
 
213
212
  /** Hermes's durations: seconds, or 30s / 5m / 2h / 1d. */
@@ -239,7 +238,6 @@ const metadataOf = (v) => {
239
238
  export async function runWorkflow(argv) {
240
239
  const args = parse(argv);
241
240
  const [verb, ...rest] = args._;
242
- if (args['review-only'] && args['no-review-only']) throw new Error('Choose review-only or no-review-only, not both.');
243
241
  if (!verb || verb === 'help' || args.help) { process.stdout.write(HELP); return 0; }
244
242
  const root = homeOf(args);
245
243
  const slug = boardOf(root, args);
@@ -282,7 +280,7 @@ export async function runWorkflow(argv) {
282
280
  const migrated = openZtrackBoard(preparing, { create: true, document: args.document, seed: { db: source.db, mapping }, init: layOut, root, slug, compatPath: paths.sqlite });
283
281
  migrated.close();
284
282
  } finally { source.close(); }
285
- renameSync(`${preparing}.runtime.db`, `${paths.ztrack}.runtime.db`);
283
+ renameSync(`${preparing}.board.db`, `${paths.ztrack}.board.db`);
286
284
  renameSync(preparing, paths.ztrack);
287
285
  say(args, `Board migrated to ${args.document}; kanban.db is now a Hermes export`, { ok: true, backing: 'ztrack', document: args.document });
288
286
  return 0;
@@ -298,7 +296,6 @@ export async function runWorkflow(argv) {
298
296
 
299
297
  case 'create': {
300
298
  need(1, 'a title');
301
- const { lanes, settings } = await laneState(root);
302
299
  const maxRetries = args['max-retries'] !== undefined ? Number(args['max-retries']) : null;
303
300
  if (maxRetries !== null && !(maxRetries >= 1)) throw new Error(`--max-retries must be >= 1 (got ${args['max-retries']}); use 1 to trip on the first failure`);
304
301
  const meta = boardMeta(root, slug);
@@ -310,22 +307,19 @@ export async function runWorkflow(argv) {
310
307
  workspace, branch: args.branch, parents: args.parent, priority: args.priority, tenant: args.tenant,
311
308
  triage: args.triage, idempotencyKey: args['idempotency-key'], maxRuntime: duration(args['max-runtime']),
312
309
  maxRetries, skills: args.skill, model: args.model, provider: args.provider, createdBy: args['created-by'] ?? callerName(),
313
- noStart: Boolean(args['no-start']), adopt: args.adopt ?? null, reviewOnly: Boolean(args['review-only']),
310
+ noStart: Boolean(args['no-start']), adopt: args.adopt ?? null,
314
311
  every: args.every ?? null, sessionId: callerSession(), subtaskOf: args['subtask-of'] ?? null, initialStatus: args['initial-status'] ?? null,
315
312
  project: args.project ?? meta.project_id ?? null, goal: args.goal ? { maxTurns: args['goal-max-turns'] ? Number(args['goal-max-turns']) : null } : args['no-goal'] ? false : null,
316
313
  }), true);
317
314
  let answer = { id: created.id, existing: created.existing };
318
315
  if (args.adopt) {
319
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);
320
318
  const adopted = withBoard(b => adoptSession(b, created.id, args.adopt, { ttl: settings.ttl }));
321
319
  answer = { ...answer, started: true, session: adopted.address, mode: adopted.mode };
322
- } else if (!args['no-start'] && !created.existing && !args['subtask-of']) {
323
- const [report] = await tick({ root, lanes, settings, only: created.id, slugs: [slug] });
324
- const started = report.started.find(([t]) => t === created.id);
325
- const queued = report.queued.find(([t]) => t === created.id);
326
- const failed = report.failed.find(([t]) => t === created.id);
327
- answer = { ...answer, ...(started ? { started: true, session: started[1], mode: started[2] } : {}), ...(queued ? { started: false, waits: queued[1] } : {}), ...(failed ? { started: false, failed: failed[1] } : {}) };
328
320
  }
321
+ // Starting a card is the dispatcher's: this write's wake hint reaches it, and the card's
322
+ // thread records the start. A create never runs a dispatch tick inline.
329
323
  // the creator hears what happens from here on; a start answered above is not told twice
330
324
  const address = args['no-notify'] ? null : args.notify ?? callerAddress();
331
325
  if (address) withBoard((b) => engine.subscribe(b, created.id, address, 'creator'));
@@ -552,7 +546,7 @@ export async function runWorkflow(argv) {
552
546
  case 'complete': {
553
547
  need(1, 'a card id');
554
548
  const metadata = metadataOf(args.metadata);
555
- for (const t of rest) await withBoardAsync((b) => engine.complete(b, t, { summary: args.summary ?? null, result: args.result ?? null, metadata: 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'] ?? null, reviewed_sha: args['reviewed-sha'] ?? null }));
549
+ for (const t of rest) await withBoardAsync((b) => engine.complete(b, t, { summary: args.summary ?? null, result: args.result ?? null, metadata: metadata ?? null, commit: args.commit ?? null, ci_run: args['ci-run'] ?? null, pr: args.pr ?? null, pr_head: args['pr-head'] ?? null, reviewed_sha: args['reviewed-sha'] ?? null }));
556
550
  say(args, rest.map((t) => `Completed ${t}`).join('\n'), { ok: true, completed: rest }); return 0;
557
551
  }
558
552
  case 'edit': {
@@ -637,8 +631,8 @@ export async function runWorkflow(argv) {
637
631
  if (!targets[0]) throw new Error(`${verb} needs a card id, or --all`);
638
632
  const results = [];
639
633
  for (const t of targets) {
640
- if (verb === 'specify' && (args.title !== undefined || args.body !== undefined || args.workspace !== undefined || args.branch !== undefined || args['review-only'] || args['no-review-only'])) {
641
- const moved = await withBoardAsync((b) => engine.specify(b, t, { title: args.title ?? null, body: args.body ?? null, workspace: args.workspace ?? null, branch: args.branch ?? null, reviewOnly: args['review-only'] ? true : args['no-review-only'] ? false : null, author: args.author ?? callerAddress() ?? callerName() }));
634
+ if (verb === 'specify' && (args.title !== undefined || args.body !== undefined || args.workspace !== undefined || args.branch !== undefined)) {
635
+ const moved = await withBoardAsync((b) => engine.specify(b, t, { title: args.title ?? null, body: args.body ?? null, workspace: args.workspace ?? null, branch: args.branch ?? null, author: args.author ?? callerAddress() ?? callerName() }));
642
636
  results.push({ id: t, specified: true, status: moved.to });
643
637
  } else if (verb === 'decompose' && args.child?.length) {
644
638
  const children = args.child.map((c) => { const [title, assignee] = c.split(/@(?=[^@]*$)/); return { title: title.trim(), assignee: assignee?.trim() || undefined }; });
@@ -1223,6 +1217,10 @@ async function serve(root, args = {}) {
1223
1217
  await new Promise(resolve => setTimeout(resolve, 250));
1224
1218
  continue;
1225
1219
  }
1220
+ // A serve never ticks on code older than the installed orchestrator, also right after an
1221
+ // installation window: its keeper starts the new one, whose board store this one does not keep.
1222
+ const replaced = (() => { const now = installed(); return running && now && now !== running ? now : null; })();
1223
+ if (replaced) { log(`orchestrator ${replaced} is installed (this serve runs ${running}); stopping, for its keeper to start the new one`); break; }
1226
1224
  pausedSaid = Boolean(paused(root)) && pausedSaid;
1227
1225
  let interval = 60;
1228
1226
  let nextMailAt = Infinity;
@@ -15,7 +15,7 @@
15
15
  // Every side effect follows a committed transition: a card is claimed before its session is launched, and a
16
16
  // subscriber's cursor moves only after its notice was delivered, so two dispatchers over one board never both
17
17
  // launch and a crash between the two steps is answered by the next tick.
18
- import { inspectCompletionEvidence, openReviewPullRequest, withCompletionEvidence, reviewCandidate, workflowRevision, retainWorkflowTick } from './ci.mjs';
18
+ import { inspectCompletionEvidence, openReviewPullRequests, reviewDue, withCompletionEvidence, reviewCandidate, workflowRevision, retainWorkflowTick } from './ci.mjs';
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';
@@ -333,13 +333,13 @@ function supervise(board, fleet, settings, report, entry, effects) {
333
333
  return;
334
334
  }
335
335
  // a run past its runtime limit (the status's `run.limits.runtime`), as Hermes times its workers out
336
- if (limits.runtime && runRow && session?.wait_started_at == null && runWorkingSeconds(runRow, t) > Number(limits.runtime)) {
336
+ if (limits.runtime && runRow && runWorkingSeconds(runRow, t) > Number(limits.runtime)) {
337
337
  observe('timed_out', { limit_seconds: limits.runtime, error: `ran past max_runtime ${limits.runtime}s` }, 'timed_out');
338
338
  return;
339
339
  }
340
340
  if (session?.surface === 'headless') {
341
341
  // Hermes's stale check: a worker running past the limit with no heartbeat in the last hour is stopped
342
- if (limits.stale && runRow && session?.wait_started_at == null && runWorkingSeconds(runRow, t) >= Number(limits.stale) && !(task.last_heartbeat_at && t - Number(task.last_heartbeat_at) < 3600)) {
342
+ if (limits.stale && runRow && runWorkingSeconds(runRow, t) >= Number(limits.stale) && !(task.last_heartbeat_at && t - Number(task.last_heartbeat_at) < 3600)) {
343
343
  observe('stale', { timeout_seconds: limits.stale, pid: session.pid, error: `no heartbeat after ${t - Number(runRow.started_at)}s running` }, 'reclaimed');
344
344
  return;
345
345
  }
@@ -690,7 +690,7 @@ export function closeRequested(board, report, { taskId = null, caller = null } =
690
690
  if (reading.alive) {
691
691
  const protectedNow = active.some(r => addressOf(json(r.metadata, {})?.supercode) === addressOf(session));
692
692
  if (protectedNow) throw new Error('close deferred: native session is owned by an unended run');
693
- if (session.lifecycle_notice_pending) throw new Error(`close deferred: lifecycle notice ${session.lifecycle_notice_pending} has not been delivered`);
693
+ if (noticeHoldsClose(session, null)) throw new Error(`close deferred: lifecycle notice ${session.lifecycle_notice_pending} has not been filed`);
694
694
  stopSession(board, session);
695
695
  }
696
696
  } else {
@@ -703,7 +703,7 @@ export function closeRequested(board, report, { taskId = null, caller = null } =
703
703
  if (observed.error) throw new Error(observed.error);
704
704
  if (observed.live.has(session.pane)) {
705
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 (pendingNotice || session.lifecycle_notice_pending) throw new Error(`close deferred: lifecycle notice ${pendingNotice?.id ?? session.lifecycle_notice_pending} has not been delivered: ${session.lifecycle_notice_error ?? 'pending'}`);
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
707
  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
708
  .some((r) => { const s = json(r.metadata, {})?.supercode; return s?.pane === session.pane && normalMachine(s.machine) === normalMachine(session.machine); });
709
709
  if (protectedNow) throw new Error(`close deferred: pane ${session.pane} is owned by an unended run`);
@@ -744,7 +744,6 @@ function observeIdle(board, fleet, settings, report, entry, effects) {
744
744
  if (!(entry.workflow.statuses[status]?.on?.session_idle || entry.workflow.on?.session_idle || entry.workflow.run?.on?.session_idle)) return;
745
745
  const runRow = currentRun(db, id);
746
746
  const session = runRow?.metadata?.supercode;
747
- if (session?.wait_started_at != null) return;
748
747
  if (!session?.session_id || !['pane', 'native'].includes(session.surface) || session.offline_since != null) return;
749
748
  if (session.surface === 'native' && fleet.native?.get(addressOf(session))?.alive !== true) return;
750
749
  const live = fleet.sessions.find((x) => x.address === addressOf(session));
@@ -814,26 +813,31 @@ export async function sessionNotice(board, e) {
814
813
  try {
815
814
  if (!e.address) throw new Error('lifecycle notice awaits the owned launch session identity');
816
815
  receipt = await send(boardAddress(board.label), board.label, e.address, e.text, effectMessageKey(e));
817
- if (!receipt.delivered) error = receipt.reason ?? 'notice queued; native transcript confirmation pending'; }
816
+ // A notice filed in the session's mailbox is done: the session reads its mail at its next
817
+ // turn, and a resumed session first. Only a notice no mailbox took is retried.
818
+ if (!receipt.delivered && !receipt.queued) error = receipt.reason ?? 'notice was not filed'; }
818
819
  catch (failure) { error = String(failure.message).slice(-500); }
819
820
  tx(board, (db) => {
820
821
  const session = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(e.runId)?.metadata, {})?.supercode;
821
822
  if (session) setRunSession(db, e.runId, { ...session,
822
- ...(error ? { lifecycle_notice_error: error } : { lifecycle_notice_error: null, ...(session.lifecycle_notice_pending === e.durableId ? { lifecycle_notice_pending: null } : {}) }) });
823
- if (!error || session?.lifecycle_notice_error !== error) event(db, e.id, error ? (receipt?.queued ? 'session_notice_queued' : 'session_notice_failed') : 'session_notice_delivered', { address: e.address, verb: e.verb, source_event: e.sourceEvent, notice: e.durableId, receipt, ...(error ? { error, retry: true } : {}) }, e.runId);
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 } : {}) }) });
825
+ 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);
824
826
  });
825
827
  return !error;
826
828
  }
827
829
 
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
+
828
836
  /** A session effect a committed move asked for: a message to the card's current actor, or closing a slot's pane. */
829
837
  export async function sessionEffect(board, e) {
830
838
  if (e.kind === 'send_to_actor') {
831
839
  const row = view(board, db => e.runId ? db.prepare('SELECT metadata FROM task_runs WHERE id=?').get(e.runId) : null);
832
840
  const session = json(row?.metadata, {})?.supercode;
833
- if (session?.wait_started_at != null) {
834
- tx(board, db => event(db, e.id, 'actor_notice_suppressed', { notice: e.durableId, reason: 'run is waiting', run_id: e.runId }, e.runId));
835
- return true;
836
- }
837
841
  const address = addressOf(session);
838
842
  let receipt;
839
843
  try {
@@ -1078,6 +1082,10 @@ function reconcileLaunches(board, fleet, report, entry, effects, settings, lanes
1078
1082
 
1079
1083
  function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'worker', meta = {} } = {}) {
1080
1084
  const machine = normalMachine(task.machine);
1085
+ // Whether a board session opens a terminal window on its machine is the workflow's choice, stated on every launch;
1086
+ // no machine default decides it.
1087
+ const window = workflowFor(board.root).params.session_window;
1088
+ if (typeof window !== 'boolean') throw unavailable('the workflow declares no params.session_window (true or false): whether a board session opens a window on its machine is the workflow\'s choice');
1081
1089
  {
1082
1090
  const made = resolveLocalWorkspace(board, task);
1083
1091
  tx(board, (db) => {
@@ -1090,7 +1098,7 @@ function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'wor
1090
1098
  if (lane.harness === 'codex' && lane.reasoning_effort) tail.push('-c', `model_reasoning_effort=${JSON.stringify(lane.reasoning_effort)}`);
1091
1099
  const harness = HARNESSES[lane.harness] ? lane.harness : 'claude-code';
1092
1100
  const how = HARNESSES[harness];
1093
- const base = ['open', '--new', how.program, '--detach', '--on', task.machine, '--cwd', task.workspace_path];
1101
+ const base = ['open', '--new', how.program, '--detach', `--window=${window}`, '--on', task.machine, '--cwd', task.workspace_path];
1094
1102
  // the slot's last session: kept when live, resumed when lost, never another slot's (a reviewer's is its own)
1095
1103
  // a close its session's own command asked for is carried out first, so its pane is gone before the slot's next
1096
1104
  // session starts (never two processes on one session)
@@ -1778,36 +1786,43 @@ export async function plan({ root, lanes, settings, slugs = null, params = {} })
1778
1786
  }
1779
1787
 
1780
1788
  /** An opened PR asks for the declared review transition once the arc's tasks are closed. */
1789
+ // A task written in the card's own Tasks section that did not become a subtask holds its review too.
1781
1790
  async function activatePullRequestReviews(board, entry, report, effects, { only = null } = {}) {
1782
1791
  const candidates = view(board, db => db.prepare(`SELECT t.id FROM tasks t JOIN supercode_cards c ON c.task_id=t.id
1783
- WHERE c.subtask_of IS NULL AND COALESCE(c.review_only,0)=0 AND c.every IS NULL
1792
+ WHERE c.subtask_of IS NULL AND c.every IS NULL
1784
1793
  AND t.workspace_kind!='scratch' ${only ? 'AND t.id=?' : ''}`).all(...(only ? [only] : [])));
1785
1794
  for (const { id } of candidates) {
1786
1795
  const eligible = db => {
1787
1796
  const task = getTask(db, id);
1788
1797
  if (!task) return false;
1789
1798
  const scope = scopeOf(db, board, task, { params: entry.params });
1790
- return task && transitionsFor(entry.workflow, statusOf(db, task), 'request_review').some(t => !t.by || t.by.includes('platform')) &&
1791
- !scope.card.dispatch_hold && !scope.card.paused && !scope.card.blocked_by.length &&
1792
- scope.card.subtasks.every(t => ['done', 'cancelled'].includes(t.status));
1799
+ if (!transitionsFor(entry.workflow, statusOf(db, task), 'request_review').some(t => !t.by || t.by.includes('platform')) ||
1800
+ scope.card.dispatch_hold || scope.card.paused || scope.card.blocked_by.length) return false;
1801
+ // The card's Tasks are its subtasks; an open one holds the review, and says so.
1802
+ const open = scope.card.subtasks.filter(t => !['done', 'cancelled'].includes(t.status));
1803
+ return open.length ? open : true;
1793
1804
  };
1794
- if (!view(board, eligible)) continue;
1805
+ const ready = view(board, eligible);
1806
+ if (!ready) continue;
1807
+ 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; }
1795
1808
  try {
1796
- const payload = openReviewPullRequest(board, id);
1797
- if (!payload) continue;
1809
+ const payload = openReviewPullRequests(board, id);
1810
+ // The arc's open PRs ask for one review only while some head has no verdict: after changes are
1811
+ // 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;
1798
1813
  const evidence = inspectCompletionEvidence(board, id, entry, payload, 'request_review');
1799
1814
  if (!evidence.ready) throw new Error(evidence.reason);
1800
1815
  refreshCardInputs(board, id, { commits: true });
1801
1816
  const moved = tx(board, db => {
1802
1817
  const task = getTask(db, id);
1803
- if (!eligible(db)) return null;
1818
+ if (eligible(db) !== true) return null;
1804
1819
  return withCompletionEvidence(board, id, evidence, () => fire(db, board, entry, id, 'request_review', {
1805
- roles: ['platform'], payload: { ...payload, run_id: task.current_run_id, summary: `Opened PR ${payload.pr} at ${payload.pr_head}`, ci: evidence }
1820
+ roles: ['platform'], payload: { ...payload, run_id: task.current_run_id, summary: `Opened ${payload.pr.map((url, index) => `${url} at ${payload.pr_head[index]}`).join(', ')}`, ci: evidence }
1806
1821
  }));
1807
1822
  });
1808
1823
  if (moved) {
1809
1824
  effects.push(...moved.effects);
1810
- (report.review_requested ??= []).push({ id, pr: payload.pr, head: payload.pr_head });
1825
+ (report.review_requested ??= []).push({ id, prs: payload.pr, heads: payload.pr_head });
1811
1826
  }
1812
1827
  } catch (error) { report.queued.push([id, `PR review waits: ${error.message}`]); }
1813
1828
  }