@volter/supercode-orchestrator 0.4.147 → 0.4.148

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,11 +1,10 @@
1
- // Deployment evidence is observed before a board mutation. No network or Git
2
- // process runs inside the optimistic transaction that decides completion.
1
+ // Review candidates are observed before a board mutation. No network or Git
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, lstatSync } from 'node:fs';
4
+ import { readFileSync, realpathSync, writeFileSync, renameSync } from 'node:fs';
5
5
  import { dirname, join, relative } from 'node:path';
6
- import { homedir } from 'node:os';
7
- import { pathToFileURL } from 'node:url';
8
6
  import { createHash } from 'node:crypto';
7
+ import { resolveWorkspace } from './workspace.mjs';
9
8
 
10
9
  const observations = new Map();
11
10
  const key = (board, id) => `${board.root}\0${board.slug}\0${id}`;
@@ -14,14 +13,19 @@ const run = (program, args, cwd) => {
14
13
  if (result.status !== 0) throw new Error(`${program}: ${(result.stderr || result.error?.message || 'command failed').trim().slice(-500)}`);
15
14
  return result.stdout.trim();
16
15
  };
17
- const codePath = path => path && !/^(?:docs\/|\.volter\/tracker\/)/.test(path) && (!/\.(?:md|txt|png|jpg|svg)$/i.test(path) || /(?:^|\/)prompts\//.test(path));
18
16
 
19
17
  export function completionEvidence(board, id) {
20
- return observations.get(key(board, id)) ?? { required: true, ready: false, reason: 'Deployment evidence has not been observed; request review or done through the board CLI with --commit and --ci-run.' };
18
+ return observations.get(key(board, id)) ?? { required: false, ready: true, phase: 'source', deployed_sha: null };
21
19
  }
22
20
 
23
- export function observeCompletionEvidence(board, id, entry, payload = {}) {
24
- const state = inspectCompletionEvidence(board, id, entry, payload);
21
+ export function completionPhase(board, id, entry, verb, payload, roles = []) {
22
+ if (verb === 'request_review') return 'request_review';
23
+ if ((verb === 'request_state' && payload.state === 'done' || verb === 'complete') && roles.includes('reviewer')) return 'review_verdict';
24
+ return verb === 'complete' ? 'acceptance' : 'completion';
25
+ }
26
+
27
+ export function observeCompletionEvidence(board, id, entry, payload = {}, phase = 'completion') {
28
+ const state = inspectCompletionEvidence(board, id, entry, payload, phase);
25
29
  observations.set(key(board, id), state);
26
30
  return state;
27
31
  }
@@ -37,111 +41,103 @@ export function withCompletionEvidence(board, id, state, fn) {
37
41
  }
38
42
  }
39
43
 
40
- export function inspectCompletionEvidence(board, id, entry, payload = {}) {
41
- const state = { required: true, ready: false, reason: null };
42
- if (!entry.params.completion_ci) { Object.assign(state, { required: false, ready: true }); return state; }
44
+ /** The latest explicitly requested PR is the reviewer's candidate, never the deployment tip. */
45
+ export function reviewCandidate(board, id) {
46
+ 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
+ const payload = row && JSON.parse(row.payload);
48
+ return payload?.ci?.pr ? payload.ci : null;
49
+ }
50
+
51
+ function repositoryWorkspace(board, id) {
52
+ 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
+ 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);
54
+ const workspace = task?.workspace_kind === 'scratch' ? parent : task;
55
+ return workspace?.workspace_kind !== 'scratch' && workspace?.workspace_anchor ? resolveWorkspace(workspace, false).path : workspace?.workspace_kind !== 'scratch' ? workspace?.workspace_path : null;
56
+ }
57
+
58
+ const pullRequestLists = new Map();
59
+
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;
64
+ 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);
76
+ }
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 };
80
+ }
81
+
82
+ export function inspectCompletionEvidence(board, id, entry, payload = {}, stage = 'completion') {
83
+ const state = { required: false, ready: true, reason: null, phase: 'source', deployed_sha: null };
43
84
  try {
44
- const task = board.db.prepare('SELECT t.*,c.subtask_of,c.workspace_anchor FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.id=?').get(id);
45
- const parent = task?.subtask_of && board.db.prepare('SELECT workspace_path FROM tasks WHERE id=?').get(task.subtask_of);
46
- const path = task?.workspace_path ?? parent?.workspace_path;
47
- const old = board.db.prepare("SELECT payload FROM task_events WHERE task_id=? AND kind IN ('review_requested','state_requested','review_pass') ORDER BY id DESC LIMIT 20").all(id)
48
- .map(row => { try { return JSON.parse(row.payload)?.ci; } catch { return null; } }).find(ci => ci?.commit);
49
- const supplied = payload.commit ?? payload.metadata?.commit ?? old?.commit;
50
- if (!path) {
51
- if (supplied) throw new Error('The named commit needs a resolved repository workspace.');
52
- Object.assign(state, { required: false, ready: true }); return state;
53
- }
54
- const history = run('git', ['log', '--all', '--format=%H%x00', '--name-only', '--fixed-strings', `--grep=${id}`], path);
55
- const records = [...history.matchAll(/([a-f0-9]{40})\0([^\0]*?)(?=[a-f0-9]{40}\0|$)/gs)];
56
- const latest = records.find(([, , names]) => names.split('\n').some(codePath))?.[1];
57
- if (!latest && !supplied) { Object.assign(state, { required: false, ready: true }); return state; }
58
- const commit = run('git', ['rev-parse', '--verify', `${supplied ?? latest}^{commit}`], path);
59
- if (latest) run('git', ['merge-base', '--is-ancestor', latest, commit], path);
60
- const remote = run('git', ['remote', 'get-url', 'origin'], path);
61
- const repo = /(?:github\.com[:/])([^/]+\/[^/]+?)(?:\.git)?$/.exec(remote)?.[1];
62
- if (!repo) throw new Error('Deployment evidence requires this workspace\'s GitHub origin.');
63
- Object.assign(state, { commit, repository: repo });
64
- const controller = entry.params.completion_ci.controller_deployments?.[repo];
65
- if (controller) {
66
- const expand = value => value.startsWith('~/') ? join(homedir(), value.slice(2)) : value;
67
- const installed = expand(controller.installed_path);
68
- if (!lstatSync(installed).isSymbolicLink()) throw new Error('The controller installation must be the declared checkout symlink.');
69
- const skill = realpathSync(installed), checkout = run('git', ['rev-parse', '--show-toplevel'], skill);
70
- const origin = /(?:github\.com[:/])([^/]+\/[^/]+?)(?:\.git)?$/.exec(run('git', ['remote', 'get-url', 'origin'], checkout))?.[1];
71
- if (origin !== repo) throw new Error('The controller installation points to another repository.');
72
- const head = run('git', ['rev-parse', 'HEAD'], checkout);
73
- run('git', ['merge-base', '--is-ancestor', commit, head], checkout);
74
- run('git', ['diff', '--quiet', 'HEAD', '--', relative(checkout, join(skill, 'scripts'))], checkout);
75
- const sourceHash = run(process.execPath, ['--experimental-strip-types', '--input-type=module', '-e',
76
- 'const m=await import(process.argv[1]); process.stdout.write(m.installedSourceHash())', pathToFileURL(join(skill, 'scripts/controller-diagnostics.ts')).href], checkout);
77
- const port = Number(readFileSync(expand(controller.port_file), 'utf8').trim());
78
- if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('The controller has no valid retained port.');
79
- const url = `http://127.0.0.1:${port}/health`;
80
- const health = JSON.parse(run('curl', ['--fail', '--silent', '--show-error', '--max-time', '2', url], checkout));
81
- if (!health.alive || !health.cdp_connected || health.restart_required !== false || health.controller?.source_hash !== sourceHash || health.installed_source_hash !== sourceHash) throw new Error('The running browser controller has not loaded the committed installed code.');
82
- Object.assign(state, { ready: true, workflow: 'installed browser controller', run_id: `${health.controller.pid}@${health.controller.started_at}`, status: 'completed', conclusion: 'success', deployed_sha: head, url, source_hash: sourceHash });
85
+ const path = repositoryWorkspace(board, id);
86
+ if (stage === 'completion' || stage === 'acceptance') {
87
+ const supplied = payload.commit ?? payload.metadata?.commit;
88
+ if (supplied) {
89
+ 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) });
92
+ }
93
+ const candidate = stage === 'acceptance' ? reviewCandidate(board, id) : null;
94
+ if (candidate) {
95
+ 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.');
100
+ Object.assign(state, { ...candidate, ready: true, reason: null });
101
+ }
83
102
  return state;
84
103
  }
85
- if (entry.params.completion_ci.keeper_repositories?.includes(repo)) {
86
- const receiptPath = join(board.root, 'runtime-deployment.json');
87
- const receipt = JSON.parse(readFileSync(receiptPath, 'utf8'));
88
- if (receipt.repository !== repo || receipt.completed_at < Number(run('git', ['show', '-s', '--format=%ct', commit], path)) * 1000) throw new Error('No keeper tick after this runtime commit.');
89
- run('git', ['merge-base', '--is-ancestor', commit, receipt.head], receipt.checkout);
90
- const current = workflowRevision(board.root);
91
- if (!current || current.head !== receipt.head || current.workflow_hash !== receipt.workflow_hash) throw new Error('The retained keeper tick does not describe the live checkout and loaded workflow revision.');
92
- Object.assign(state, { ready: true, workflow: 'launchd keeper', run_id: `${receipt.pid}@${receipt.completed_at}`, status: 'completed', conclusion: 'success', deployed_sha: receipt.head, url: receiptPath });
104
+ 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.');
93
108
  return state;
94
109
  }
95
- const configured = entry.params.completion_ci.workflows?.[repo] ?? entry.params.completion_ci.workflows?.['*'];
96
- if (!Array.isArray(configured) || !configured.length) throw new Error(`No deployment workflow is declared for ${repo} in completion_ci.workflows.`);
97
- const matchedRules = (entry.params.completion_ci.path_workflows?.[repo] ?? []).map(rule => ({ ...rule,
98
- changed_at: records.find(([, , names]) => names.split('\n').some(path => rule.paths.some(prefix => path.startsWith(prefix))))?.[1]
99
- })).filter(rule => rule.changed_at);
100
- const requestedRun = payload.ci_run ?? payload['ci-run'] ?? payload.metadata?.ci_run ?? (old?.commit === commit ? old?.run_id : null);
101
- let receipt;
102
- if (requestedRun) {
103
- receipt = JSON.parse(run('gh', ['run', 'view', String(requestedRun), '--repo', repo, '--json', 'databaseId,workflowName,headSha,status,conclusion,url'], path));
104
- } else {
105
- const runs = JSON.parse(run('gh', ['run', 'list', '--repo', repo, '--commit', commit, '--limit', '100', '--json', 'databaseId,workflowName,headSha,status,conclusion,url'], path));
106
- receipt = runs.find(row => configured.includes(row.workflowName));
107
- }
108
- if (!receipt) throw new Error(`No deployment CI run exists for ${repo}@${commit}.`);
109
- Object.assign(state, { run_id: receipt.databaseId, workflow: receipt.workflowName, status: receipt.status, conclusion: receipt.conclusion, url: receipt.url });
110
- if (receipt.headSha !== commit) throw new Error(`CI run ${receipt.databaseId} is for ${receipt.headSha}, not ${commit}.`);
111
- if (!configured.includes(receipt.workflowName)) throw new Error(`${receipt.workflowName} is not a declared deployment workflow for ${repo}.`);
112
- if (receipt.status !== 'completed' || receipt.conclusion !== 'success') throw new Error(`Deployment CI ${receipt.databaseId} is ${receipt.status}/${receipt.conclusion || 'pending'}.`);
113
- // A later SDK publication need not rebuild unchanged native code. It does
114
- // need a successful native deployment containing this task's native changes.
115
- // Retain each artifact's actual SHA, rather than calling the SDK run a native release.
116
- state.coverage = [];
117
- const deployments = new Map();
118
- for (const rule of matchedRules) {
119
- if (rule.workflows.includes(receipt.workflowName)) continue;
120
- for (const workflow of rule.workflows) {
121
- if (!deployments.has(workflow)) deployments.set(workflow, JSON.parse(run('gh', ['run', 'list', '--repo', repo, '--workflow', workflow, '--limit', '100', '--json', 'databaseId,workflowName,headSha,status,conclusion,url'], path)));
122
- }
123
- const successful = rule.workflows.flatMap(workflow => deployments.get(workflow)).filter(row => row.status === 'completed' && row.conclusion === 'success');
124
- const covering = successful.find(row => {
125
- if (!rule.workflows.includes(row.workflowName) || !configured.includes(row.workflowName)) return false;
126
- try {
127
- run('git', ['merge-base', '--is-ancestor', rule.changed_at, row.headSha], path);
128
- try {
129
- run('git', ['merge-base', '--is-ancestor', commit, row.headSha], path);
130
- return true;
131
- } catch {}
132
- run('git', ['merge-base', '--is-ancestor', row.headSha, commit], path);
133
- // Any later change in these paths requires another native deployment,
134
- // even when a different task authored that change.
135
- return run('git', ['diff', '--name-only', row.headSha, commit], path).split('\n').every(name => !rule.paths.some(prefix => name.startsWith(prefix)));
136
- } catch { return false; }
137
- });
138
- if (!covering) throw new Error(`No successful ${rule.workflows.join('/')} deployment covers ${rule.paths.join(', ')} through ${commit}.`);
139
- state.coverage.push({ paths: rule.paths, commit: covering.headSha, workflow: covering.workflowName, run_id: covering.databaseId, conclusion: covering.conclusion, url: covering.url });
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;
113
+ 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.');
125
+ 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.');
132
+ } 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.');
134
+ 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.');
140
137
  }
141
- state.deployed_sha = commit;
142
- state.ready = true;
143
- state.reason = null;
144
- } catch (error) { state.reason = error.message; }
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 });
140
+ } catch (error) { Object.assign(state, { required: true, ready: false, reason: error.message }); }
145
141
  return state;
146
142
  }
147
143
 
package/board/cli.mjs CHANGED
@@ -36,9 +36,10 @@ 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> [--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
- specify <id> [--title --body] [--all] decompose <id> [--child "<title>[@<profile>]"]... [--all]
41
+ specify <id> [--title --body] [--workspace scratch|worktree:<repository>|dir:<repository>] [--branch NAME] [--all]
42
+ decompose <id> [--child "<title>[@<profile>]"]... [--all]
42
43
  diagnostics [--task] [--severity] stats assignees log <id> [--tail N] tail <id> watch [--assignee] [--kinds]
43
44
  notify-subscribe <id> --platform P --chat-id C [--thread-id] notify-list [<id>] notify-unsubscribe <id> --platform P --chat-id C
44
45
  dispatch [--dry-run] [--max N] [--failure-limit N] daemon [--interval S] [--max N] [--failure-limit N] [--pidfile F] [--verbose] gc [--event-retention-days] [--log-retention-days] repair
@@ -52,6 +53,8 @@ supercode's own:
52
53
  waits, and its creator is told when it starts. A subtask is worked by its arc's session and never starts alone.
53
54
  move <id> <machine|none> adopt <id> <session> [--assignee P] [--role ROLE] close <id> serve (= daemon) list --machine M
54
55
  notify-subscribe <id> --address sc:<machine>:<harness>:<id> [--role creator|worker|dependency]
56
+ notify-subscribe <dependency> --for-card <your-arc>: subscribe only your arc's session to dependency notices.
57
+ notify-unsubscribe <dependency> --for-card <your-arc>: remove that arc's subscription only.
55
58
  Any verb … --at sc:<machine>:board:<home>: sent as mail to a board kept on another machine.
56
59
  <event> <id>... [reason] [--<field> value]: any other event the board's workflow declares, its flags the event's fields.
57
60
  start <id> release a --no-start hold and start through the declared workflow
@@ -59,6 +62,11 @@ supercode's own:
59
62
  goto <id> <status> [reason] [--<field> value]: the event the workflow says takes the card to that status, sent.
60
63
 
61
64
  specify <id> --review-only|--no-review-only: a manager declares a standalone independent review job.
65
+ 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.
68
+ request-state <id>... --state STATE --reason REASON: request several decisions in one retained manager mail.
69
+ complete <id>: creator/manager accepts the independently reviewed arc; deployment CI is not a board gate.
62
70
  specify/decompose with no spec start a session on the triage card to write it. A card's own session may comment on,
63
71
  heartbeat and request review of its card, specify or decompose it in triage, and work its subtasks. The home's
64
72
  workflow declares who may block, pause or finish a card; request-state mails its manager without changing state.
@@ -544,7 +552,7 @@ export async function runWorkflow(argv) {
544
552
  case 'complete': {
545
553
  need(1, 'a card id');
546
554
  const metadata = metadataOf(args.metadata);
547
- 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 }));
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 }));
548
556
  say(args, rest.map((t) => `Completed ${t}`).join('\n'), { ok: true, completed: rest }); return 0;
549
557
  }
550
558
  case 'edit': {
@@ -574,7 +582,7 @@ export async function runWorkflow(argv) {
574
582
  if (args.reason) for (const [t] of results) await withBoardAsync((b) => engine.comment(b, t, callerName(), `UNBLOCK: ${args.reason}`));
575
583
  say(args, results.map(([t, s]) => `Unblocked ${t} → ${s}`).join('\n'), { ok: true, results }); return 0;
576
584
  }
577
- 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, force: Boolean(args.force), expectedRunId: workerRun(b.db, id, sid) })); say(args, `${id} → review`, { ok: true }); return 0;
585
+ 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;
578
586
  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
587
  case 'reopen': {
580
588
  // a done card back to work; the cards that built on it are invalidated with it (Hermes's reopen)
@@ -629,7 +637,7 @@ export async function runWorkflow(argv) {
629
637
  if (!targets[0]) throw new Error(`${verb} needs a card id, or --all`);
630
638
  const results = [];
631
639
  for (const t of targets) {
632
- if (verb === 'specify' && (args.title !== undefined || args.body !== undefined || args.workspace !== undefined || args['review-only'] || args['no-review-only'])) {
640
+ if (verb === 'specify' && (args.title !== undefined || args.body !== undefined || args.workspace !== undefined || args.branch !== undefined || args['review-only'] || args['no-review-only'])) {
633
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
642
  results.push({ id: t, specified: true, status: moved.to });
635
643
  } else if (verb === 'decompose' && args.child?.length) {
@@ -711,6 +719,12 @@ export async function runWorkflow(argv) {
711
719
 
712
720
  case 'notify-subscribe': case 'notify-unsubscribe': {
713
721
  need(1, 'a card id');
722
+ if (args['for-card']) {
723
+ if (args.address || args['chat-id'] || args['thread-id'] || (args.platform && args.platform !== 'supercode') || (args.role && args.role !== 'dependency')) throw new Error('--for-card targets that arc\'s session; do not supply another recipient or audience.');
724
+ const subscription = withBoard(board => engine.subscribeDependency(board, args['for-card'], id, { remove: verb === 'notify-unsubscribe' }));
725
+ say(args, verb === 'notify-unsubscribe' ? `Removed ${args['for-card']}'s dependency subscription to ${id}` : `${args['for-card']} hears ${id} through ${subscription.address}`, { ok: true, ...subscription });
726
+ return 0;
727
+ }
714
728
  const platform = args.platform ?? 'supercode';
715
729
  const chat = args['chat-id'] ?? args.address ?? (platform === 'supercode' ? callerAddress() : null);
716
730
  if (!chat) throw new Error(`${verb} names the subscriber: --platform P --chat-id C (a supercode session's address with --platform supercode)`);
@@ -863,9 +877,10 @@ export async function runWorkflow(argv) {
863
877
  if (!declared) throw new Error(`no workflow verb ${verb}; \`supercode workflow help\` lists them`);
864
878
  need(1, 'a card id');
865
879
  const { _: _words, root: _r, board: _b, json: _j, at: _a, ids: _i, ...fields } = args;
866
- const payload = { ...fields, ci_run: args['ci-run'] ?? fields.ci_run, deployed_sha: args['deployed-sha'] ?? fields.deployed_sha, waiting_on: args['waiting-on'] ?? fields.waiting_on, reason: args.reason ?? (rest.slice(1).join(' ') || null) };
880
+ const payload = { ...fields, ci_run: args['ci-run'] ?? fields.ci_run, deployed_sha: args['deployed-sha'] ?? fields.deployed_sha, reviewed_sha: args['reviewed-sha'] ?? fields.reviewed_sha, pr_head: args['pr-head'] ?? fields.pr_head, waiting_on: args['waiting-on'] ?? fields.waiting_on, reason: args.reason ?? (name === 'request_state' ? null : rest.slice(1).join(' ') || null) };
867
881
  const results = [];
868
- for (const t of [id, ...(args.ids ?? [])]) results.push([t, (await withBoardAsync((b) => engine.act(b, t, name, { payload }))).to]);
882
+ if (name === 'request_state') results.push(...await withBoardAsync(board => engine.requestStateMany(board, [...rest, ...(args.ids ?? [])], payload)));
883
+ else for (const t of [id, ...(args.ids ?? [])]) results.push([t, (await withBoardAsync((b) => engine.act(b, t, name, { payload }))).to]);
869
884
  say(args, results.map(([t, s]) => name === 'request_state' ? `${t}: ${payload.state} requested; state remains ${s}. Mail delivery is recorded on the card.` : `${t} → ${s}`).join('\n'), { ok: true, results }); return 0;
870
885
  }
871
886
  }
@@ -1101,7 +1116,11 @@ function rolesFor(db, cardId, sessionId, params = {}) {
1101
1116
  const current = task.current_run_id ? json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(task.current_run_id)?.metadata, {})?.supercode : null;
1102
1117
  if (current?.session_id === sessionId) roles.push(current.role ?? 'implementer');
1103
1118
  else if (engine.lastSession(db, cardId)?.session_id === sessionId) roles.push('implementer');
1104
- if (task.subtask_of && engine.lastSession(db, task.subtask_of)?.session_id === sessionId) roles.push('arc');
1119
+ if (task.subtask_of) {
1120
+ const parent = engine.getTask(db, task.subtask_of);
1121
+ const worker = parent?.current_run_id ? engine.currentRun(db, parent.id)?.metadata?.supercode : engine.lastSession(db, task.subtask_of);
1122
+ if (worker?.session_id === sessionId) roles.push('arc');
1123
+ }
1105
1124
  return roles;
1106
1125
  }
1107
1126
 
@@ -1116,14 +1135,18 @@ function authorize(db, verb, rest, args, own) {
1116
1135
  // (what a session may do to a card is its workflow's: each transition's `by`)
1117
1136
  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`);
1118
1137
  if (args.all) refuse(`does not ${verb} every card; only its own`);
1119
- const card = engine.getTask(db, rest[0]);
1120
- if (!card) return; // the verb itself answers "no card"
1121
- const creator = completionOwnerOf(db, card.id);
1122
- if (creator && creator === actorOf()) return;
1123
- if (verb === 'comment' && isLatestReviewer(db, card.id, actorOf())) return;
1124
- // a card's session writes its own card and its subtasks; what it may do with them is the workflow's (`by`)
1125
- if (card.id !== own.id && card.subtask_of !== own.id) refuse(`writes only its own card and its subtasks, not ${card.id}`);
1126
- for (const other of args.ids ?? []) if (other !== own.id && engine.getTask(db, other)?.subtask_of !== own.id) refuse(`writes only its own card and its subtasks, not ${other}`);
1138
+ if (['notify-subscribe', 'notify-unsubscribe'].includes(verb) && args['for-card']) {
1139
+ if (args['for-card'] !== own.id) refuse(`subscribes only its own arc (--for-card ${own.id})`);
1140
+ return;
1141
+ }
1142
+ const targets = verb === 'request-state' ? [...rest, ...(args.ids ?? [])] : [rest[0], ...(args.ids ?? [])];
1143
+ for (const target of targets) {
1144
+ const card = engine.getTask(db, target);
1145
+ if (!card) continue;
1146
+ if (completionOwnerOf(db, card.id) === actorOf()) continue;
1147
+ if (verb === 'comment' && isLatestReviewer(db, card.id, actorOf())) continue;
1148
+ if (card.id !== own.id && card.subtask_of !== own.id) refuse(`writes only its own card and its subtasks, not ${card.id}`);
1149
+ }
1127
1150
  }
1128
1151
 
1129
1152
  /** Close the live sessions of a card that is not running (its worker's and its reviewers'), freeing its machine's place. */
@@ -1202,6 +1225,7 @@ async function serve(root, args = {}) {
1202
1225
  }
1203
1226
  pausedSaid = Boolean(paused(root)) && pausedSaid;
1204
1227
  let interval = 60;
1228
+ let nextMailAt = Infinity;
1205
1229
  const began = Date.now();
1206
1230
  if (wakeReason) log(`dispatch wake ${wakeReason.sources.join(', ')}; event age ${began - wakeReason.at}ms`);
1207
1231
  wakeReason = null;
@@ -1210,6 +1234,7 @@ async function serve(root, args = {}) {
1210
1234
  const settings = { ...state.settings, ...override };
1211
1235
  interval = settings.interval;
1212
1236
  for (const r of await tick({ root, lanes: state.lanes, settings, params, deploymentReceipt: true })) {
1237
+ nextMailAt = Math.min(nextMailAt, r.next_mail_at ?? Infinity);
1213
1238
  const said = summary(r);
1214
1239
  if (r.deployment) log(`runtime deployed ${r.deployment.repository}@${r.deployment.head}; workflow ${r.deployment.workflow_hash}; tick ${r.deployment.completed_at}`);
1215
1240
  if (!said.endsWith('nothing to do') || args.verbose) log(said);
@@ -1233,7 +1258,7 @@ async function serve(root, args = {}) {
1233
1258
  // the next tick is due an interval after this one began (a long tick is not followed by a full interval's wait)
1234
1259
  events.refresh();
1235
1260
  if (!stopped && !moved) {
1236
- wakeReason = await events.wait(Math.max(0, interval * 1000 - (Date.now() - began)));
1261
+ wakeReason = await events.wait(Math.max(0, Math.min(interval * 1000 - (Date.now() - began), nextMailAt - Date.now())));
1237
1262
  // A busy event stream is coalesced to at most one tick per second.
1238
1263
  const remaining = 1000 - (Date.now() - began);
1239
1264
  if (remaining > 0 && !stopped) await new Promise(done => setTimeout(done, remaining));