@volter/supercode-orchestrator 0.4.17 → 0.4.19

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/cli.mjs CHANGED
@@ -1,55 +1,73 @@
1
1
  // `supercode workflow <verb>` (docs/architecture/orchestrator.md §2.9): supercode's own board, in Hermes Kanban's
2
- // format, and its dispatcher. The Rust CLI hands every verb but `load` to `bin/orchestrator.mjs workflow`, which
3
- // lands here. The home is `--root`, else `SUPERCODE_ORCHESTRATOR_HOME`, else `HERMES_HOME`; the board is
4
- // `--board <slug>` (default `default`). Every verb prints a line for a person, or one JSON object with `--json`.
5
- import { existsSync } from 'node:fs';
2
+ // format, and its dispatcher. Every verb of `hermes kanban` is here under its own name, with its own flags and its own
3
+ // output lines, so a caller of Hermes's CLI calls this one unchanged; supercode's own verbs (`move`, `adopt`, `close`,
4
+ // `serve`, `--at`) are added beside them. The Rust CLI hands every verb but `load` to `bin/orchestrator.mjs workflow`,
5
+ // which lands here. The home is `--root`, else `SUPERCODE_ORCHESTRATOR_HOME`, else `HERMES_HOME`; the board is
6
+ // `--board <slug>`, else `HERMES_KANBAN_BOARD`, else the home's current board (`kanban/current`), else `default`.
7
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
6
8
  import { spawnSync } from 'node:child_process';
9
+ import { basename, join, resolve } from 'node:path';
7
10
  import { tmpdir } from 'node:os';
8
- import { resolve } from 'node:path';
9
11
  import { read } from '../orchestration.mjs';
10
12
  import { holdDispatcherLock } from '../hermes-locks.mjs';
11
- import { boardPath, DEFAULT_BOARD, event, listBoards, openBoard, tx } from './store.mjs';
13
+ import { boardPath, DEFAULT_BOARD, event, json, listBoards, now, openBoard, tx } from './store.mjs';
12
14
  import * as engine from './engine.mjs';
13
- import { attachSession, boardLabel, boardSettings, lanesOf, observeFleet, tick } from './dispatch.mjs';
15
+ import { adoptSession, boardLabel, boardSettings, lanesOf, localMachine, observeFleet, paused, startTriageSession, tick } from './dispatch.mjs';
14
16
 
15
17
  const HELP = `supercode workflow <verb> [--root <home>] [--board <slug>] [--json]
16
18
 
17
- create <title> [--body T] [--assignee PROFILE] [--machine M] [--workspace dir:<path>|scratch|worktree[:<path>]]
18
- [--branch B] [--parent ID]... [--priority N] [--tenant T] [--triage] [--idempotency-key K] [--max-runtime SECONDS]
19
- [--max-retries N] [--skill S]... [--model M] [--provider P] [--created-by NAME] [--notify ADDRESS | --no-notify]
20
- [--subtask-of ARC] [--no-start]
21
- File a card. With a lane and capacity it starts at once and answers "started" with its session; otherwise it
22
- answers why it waits, and its creator is told when it starts. A subtask is worked by its arc's session and never
23
- starts on its own; another card may wait on it (link).
24
- list [--status S] [--assignee P] [--machine M] [--all] show <id> context <id>
25
- comment <id> <text> [--author NAME] complete <id> [--summary T] [--result T]
26
- block <id> [reason] [--kind dependency|needs_input|capability|transient] unblock <id>
27
- review <id> [--summary T] [--reviewer P] changes <id> [--reason T] [--reviewer P]
28
- schedule <id> [reason] promote <id> [--force] reclaim <id> [reason] archive <id>
29
- assign <id> <profile|none> move <id> <machine|none> link <parent> <child> unlink <parent> <child>
30
- subscribe <id> [--address A] [--role creator|worker|dependency] unsubscribe <id> [--address A]
31
- close <id> close the session of a card that is not running (an idle session holds its machine's place)
32
- attach <id> <session> [--workspace dir:<path>]
33
- put a live session the board did not start on the card (an address, id or name@machine the
34
- fleet lists); the board watches, resumes and tells it from then on
35
-
36
- A card's own session may comment on, block (with --kind) or send to review its card, and create, complete, block and
37
- comment on its subtasks; its arc is marked done by its reviewers, never by itself.
38
- init create the board if it does not exist
39
- dispatch one dispatcher tick over every board of the home
40
- serve the dispatcher, a tick every kanban.dispatch_interval_seconds, until SIGTERM
19
+ Hermes Kanban's verbs, with their flags:
20
+ init · boards list|create|rm|switch|show|rename|set-default-workdir|export|import
21
+ create <title> [--body] [--assignee] [--parent]... [--workspace scratch|worktree[:<path>]|dir:<path>] [--branch] [--project]
22
+ [--tenant] [--priority] [--triage] [--idempotency-key] [--max-runtime 30m] [--created-by] [--skill]... [--max-retries]
23
+ [--model] [--provider] [--goal] [--goal-max-turns] [--initial-status blocked|running]
24
+ swarm <goal> --worker PROFILE:TITLE[:SKILL,SKILL]... --verifier P --synthesizer P
25
+ list [--mine] [--assignee] [--status] [--tenant] [--session] [--archived] [--sort] show <id> context <id> runs <id>
26
+ assign <id> <profile|none> reassign <id> <profile> [--reclaim] [--reason] set-model <id> [model] [--provider]
27
+ claim <id> [--ttl] reclaim <id> [--reason] heartbeat <id> [--note] link|unlink <parent> <child>
28
+ comment <id> <text> [--author] [--max-len] attach <id> <path> [--name] [--content-type] attachments <id> attach-rm <n>
29
+ complete <id>... [--result] [--summary] [--metadata JSON] edit <id> --result [--summary] [--metadata]
30
+ block <id> [reason] [--kind dependency|needs_input|capability|transient] [--ids ...] schedule <id> [reason] [--ids ...]
31
+ unblock <id>... request-review <id> [--summary] [--reviewer] request-changes <id> <reason> reopen-review <id>...
32
+ promote <id> [reason] [--ids ...] [--force] [--dry-run] archive <id>... [--rm <id>...]
33
+ specify <id> [--title --body] [--all] decompose <id> [--child "<title>[@<profile>]"]... [--all]
34
+ diagnostics [--task] [--severity] stats assignees log <id> [--tail N] tail <id> watch [--assignee] [--kinds]
35
+ notify-subscribe <id> --platform P --chat-id C [--thread-id] notify-list [<id>] notify-unsubscribe <id> --platform P --chat-id C
36
+ dispatch [--dry-run] [--max N] [--failure-limit N] daemon gc [--event-retention-days] [--log-retention-days] repair
37
+ pause [reason] · resume (Hermes's emergency stop, the ESTOP file: new starts stop, running sessions go on)
38
+
39
+ supercode's own:
40
+ create … --machine M --subtask-of ARC --notify ADDRESS|--no-notify --no-start
41
+ With a pane lane and room, a card starts at once and answers "started" with its session; else it answers why it
42
+ waits, and its creator is told when it starts. A subtask is worked by its arc's session and never starts alone.
43
+ move <id> <machine|none> adopt <id> <session> close <id> serve (= daemon) list --machine M
44
+ notify-subscribe <id> --address sc:<machine>:<harness>:<id> [--role creator|worker|dependency]
45
+ Any verb … --at sc:<machine>:board:<home>: sent as mail to a board kept on another machine.
46
+
47
+ specify/decompose with no spec start a session on the triage card to write it. A card's own session may comment on,
48
+ block (with --kind), heartbeat and request review of its card, specify or decompose it in triage, and file, complete,
49
+ block and comment on its subtasks; its arc is marked done by its reviewers, never by itself.
41
50
  `;
42
51
 
43
- const MULTI = new Set(['parent', 'skill']);
44
- const FLAGS = new Set(['json', 'triage', 'no-notify', 'no-start', 'all', 'force', 'help']);
52
+ const MULTI = new Set(['parent', 'skill', 'worker', 'child', 'ids', 'rm']);
53
+ const FLAGS = new Set(['json', 'triage', 'no-notify', 'no-start', 'all', 'force', 'help', 'mine', 'archived', 'reclaim', 'dry-run',
54
+ 'goal', 'switch', 'delete', 'no-attachments', 'include-logs', 'verbose']);
55
+ // flags that take every following word up to the next flag (Hermes's nargs='+')
56
+ const GREEDY = new Set(['ids', 'rm']);
45
57
 
46
58
  function parse(argv) {
47
59
  const out = { _: [] };
48
60
  for (let i = 0; i < argv.length; i += 1) {
49
61
  const a = argv[i];
50
- if (!a.startsWith('--')) { out._.push(a); continue; }
62
+ if (!a.startsWith('--') || a === '--') { out._.push(a); continue; }
51
63
  const [key, inline] = a.slice(2).split(/=(.*)/s);
52
64
  if (FLAGS.has(key)) { out[key] = true; continue; }
65
+ if (GREEDY.has(key)) {
66
+ const values = inline !== undefined ? [inline] : [];
67
+ while (i + 1 < argv.length && !argv[i + 1].startsWith('--')) values.push(argv[++i]);
68
+ (out[key] ??= []).push(...values);
69
+ continue;
70
+ }
53
71
  const value = inline ?? argv[++i];
54
72
  if (value === undefined) throw new Error(`--${key} needs a value`);
55
73
  if (MULTI.has(key)) (out[key] ??= []).push(value);
@@ -64,11 +82,74 @@ function homeOf(args) {
64
82
  return resolve(root);
65
83
  }
66
84
 
85
+ const SLUG = /^[a-z0-9][a-z0-9_-]{0,63}$/;
86
+
87
+ function normalSlug(slug) {
88
+ const s = String(slug ?? '').trim().toLowerCase();
89
+ if (!SLUG.test(s)) throw new Error(`invalid board slug ${slug}: 1-64 lowercase letters, digits, - or _, not starting with - or _`);
90
+ return s;
91
+ }
92
+
93
+ const currentFile = (root) => join(root, 'kanban', 'current');
94
+
95
+ /** The board a verb acts on, as Hermes resolves it. */
96
+ function boardOf(root, args) {
97
+ for (const want of [args.board, process.env.HERMES_KANBAN_BOARD, existsSync(currentFile(root)) ? readFileSync(currentFile(root), 'utf8').trim() : null]) {
98
+ if (!want) continue;
99
+ const slug = normalSlug(want);
100
+ if (slug === DEFAULT_BOARD || existsSync(boardPath(root, slug))) return slug;
101
+ if (want === args.board) return slug;
102
+ }
103
+ return DEFAULT_BOARD;
104
+ }
105
+
106
+ const boardDir = (root, slug) => join(root, 'kanban', 'boards', slug);
107
+ const metaPath = (root, slug) => join(boardDir(root, slug), 'board.json');
108
+
109
+ function boardMeta(root, slug) {
110
+ const meta = { slug, name: slug === DEFAULT_BOARD ? 'Default' : slug, description: '', icon: '', color: '', default_workdir: null, project_id: null, created_at: null, archived: false };
111
+ try { Object.assign(meta, JSON.parse(readFileSync(metaPath(root, slug), 'utf8')), { slug }); } catch { /* no metadata yet */ }
112
+ return { ...meta, db_path: boardPath(root, slug) };
113
+ }
114
+
115
+ function writeMeta(root, slug, patch) {
116
+ const { db_path: _, ...meta } = boardMeta(root, slug);
117
+ for (const [k, v] of Object.entries(patch)) if (v !== undefined) meta[k] = v;
118
+ meta.created_at ??= now();
119
+ mkdirSync(boardDir(root, slug), { recursive: true });
120
+ writeFileSync(metaPath(root, slug), `${JSON.stringify(meta, null, 2)}\n`);
121
+ return { ...meta, db_path: boardPath(root, slug) };
122
+ }
123
+
124
+ /** Hermes's list: the default board and every named one with a database or metadata. */
125
+ function boardsOf(root, { includeArchived = true } = {}) {
126
+ const out = [boardMeta(root, DEFAULT_BOARD)];
127
+ const dir = join(root, 'kanban', 'boards');
128
+ if (existsSync(dir)) {
129
+ for (const slug of readdirSync(dir).sort()) {
130
+ if (slug === DEFAULT_BOARD || !SLUG.test(slug)) continue;
131
+ if (!existsSync(boardPath(root, slug)) && !existsSync(metaPath(root, slug))) continue;
132
+ const meta = boardMeta(root, slug);
133
+ if (meta.archived && !includeArchived) continue;
134
+ out.push(meta);
135
+ }
136
+ }
137
+ return out;
138
+ }
139
+
140
+ function counts(root, slug) {
141
+ if (!existsSync(boardPath(root, slug))) return {};
142
+ const b = openBoard(root, slug);
143
+ try {
144
+ return Object.fromEntries(b.db.prepare('SELECT status, COUNT(*) AS n FROM tasks GROUP BY status').all().map((r) => [r.status, Number(r.n)]));
145
+ } finally { b.close(); }
146
+ }
147
+
67
148
  /** The home's lanes (profiles' workers) and board settings (the root config's `kanban:` block), as the IR reads them. */
68
149
  async function laneState(root) {
69
150
  const orchestration = await read(root);
70
151
  const kanban = orchestration.profiles?.default?.residue?.config?.kanban ?? {};
71
- return { lanes: lanesOf(orchestration.profiles), settings: boardSettings(kanban), kanban };
152
+ return { lanes: lanesOf(orchestration.profiles), settings: boardSettings(kanban), kanban, profiles: Object.keys(orchestration.profiles ?? {}) };
72
153
  }
73
154
 
74
155
  /** The caller's own supercode address, when it is a session supercode can reach (for its card's notices). */
@@ -79,22 +160,56 @@ function callerAddress() {
79
160
  return [...fleet.alive].find((a) => a.endsWith(`:${id}`)) ?? null;
80
161
  }
81
162
 
82
- const say = (args, human, value) => { process.stdout.write(args.json ? `${JSON.stringify(value)}\n` : `${human}\n`); };
163
+ const callerName = () => process.env.HERMES_PROFILE || process.env.USER || 'user';
83
164
 
84
- function row(t) {
85
- return `${t.id} ${t.status.padEnd(9)} ${(t.assignee ?? '-').padEnd(14)} ${(t.machine ?? '-').padEnd(20)} ${t.title}`;
165
+ const say = (args, human, value) => { process.stdout.write(args.json ? `${JSON.stringify(value, null, 2)}\n` : `${human}\n`); };
166
+
167
+ // Hermes's own output lines, so a reader of `hermes kanban list` reads this one
168
+ const ICONS = { todo: '◻', ready: '▶', running: '●', scheduled: '⏱', blocked: '⊘', done: '✓', archived: '—' };
169
+ const ts = (t) => {
170
+ if (!t) return '';
171
+ const d = new Date(Number(t) * 1000);
172
+ const p = (n) => String(n).padStart(2, '0');
173
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
174
+ };
175
+ const line = (t) => `${ICONS[t.status] ?? '?'} ${t.id} ${t.status.padEnd(8)} ${(t.assignee || '(unassigned)').padEnd(20)}${t.tenant ? ` [${t.tenant}]` : ''} ${t.title}${t.machine ? ` @${t.machine}` : ''}`;
176
+ const cardDict = (t) => ({
177
+ id: t.id, title: t.title, body: t.body, assignee: t.assignee, status: t.status, priority: t.priority, tenant: t.tenant,
178
+ workspace_kind: t.workspace_kind, workspace_path: t.workspace_path, branch_name: t.branch_name, project_id: t.project_id,
179
+ created_by: t.created_by, created_at: t.created_at, started_at: t.started_at, completed_at: t.completed_at, result: t.result,
180
+ skills: t.skills ?? [], max_retries: t.max_retries, model_override: t.model_override, provider_override: t.provider_override,
181
+ session_id: t.session_id, workflow_template_id: t.workflow_template_id, current_step_key: t.current_step_key,
182
+ machine: t.machine ?? null, subtask_of: t.subtask_of ?? null,
183
+ });
184
+
185
+ /** Hermes's durations: seconds, or 30s / 5m / 2h / 1d. */
186
+ function duration(v) {
187
+ if (v === undefined || v === null || v === '') return null;
188
+ const s = String(v).trim().toLowerCase();
189
+ if (/^\d+$/.test(s)) return Number(s);
190
+ const m = /^(\d+(?:\.\d+)?)([smhd])$/.exec(s);
191
+ if (!m) throw new Error(`malformed duration ${v} (expected 30s, 5m, 2h, 1d, or a number)`);
192
+ return Math.floor(Number(m[1]) * { s: 1, m: 60, h: 3600, d: 86400 }[m[2]]);
86
193
  }
87
194
 
195
+ const metadataOf = (v) => {
196
+ if (v === undefined) return undefined;
197
+ const got = json(v, undefined);
198
+ if (got === undefined || typeof got !== 'object') throw new Error('--metadata is a JSON object');
199
+ return got;
200
+ };
201
+
88
202
  export async function runWorkflow(argv) {
89
203
  const args = parse(argv);
90
204
  const [verb, ...rest] = args._;
91
205
  if (!verb || verb === 'help' || args.help) { process.stdout.write(HELP); return 0; }
92
206
  const root = homeOf(args);
93
- const slug = args.board ?? DEFAULT_BOARD;
207
+ const slug = boardOf(root, args);
94
208
  const open = (create = false) => { const b = openBoard(root, slug, { create }); b.label = boardLabel(root, slug); return b; };
95
209
  const withBoard = (fn, create = false) => { const b = open(create); try { return fn(b); } finally { b.close(); } };
96
210
  const id = rest[0];
97
211
  const need = (n, what) => { if (rest.length < n) throw new Error(`${verb} needs ${what}`); };
212
+ const ids = (first = rest) => [...first, ...(args.ids ?? [])];
98
213
 
99
214
  // a caller that is a card's own session (its harness names it; the board recorded which session it launched)
100
215
  const sid = process.env.CLAUDE_CODE_SESSION_ID || process.env.CODEX_THREAD_ID;
@@ -103,34 +218,34 @@ export async function runWorkflow(argv) {
103
218
  }
104
219
 
105
220
  switch (verb) {
106
- case 'close': {
107
- need(1, 'a card id');
108
- const closed = withBoard((b) => closeSession(b, id));
109
- say(args, closed ? `closed ${closed.session_id} (pane ${closed.pane} on ${closed.machine})` : `${id} has no live session`, { ok: true, closed });
221
+ case 'init': {
222
+ withBoard(() => {}, true);
223
+ const { profiles } = await laneState(root).catch(() => ({ profiles: [] }));
224
+ say(args, `Kanban DB initialized at ${boardPath(root, slug)}\n\n${profiles.length ? `Profiles of this home (any can be an --assignee): ${profiles.join(', ')}` : 'No profiles in this home.'}\nThe dispatcher: \`supercode workflow serve\`, or the orchestrator while it serves the home.`, { ok: true, board: slug, path: boardPath(root, slug), profiles });
110
225
  return 0;
111
226
  }
112
- case 'attach': {
113
- need(2, 'a card id and a session');
114
- const { settings } = await laneState(root);
115
- const got = withBoard((b) => attachSession(b, id, rest[1], { workspace: args.workspace ?? null, ttl: settings.ttl }));
116
- say(args, `${id} is worked by ${got.address} (pane ${got.pane ?? '?'} in ${got.workspace ?? 'its card\'s directory'})`, { ok: true, ...got });
117
- return 0;
118
- }
119
- case 'init': withBoard(() => {}, true); say(args, `board ${slug} ready in ${root}`, { ok: true, board: slug }); return 0;
227
+
228
+ case 'boards': return boardsVerb(root, rest, args);
229
+
120
230
  case 'create': {
121
231
  need(1, 'a title');
122
232
  const { lanes, settings } = await laneState(root);
233
+ const maxRetries = args['max-retries'] !== undefined ? Number(args['max-retries']) : null;
234
+ 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`);
235
+ const meta = boardMeta(root, slug);
236
+ let workspace = args.workspace;
237
+ if ((workspace === 'dir' || workspace === 'worktree') && meta.default_workdir) workspace = `${workspace}:${meta.default_workdir}`;
123
238
  const created = withBoard((b) => engine.createCard(b, {
124
239
  title: rest.join(' '), body: args.body, assignee: args.assignee === 'none' ? null : args.assignee, machine: args.machine,
125
- workspace: args.workspace, branch: args.branch, parents: args.parent, priority: args.priority, tenant: args.tenant,
126
- triage: args.triage, idempotencyKey: args['idempotency-key'], maxRuntime: args['max-runtime'] ? Number(args['max-runtime']) : null,
127
- maxRetries: args['max-retries'] ? Number(args['max-retries']) : null, skills: args.skill, model: args.model,
128
- provider: args.provider, createdBy: args['created-by'] ?? callerName(), sessionId: process.env.CLAUDE_CODE_SESSION_ID ?? null,
129
- subtaskOf: args['subtask-of'] ?? null,
240
+ workspace, branch: args.branch, parents: args.parent, priority: args.priority, tenant: args.tenant,
241
+ triage: args.triage, idempotencyKey: args['idempotency-key'], maxRuntime: duration(args['max-runtime']),
242
+ maxRetries, skills: args.skill, model: args.model, provider: args.provider, createdBy: args['created-by'] ?? callerName(),
243
+ sessionId: process.env.CLAUDE_CODE_SESSION_ID ?? null, subtaskOf: args['subtask-of'] ?? null, initialStatus: args['initial-status'] ?? null,
244
+ project: args.project ?? meta.project_id ?? null, goal: args.goal ? { maxTurns: args['goal-max-turns'] ? Number(args['goal-max-turns']) : null } : null,
130
245
  }), true);
131
246
  let answer = { id: created.id, existing: created.existing };
132
247
  if (!args['no-start'] && !created.existing && !args['subtask-of']) {
133
- const [report] = tick({ root, lanes, settings, only: created.id, slugs: [slug] });
248
+ const [report] = await tick({ root, lanes, settings, only: created.id, slugs: [slug] });
134
249
  const started = report.started.find(([t]) => t === created.id);
135
250
  const queued = report.queued.find(([t]) => t === created.id);
136
251
  const failed = report.failed.find(([t]) => t === created.id);
@@ -139,115 +254,691 @@ export async function runWorkflow(argv) {
139
254
  // the creator hears what happens from here on; a start answered above is not told twice
140
255
  const address = args['no-notify'] ? null : args.notify ?? callerAddress();
141
256
  if (address) withBoard((b) => engine.subscribe(b, created.id, address, 'creator'));
142
- const status = withBoard((b) => tx(b, (db) => engine.getTask(db, created.id)));
143
- answer = { ...answer, status: status.status, notify: address ?? null };
144
- const line = answer.started ? `started ${created.id}: session ${answer.session} (${answer.mode})`
145
- : answer.failed ? `${created.id} did not start: ${answer.failed}`
146
- : answer.waits ? `${created.id} waits: ${answer.waits}`
147
- : `${created.existing ? 'existing' : 'created'} ${created.id} (${status.status})`;
148
- say(args, `${line}${address ? `; notices go to ${address}` : ''}`, answer);
257
+ const task = withBoard((b) => tx(b, (db) => engine.getTask(db, created.id)));
258
+ if (args.json) { say(args, '', { ...cardDict(task), ...answer, notify: address ?? null }); return answer.failed ? 1 : 0; }
259
+ const said = answer.started ? `; started session ${answer.session} (${answer.mode})` : answer.failed ? `; did not start: ${answer.failed}` : answer.waits ? `; waits: ${answer.waits}` : '';
260
+ say(args, `${created.existing ? 'Existing' : 'Created'} ${created.id} (${task.status}, assignee=${task.assignee || '-'})${said}${address ? `; notices go to ${address}` : ''}`, null);
149
261
  return answer.failed ? 1 : 0;
150
262
  }
151
- case 'list': {
152
- const cards = withBoard((b) => engine.listCards(b.db, { status: args.status, assignee: args.assignee, machine: args.machine, includeArchived: args.all }));
153
- if (args.json) say(args, '', cards); else process.stdout.write(`${cards.map(row).join('\n')}${cards.length ? '\n' : ''}`);
263
+
264
+ case 'swarm': {
265
+ need(1, 'a goal');
266
+ const workers = (args.worker ?? []).map((w) => {
267
+ const [profile, title, skills] = w.split(':');
268
+ if (!profile || !title) throw new Error(`--worker is PROFILE:TITLE[:SKILL,SKILL], not ${w}`);
269
+ return { profile, title, skills: skills ? skills.split(',').map((x) => x.trim()).filter(Boolean) : [] };
270
+ });
271
+ if (!args.verifier || !args.synthesizer) throw new Error('a swarm names its --verifier and --synthesizer');
272
+ const made = withBoard((b) => engine.swarm(b, { goal: rest.join(' '), workers, verifier: args.verifier, synthesizer: args.synthesizer, tenant: args.tenant ?? null, createdBy: args['created-by'] ?? callerName(), priority: Number(args.priority) || 0, idempotencyKey: args['idempotency-key'] ?? null }), true);
273
+ say(args, `Swarm root: ${made.root_id}\nWorkers: ${(made.worker_ids ?? []).join(', ')}\nVerifier: ${made.verifier_id}\nSynthesizer: ${made.synthesizer_id}`, made);
154
274
  return 0;
155
275
  }
276
+
277
+ case 'list': case 'ls': {
278
+ const assignee = args.assignee ?? (args.mine ? callerName() : null);
279
+ const cards = withBoard((b) => {
280
+ engine.recomputeReady(b);
281
+ let rows = engine.listCards(b.db, { status: args.status, assignee, machine: args.machine, includeArchived: args.archived || args.all });
282
+ if (args.tenant) rows = rows.filter((t) => t.tenant === args.tenant);
283
+ if (args.session) rows = rows.filter((t) => t.session_id === args.session);
284
+ if (args['workflow-template-id']) rows = rows.filter((t) => t.workflow_template_id === args['workflow-template-id']);
285
+ if (args['step-key']) rows = rows.filter((t) => t.current_step_key === args['step-key']);
286
+ return rows;
287
+ });
288
+ const sorters = {
289
+ assignee: (a, b) => String(a.assignee ?? '').localeCompare(String(b.assignee ?? '')), created: (a, b) => a.created_at - b.created_at,
290
+ 'created-desc': (a, b) => b.created_at - a.created_at, priority: (a, b) => a.priority - b.priority, 'priority-desc': (a, b) => b.priority - a.priority,
291
+ status: (a, b) => a.status.localeCompare(b.status), title: (a, b) => a.title.localeCompare(b.title),
292
+ updated: (a, b) => (b.completed_at ?? b.started_at ?? b.created_at) - (a.completed_at ?? a.started_at ?? a.created_at),
293
+ };
294
+ if (args.sort) { if (!sorters[args.sort]) throw new Error(`--sort is one of ${Object.keys(sorters).join(', ')}`); cards.sort(sorters[args.sort]); }
295
+ if (args.json) { say(args, '', cards.map(cardDict)); return 0; }
296
+ const others = boardsOf(root, { includeArchived: false }).length - 1;
297
+ if (others > 0) process.stdout.write(`Board: ${slug} (${others} other board${others === 1 ? '' : 's'} — \`supercode workflow boards list\`)\n\n`);
298
+ process.stdout.write(cards.length ? `${cards.map(line).join('\n')}\n` : '(no matching tasks)\n');
299
+ return 0;
300
+ }
301
+
156
302
  case 'show': {
157
303
  need(1, 'a card id');
158
- const card = withBoard((b) => engine.showCard(b.db, id));
159
- if (args.json) { say(args, '', card); return 0; }
304
+ const { settings } = await laneState(root).catch(() => ({ settings: boardSettings({}) }));
305
+ const card = withBoard((b) => {
306
+ const got = engine.showCard(b.db, id);
307
+ got.runs = engine.runs(b.db, id, { stateType: args['state-type'] ?? null, stateName: args['state-name'] ?? null });
308
+ got.diagnostics = engine.diagnose(b.db, got.task, { failureLimit: settings.failureLimit });
309
+ return got;
310
+ });
160
311
  const t = card.task;
161
- const lines = [row(t), t.body ? `\n${t.body}\n` : ''];
162
- if (card.parents.length) lines.push(`parents: ${card.parents.map((p) => `${p.id} (${p.status})`).join(', ')}`);
163
- if (card.children.length) lines.push(`children: ${card.children.join(', ')}`);
164
- if (t.subtask_of) lines.push(`subtask of ${t.subtask_of}`);
165
- for (const st of card.subtasks) lines.push(`subtask ${st.id} [${st.status}] ${st.title}`);
166
- for (const r of card.runs) {
167
- const s = r.metadata?.supercode;
168
- const who = s ? (s.session_id ? `session ${s.session_id}${s.pane ? ` in pane ${s.pane}` : ''}` : `worker pid ${s.pid}`) : '';
169
- lines.push(`run ${r.id}: ${r.status}${r.outcome ? ` / ${r.outcome}` : ''}${s ? ` — ${who} on ${s.machine} (${s.mode})` : ''}${r.summary ? ` — ${r.summary.split('\n')[0]}` : ''}`);
312
+ const latest = [...card.runs].reverse().find((r) => r.summary)?.summary ?? null;
313
+ if (args.json) {
314
+ say(args, '', { task: cardDict(t), latest_summary: latest, parents: card.parents.map((p) => p.id), children: card.children, subtasks: card.subtasks, comments: card.comments, events: card.events, runs: card.runs, subscriptions: card.subscriptions, diagnostics: card.diagnostics });
315
+ return 0;
316
+ }
317
+ const out = [`Task ${t.id}: ${t.title}`, ` status: ${t.status}`, ` assignee: ${t.assignee || '-'}`];
318
+ if (t.machine) out.push(` machine: ${t.machine}`);
319
+ if (t.subtask_of) out.push(` subtask of: ${t.subtask_of}`);
320
+ if (t.tenant) out.push(` tenant: ${t.tenant}`);
321
+ out.push(` workspace: ${t.workspace_kind}${t.workspace_path ? ` @ ${t.workspace_path}` : ''}`);
322
+ if (t.branch_name) out.push(` branch: ${t.branch_name}`);
323
+ if (t.skills?.length) out.push(` skills: ${t.skills.join(', ')}`);
324
+ if (t.model_override) out.push(` model: ${t.model_override}${t.provider_override ? ` (provider: ${t.provider_override})` : ''}`);
325
+ out.push(` max-retries: ${t.max_retries ?? settings.failureLimit} (${t.max_retries !== null ? 'task' : 'board'})`);
326
+ out.push(` created: ${ts(t.created_at)} by ${t.created_by || '-'}`);
327
+ if (card.diagnostics.length) {
328
+ out.push('', ` Diagnostics (${card.diagnostics.length}):`);
329
+ for (const d of card.diagnostics) { out.push(` ${{ warning: '⚠', error: '!!', critical: '!!!' }[d.severity]} [${d.severity}] ${d.title}`); for (const a of d.actions.slice(0, 1)) out.push(` → ${a}`); }
170
330
  }
171
- for (const c of card.comments) lines.push(`${c.author}: ${c.body}`);
172
- for (const s of card.subscriptions) lines.push(`notices → ${s.chat_id} (${s.delivery_metadata?.role ?? s.platform})`);
173
- process.stdout.write(`${lines.filter(Boolean).join('\n')}\n`);
331
+ if (t.started_at) out.push(` started: ${ts(t.started_at)}`);
332
+ if (t.completed_at) out.push(` completed: ${ts(t.completed_at)}`);
333
+ if (card.parents.length) out.push(` parents: ${card.parents.map((p) => p.id).join(', ')}`);
334
+ if (card.children.length) out.push(` children: ${card.children.join(', ')}`);
335
+ if (card.subtasks.length) out.push(` subtasks: ${card.subtasks.map((s) => `${s.id} (${s.status})`).join(', ')}`);
336
+ if (t.body) out.push('', 'Body:', t.body);
337
+ if (t.result) out.push('', 'Result:', t.result);
338
+ else if (latest) out.push('', 'Latest summary:', latest);
339
+ if (card.comments.length) { out.push('', `Comments (${card.comments.length}):`); for (const c of card.comments) out.push(` [${ts(c.created_at)}] ${c.author}: ${c.body}`); }
340
+ if (card.events.length) {
341
+ out.push('', `Events (${card.events.length}):`);
342
+ for (const e of card.events.slice(-20)) out.push(` [${ts(e.created_at)}]${e.run_id ? ` [run ${e.run_id}]` : ''} ${e.kind}${e.payload ? ` ${JSON.stringify(e.payload)}` : ''}`);
343
+ }
344
+ if (card.runs.length) {
345
+ out.push('', `Runs (${card.runs.length}):`);
346
+ for (const r of card.runs) {
347
+ const s = r.metadata?.supercode;
348
+ out.push(` #${String(r.id).padEnd(3)} ${(r.outcome || r.status || 'active').padEnd(12)} @${r.profile || '-'} ${r.ended_at ? `${Math.max(0, r.ended_at - r.started_at)}s` : 'active'} ${ts(r.started_at)}`);
349
+ if (s) out.push(` ⇢ ${s.session_id ? `session ${s.session_id}${s.pane ? ` in pane ${s.pane}` : ''}` : `worker pid ${s.pid}`} on ${s.machine} (${s.mode})`);
350
+ if (r.summary) out.push(` → ${r.summary.split('\n')[0].slice(0, 160)}`);
351
+ if (r.error) out.push(` ! ${r.error.split('\n')[0].slice(0, 160)}`);
352
+ }
353
+ }
354
+ if (card.subscriptions.length) { out.push('', 'Notices:'); for (const s of card.subscriptions) out.push(` → ${s.chat_id} (${s.platform === 'supercode' ? s.delivery_metadata?.role ?? 'creator' : s.platform})`); }
355
+ process.stdout.write(`${out.join('\n')}\n`);
174
356
  return 0;
175
357
  }
358
+
176
359
  case 'context': need(1, 'a card id'); process.stdout.write(`${withBoard((b) => tx(b, (db) => engine.cardContext(db, id)))}\n`); return 0;
360
+
361
+ case 'runs': {
362
+ need(1, 'a card id');
363
+ const got = withBoard((b) => engine.runs(b.db, id, { stateType: args['state-type'] ?? null, stateName: args['state-name'] ?? null }));
364
+ if (args.json) { say(args, '', got); return 0; }
365
+ process.stdout.write(got.length ? `${got.map((r) => `#${r.id} ${(r.outcome || r.status).padEnd(14)} @${r.profile || '-'} ${ts(r.started_at)}${r.ended_at ? ` → ${ts(r.ended_at)}` : ' (active)'}${r.metadata?.supercode?.address ? ` ${r.metadata.supercode.address}` : ''}${r.summary ? `\n → ${r.summary.split('\n')[0].slice(0, 160)}` : ''}`).join('\n')}\n` : `(no runs for ${id})\n`);
366
+ return 0;
367
+ }
368
+
369
+ case 'assign': need(2, 'a card id and a profile (or none)'); withBoard((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;
370
+ case 'reassign': {
371
+ need(2, 'a card id and a profile');
372
+ withBoard((b) => {
373
+ const task = tx(b, (db) => engine.getTask(db, id));
374
+ if (task?.status === 'running' && args.reclaim) engine.reclaim(b, id, args.reason ?? 'reassign');
375
+ engine.assign(b, id, rest[1] === 'none' ? null : rest[1]);
376
+ });
377
+ say(args, `Reassigned ${id} to ${rest[1]}`, { ok: true }); return 0;
378
+ }
379
+ case 'set-model': need(1, 'a card id'); withBoard((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;
380
+ case 'move': need(2, 'a card id and a machine (or none)'); withBoard((b) => engine.setMachine(b, id, rest[1] === 'none' ? null : rest[1])); say(args, `${id} runs on ${rest[1]}`, { ok: true }); return 0;
381
+
382
+ case 'claim': {
383
+ need(1, 'a card id');
384
+ const ttl = args.ttl ? duration(args.ttl) : engine.claimTtl();
385
+ const runId = withBoard((b) => engine.claim(b, id, { ttl }));
386
+ if (!runId) throw new Error(`cannot claim ${id} (not ready, already claimed, or waiting on parents)`);
387
+ const task = withBoard((b) => tx(b, (db) => engine.getTask(db, id)));
388
+ // Hermes's claim resolves the workspace and prints it; a scratch one is made under the board's workspaces
389
+ let workspace = task.workspace_path;
390
+ if (!workspace && task.workspace_kind === 'scratch') {
391
+ workspace = join(slug === DEFAULT_BOARD ? join(root, 'kanban', 'workspaces') : join(boardDir(root, slug), 'workspaces'), id);
392
+ mkdirSync(workspace, { recursive: true });
393
+ withBoard((b) => tx(b, (db) => db.prepare('UPDATE tasks SET workspace_path = ? WHERE id = ?').run(workspace, id)));
394
+ }
395
+ say(args, workspace ?? `(claimed ${id}; its ${task.workspace_kind} workspace is made where it starts)`, { ok: true, run_id: runId, workspace });
396
+ return 0;
397
+ }
398
+ case 'reclaim': need(1, 'a card id'); withBoard((b) => engine.reclaim(b, id, args.reason ?? (rest.slice(1).join(' ') || null))); say(args, `Reclaimed ${id}`, { ok: true }); return 0;
399
+ case 'heartbeat': need(1, 'a card id'); withBoard((b) => engine.heartbeat(b, id, args.note ?? null)); say(args, `Heartbeat recorded for ${id}`, { ok: true }); return 0;
400
+
401
+ case 'link': need(2, 'a parent and a child'); withBoard((b) => engine.link(b, rest[0], rest[1])); say(args, `Linked ${rest[0]} -> ${rest[1]}`, { ok: true }); return 0;
402
+ case 'unlink': { need(2, 'a parent and a child'); const gone = withBoard((b) => engine.unlink(b, rest[0], rest[1])); say(args, gone ? `Unlinked ${rest[0]} -> ${rest[1]}` : `no link ${rest[0]} -> ${rest[1]}`, { ok: gone }); return gone ? 0 : 1; }
403
+
177
404
  case 'comment': {
178
405
  need(2, 'a card id and the text');
179
- const n = withBoard((b) => engine.comment(b, id, args.author ?? callerAddress() ?? callerName(), rest.slice(1).join(' ')));
180
- say(args, `commented on ${id}`, { ok: true, comment: n }); return 0;
181
- }
182
- case 'complete': need(1, 'a card id'); withBoard((b) => engine.complete(b, id, { summary: args.summary ?? null, result: args.result ?? null })); say(args, `${id} done`, { ok: true }); return 0;
183
- case 'block': { need(1, 'a card id'); const s = withBoard((b) => engine.block(b, id, { reason: rest.slice(1).join(' ') || null, kind: args.kind ?? null })); say(args, `${id} ${s}`, { ok: true, status: s }); return 0; }
184
- case 'unblock': { need(1, 'a card id'); const s = withBoard((b) => engine.unblock(b, id)); say(args, `${id} ${s}`, { ok: true, status: s }); return 0; }
185
- case 'review': need(1, 'a card id'); withBoard((b) => engine.requestReview(b, id, { summary: args.summary ?? null, reviewer: args.reviewer ?? null })); say(args, `${id} in review`, { ok: true }); return 0;
186
- case 'changes': need(1, 'a card id'); withBoard((b) => engine.requestChanges(b, id, { reason: args.reason ?? null, reviewer: args.reviewer ?? null })); say(args, `${id} sent back`, { ok: true }); return 0;
187
- case 'schedule': need(1, 'a card id'); withBoard((b) => engine.schedule(b, id, rest.slice(1).join(' ') || null)); say(args, `${id} scheduled`, { ok: true }); return 0;
188
- case 'promote': need(1, 'a card id'); withBoard((b) => engine.promote(b, id, { actor: callerName(), force: args.force })); say(args, `${id} ready`, { ok: true }); return 0;
189
- case 'reclaim': need(1, 'a card id'); withBoard((b) => engine.reclaim(b, id, rest.slice(1).join(' ') || null)); say(args, `${id} reclaimed`, { ok: true }); return 0;
190
- case 'archive': need(1, 'a card id'); withBoard((b) => engine.archive(b, id)); say(args, `${id} archived`, { ok: true }); return 0;
191
- case 'assign': need(2, 'a card id and a profile (or none)'); withBoard((b) => engine.assign(b, id, rest[1] === 'none' ? null : rest[1])); say(args, `${id} → ${rest[1]}`, { ok: true }); return 0;
192
- case 'move': need(2, 'a card id and a machine (or none)'); withBoard((b) => engine.setMachine(b, id, rest[1] === 'none' ? null : rest[1])); say(args, `${id} runs on ${rest[1]}`, { ok: true }); return 0;
193
- case 'link': need(2, 'a parent and a child'); withBoard((b) => engine.link(b, rest[0], rest[1])); say(args, `${rest[1]} waits on ${rest[0]}`, { ok: true }); return 0;
194
- case 'unlink': need(2, 'a parent and a child'); withBoard((b) => engine.unlink(b, rest[0], rest[1])); say(args, `${rest[1]} no longer waits on ${rest[0]}`, { ok: true }); return 0;
195
- case 'subscribe': {
406
+ let text = rest.slice(1).join(' ');
407
+ if (args['max-len']) text = text.slice(0, Number(args['max-len']));
408
+ const n = withBoard((b) => engine.comment(b, id, args.author ?? callerAddress() ?? callerName(), text));
409
+ say(args, `Comment added to ${id}`, { ok: true, comment: n }); return 0;
410
+ }
411
+ case 'attach': {
412
+ need(2, 'a card id and a file');
413
+ const source = resolve(rest[1]);
414
+ if (!existsSync(source) || !statSync(source).isFile()) throw new Error(`no file ${rest[1]}`);
415
+ const name = basename(args.name ?? source).replace(/[^A-Za-z0-9._ -]/g, '_') || 'attachment';
416
+ const dir = join(slug === DEFAULT_BOARD ? join(root, 'kanban', 'attachments') : join(boardDir(root, slug), 'attachments'), id);
417
+ mkdirSync(dir, { recursive: true });
418
+ let target = join(dir, name);
419
+ for (let n = 1; existsSync(target); n += 1) target = join(dir, `${n}-${name}`);
420
+ copyFileSync(source, target);
421
+ const n = withBoard((b) => engine.addAttachment(b, id, { file: { path: target, size: statSync(target).size }, name, contentType: args['content-type'] ?? null, author: args.author ?? callerName() }));
422
+ say(args, `Attached ${name} to ${id} (#${n})`, { ok: true, id: n, path: target }); return 0;
423
+ }
424
+ case 'attachments': {
196
425
  need(1, 'a card id');
197
- const address = args.address ?? callerAddress();
198
- if (!address) throw new Error('whose notices? --address sc:<machine>:<harness>:<id>');
199
- withBoard((b) => engine.subscribe(b, id, address, args.role ?? 'creator'));
200
- say(args, `${address} hears ${id}`, { ok: true, address }); return 0;
426
+ const got = withBoard((b) => engine.attachments(b.db, id));
427
+ if (args.json) { say(args, '', got); return 0; }
428
+ process.stdout.write(got.length ? `${got.map((a) => `#${a.id} ${a.filename} ${a.size} bytes ${ts(a.created_at)} ${a.stored_path}`).join('\n')}\n` : `(no attachments on ${id})\n`);
429
+ return 0;
201
430
  }
202
- case 'unsubscribe': {
431
+ case 'attach-rm': {
432
+ need(1, 'an attachment id');
433
+ const row = withBoard((b) => engine.removeAttachment(b, Number(id)));
434
+ try { unlinkSync(row.stored_path); } catch { /* already gone */ }
435
+ say(args, `Removed attachment #${id} (${row.filename})`, { ok: true }); return 0;
436
+ }
437
+
438
+ case 'complete': {
203
439
  need(1, 'a card id');
204
- const address = args.address ?? callerAddress();
205
- const gone = withBoard((b) => engine.unsubscribe(b, id, address));
206
- say(args, gone ? `${address} no longer hears ${id}` : `${address} did not hear ${id}`, { ok: gone }); return 0;
440
+ const metadata = metadataOf(args.metadata);
441
+ for (const t of rest) withBoard((b) => engine.complete(b, t, { summary: args.summary ?? null, result: args.result ?? null, metadata: metadata ?? null }));
442
+ say(args, rest.map((t) => `Completed ${t}`).join('\n'), { ok: true, completed: rest }); return 0;
207
443
  }
444
+ case 'edit': {
445
+ need(1, 'a card id');
446
+ if (args.result === undefined) throw new Error('edit needs --result');
447
+ withBoard((b) => engine.editResult(b, id, { result: args.result, summary: args.summary ?? null, metadata: metadataOf(args.metadata) }));
448
+ say(args, `Edited ${id}`, { ok: true }); return 0;
449
+ }
450
+ case 'block': {
451
+ need(1, 'a card id');
452
+ const reason = rest.slice(1).join(' ') || null;
453
+ const results = [id, ...(args.ids ?? [])].map((t) => [t, withBoard((b) => engine.block(b, t, { reason, kind: args.kind ?? null }))]);
454
+ say(args, results.map(([t, s]) => `${s === 'todo' ? 'Waiting on dependencies' : s === 'triage' ? 'Routed to triage' : 'Blocked'} ${t}`).join('\n'), { ok: true, results }); return 0;
455
+ }
456
+ case 'schedule': {
457
+ need(1, 'a card id');
458
+ const reason = rest.slice(1).join(' ') || null;
459
+ for (const t of [id, ...(args.ids ?? [])]) withBoard((b) => engine.schedule(b, t, reason));
460
+ say(args, [id, ...(args.ids ?? [])].map((t) => `Scheduled ${t}`).join('\n'), { ok: true }); return 0;
461
+ }
462
+ case 'unblock': {
463
+ need(1, 'a card id');
464
+ const results = ids().map((t) => [t, withBoard((b) => engine.unblock(b, t))]);
465
+ say(args, results.map(([t, s]) => `Unblocked ${t} → ${s}`).join('\n'), { ok: true, results }); return 0;
466
+ }
467
+ case 'request-review': need(1, 'a card id'); withBoard((b) => engine.requestReview(b, id, { summary: args.summary ?? null, reviewer: args.reviewer ?? null, metadata: metadataOf(args.metadata) ?? null })); say(args, `${id} → review`, { ok: true }); return 0;
468
+ case 'request-changes': need(2, 'a card id and the reason'); withBoard((b) => engine.requestChanges(b, id, { reason: rest.slice(1).join(' '), reviewer: args.reviewer ?? callerName() })); say(args, `${id} sent back for changes`, { ok: true }); return 0;
469
+ case 'reopen-review': {
470
+ need(1, 'a card id');
471
+ const results = ids().map((t) => [t, withBoard((b) => engine.reopenReview(b, t))]);
472
+ say(args, results.map(([t, s]) => `Reopened ${t} → ${s}`).join('\n'), { ok: true, results }); return 0;
473
+ }
474
+ case 'promote': {
475
+ need(1, 'a card id');
476
+ const reason = rest.slice(1).join(' ') || null;
477
+ const targets = [id, ...(args.ids ?? [])];
478
+ if (args['dry-run']) {
479
+ const verdicts = withBoard((b) => targets.map((t) => {
480
+ const card = engine.showCard(b.db, t);
481
+ const open = card.parents.filter((p) => !['done', 'archived'].includes(p.status)).map((p) => p.id);
482
+ const ok = ['todo', 'blocked'].includes(card.task.status) && (!open.length || args.force);
483
+ return { id: t, ok, reason: ok ? null : !['todo', 'blocked'].includes(card.task.status) ? `${t} is ${card.task.status}` : `parents not done: ${open.join(', ')}` };
484
+ }));
485
+ say(args, verdicts.map((v) => `${v.ok ? 'would promote' : 'would refuse'} ${v.id}${v.reason ? `: ${v.reason}` : ''}`).join('\n'), verdicts);
486
+ return 0;
487
+ }
488
+ for (const t of targets) withBoard((b) => engine.promote(b, t, { actor: callerName(), reason, force: args.force }));
489
+ say(args, targets.map((t) => `Promoted ${t} → ready`).join('\n'), { ok: true }); return 0;
490
+ }
491
+ case 'archive': {
492
+ for (const t of rest) withBoard((b) => engine.archive(b, t));
493
+ const purged = [];
494
+ for (const t of args.rm ?? []) {
495
+ withBoard((b) => tx(b, (db) => {
496
+ const task = engine.getTask(db, t);
497
+ if (!task) throw new Error(`no card ${t}`);
498
+ if (task.status !== 'archived') throw new Error(`${t} is ${task.status}; only an archived card is deleted`);
499
+ for (const table of ['task_links']) db.prepare(`DELETE FROM ${table} WHERE parent_id = ? OR child_id = ?`).run(t, t);
500
+ for (const table of ['task_comments', 'task_events', 'task_runs', 'task_attachments', 'kanban_notify_subs', 'supercode_cards']) db.prepare(`DELETE FROM ${table} WHERE task_id = ?`).run(t);
501
+ db.prepare('DELETE FROM tasks WHERE id = ?').run(t);
502
+ }));
503
+ purged.push(t);
504
+ }
505
+ say(args, [...rest.map((t) => `Archived ${t}`), ...purged.map((t) => `Deleted ${t}`)].join('\n'), { ok: true, archived: rest, deleted: purged }); return 0;
506
+ }
507
+
508
+ case 'specify': case 'decompose': {
509
+ const { lanes, settings } = await laneState(root);
510
+ const targets = args.all ? withBoard((b) => engine.listCards(b.db, { status: 'triage' }).filter((t) => !args.tenant || t.tenant === args.tenant).map((t) => t.id)) : [id];
511
+ if (!targets[0]) throw new Error(`${verb} needs a card id, or --all`);
512
+ const results = [];
513
+ for (const t of targets) {
514
+ if (verb === 'specify' && (args.title !== undefined || args.body !== undefined)) {
515
+ withBoard((b) => engine.specify(b, t, { title: args.title ?? null, body: args.body ?? null, author: args.author ?? callerName() }));
516
+ results.push({ id: t, specified: true });
517
+ } else if (verb === 'decompose' && args.child?.length) {
518
+ const children = args.child.map((c) => { const [title, assignee] = c.split(/@(?=[^@]*$)/); return { title: title.trim(), assignee: assignee?.trim() || undefined }; });
519
+ results.push({ id: t, children: withBoard((b) => engine.decompose(b, t, children, { author: args.author ?? callerName() })) });
520
+ } else {
521
+ const session = withBoard((b) => startTriageSession(b, t, lanes, settings, verb));
522
+ results.push({ id: t, session: session.address, mode: session.mode });
523
+ }
524
+ }
525
+ say(args, results.map((r) => (r.specified ? `Specified ${r.id} → todo` : r.children ? `Decomposed ${r.id} into ${r.children.join(', ')}` : `${r.id}: session ${r.session} (${r.mode}) is writing its ${verb === 'specify' ? 'spec' : 'breakdown'}`)).join('\n'), results);
526
+ return 0;
527
+ }
528
+
529
+ case 'diagnostics': case 'diag': {
530
+ const { settings } = await laneState(root).catch(() => ({ settings: boardSettings({}) }));
531
+ const fleet = observeFleet();
532
+ const order = ['warning', 'error', 'critical'];
533
+ const found = withBoard((b) => {
534
+ const cards = args.task ? [engine.getTask(b.db, args.task)].filter(Boolean) : engine.listCards(b.db, {});
535
+ return cards.flatMap((t) => engine.diagnose(b.db, t, { failureLimit: settings.failureLimit, fleet }).map((d) => ({ task: t.id, title_of_task: t.title, ...d })))
536
+ .filter((d) => !args.severity || order.indexOf(d.severity) >= order.indexOf(args.severity));
537
+ });
538
+ if (args.json) { say(args, '', found); return 0; }
539
+ process.stdout.write(found.length ? `${found.map((d) => `${d.severity.padEnd(8)} ${d.task} ${d.kind}: ${d.title}${d.actions[0] ? `\n → ${d.actions[0]}` : ''}`).join('\n')}\n` : '(no active diagnostics)\n');
540
+ return 0;
541
+ }
542
+ case 'stats': {
543
+ const s = withBoard((b) => engine.stats(b.db));
544
+ if (args.json) { say(args, '', s); return 0; }
545
+ const fmt = (o) => Object.entries(o).sort().map(([k, v]) => `${k}=${v}`).join(', ') || '(none)';
546
+ process.stdout.write(`By status: ${fmt(s.by_status)}\nBy assignee:\n${Object.entries(s.by_assignee).map(([a, c]) => ` ${a.padEnd(20)} ${fmt(c)}`).join('\n') || ' (none)'}\nBy machine:\n${Object.entries(s.by_machine).map(([m, c]) => ` ${m.padEnd(20)} ${fmt(c)}`).join('\n') || ' (none)'}\nOldest ready: ${s.oldest_ready_age_seconds === null ? '-' : `${Math.floor(s.oldest_ready_age_seconds / 60)}m`}\n`);
547
+ return 0;
548
+ }
549
+ case 'assignees': {
550
+ const { profiles, lanes } = await laneState(root);
551
+ const c = withBoard((b) => engine.stats(b.db).by_assignee);
552
+ const data = [...new Set([...profiles, ...Object.keys(c)])].sort().map((name) => ({ name, on_disk: profiles.includes(name), surface: lanes[name]?.surface ?? null, counts: c[name] ?? {} }));
553
+ if (args.json) { say(args, '', data); return 0; }
554
+ process.stdout.write(`${'NAME'.padEnd(20)} ${'ON DISK'.padEnd(8)} ${'LANE'.padEnd(9)} COUNTS\n${data.map((e) => `${e.name.padEnd(20)} ${(e.on_disk ? 'yes' : 'no').padEnd(8)} ${(e.surface ?? '-').padEnd(9)} ${Object.entries(e.counts).sort().map(([k, v]) => `${k}=${v}`).join(', ') || '(idle)'}`).join('\n')}\n`);
555
+ return 0;
556
+ }
557
+ case 'log': {
558
+ need(1, 'a card id');
559
+ const file = join(slug === DEFAULT_BOARD ? join(root, 'kanban', 'logs') : join(boardDir(root, slug), 'logs'), `${id}.log`);
560
+ if (!existsSync(file)) {
561
+ // a pane session keeps no log file: its transcript is the harness's own, named by the run
562
+ const s = withBoard((b) => tx(b, (db) => engine.lastSession(db, id)));
563
+ process.stdout.write(s ? `(${id} runs as session ${s.address}; its transcript is that session's: \`supercode sessions show ${s.session_id}\`)\n` : `(no log for ${id})\n`);
564
+ return 0;
565
+ }
566
+ const text = readFileSync(file, 'utf8');
567
+ const tail = args.tail ? Number(args.tail) : null;
568
+ process.stdout.write(tail ? text.slice(-tail) : text);
569
+ return 0;
570
+ }
571
+ case 'tail': case 'watch': {
572
+ const interval = Math.max(Number(args.interval) || (verb === 'tail' ? 1 : 0.5), 0.2) * 1000;
573
+ const kinds = args.kinds ? new Set(String(args.kinds).split(',').map((k) => k.trim())) : null;
574
+ if (verb === 'tail') need(1, 'a card id');
575
+ let cursor = withBoard((b) => Number(b.db.prepare('SELECT COALESCE(MAX(id), 0) AS c FROM task_events').get().c));
576
+ if (verb === 'tail') cursor = 0;
577
+ let stop = false;
578
+ process.on('SIGINT', () => { stop = true; });
579
+ process.on('SIGTERM', () => { stop = true; });
580
+ while (!stop) {
581
+ const rows = withBoard((b) => b.db.prepare(`SELECT e.id, e.task_id, e.kind, e.payload, e.created_at, t.assignee, t.tenant FROM task_events e LEFT JOIN tasks t ON t.id = e.task_id WHERE e.id > ?${verb === 'tail' ? ' AND e.task_id = ?' : ''} ORDER BY e.id`).all(...(verb === 'tail' ? [cursor, id] : [cursor])));
582
+ for (const e of rows) {
583
+ cursor = e.id;
584
+ if (kinds && !kinds.has(e.kind)) continue;
585
+ if (args.assignee && e.assignee !== args.assignee) continue;
586
+ if (args.tenant && e.tenant !== args.tenant) continue;
587
+ process.stdout.write(`[${ts(e.created_at)}] ${verb === 'watch' ? `${e.task_id} ` : ''}${e.kind}${e.payload ? ` ${e.payload}` : ''}\n`);
588
+ }
589
+ await new Promise((done) => setTimeout(done, interval));
590
+ }
591
+ return 0;
592
+ }
593
+
594
+ case 'notify-subscribe': case 'notify-unsubscribe': {
595
+ need(1, 'a card id');
596
+ const platform = args.platform ?? 'supercode';
597
+ const chat = args['chat-id'] ?? args.address ?? (platform === 'supercode' ? callerAddress() : null);
598
+ if (!chat) throw new Error(`${verb} names the subscriber: --platform P --chat-id C (a supercode session's address with --platform supercode)`);
599
+ if (verb === 'notify-unsubscribe') {
600
+ const gone = withBoard((b) => tx(b, (db) => db.prepare('DELETE FROM kanban_notify_subs WHERE task_id = ? AND platform = ? AND chat_id = ? AND thread_id = ?').run(id, platform, chat, args['thread-id'] ?? '').changes > 0));
601
+ say(args, gone ? `Unsubscribed ${platform}:${chat} from ${id}` : `${platform}:${chat} was not subscribed to ${id}`, { ok: gone }); return gone ? 0 : 1;
602
+ }
603
+ withBoard((b) => tx(b, (db) => {
604
+ if (!engine.getTask(db, id)) throw new Error(`no card ${id}`);
605
+ if (platform === 'supercode') return engine.subscribeIn(db, id, chat, args.role ?? 'creator');
606
+ const cursor = db.prepare('SELECT COALESCE(MAX(id), 0) AS c FROM task_events WHERE task_id = ?').get(id).c;
607
+ db.prepare(`INSERT INTO kanban_notify_subs (task_id, platform, chat_id, thread_id, user_id, user_id_alt, chat_type, notifier_profile, delivery_mode, created_at, last_event_id)
608
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(task_id, platform, chat_id, thread_id) DO UPDATE SET chat_type = COALESCE(excluded.chat_type, chat_type), delivery_mode = excluded.delivery_mode`)
609
+ .run(id, platform, chat, args['thread-id'] ?? '', args['user-id'] ?? null, args['user-id-alt'] ?? null, args['chat-type'] ?? 'dm', args['notifier-profile'] ?? null, args['delivery-mode'] ?? 'notify', now(), cursor);
610
+ return null;
611
+ }));
612
+ say(args, `Subscribed ${platform}:${chat} to ${id}`, { ok: true }); return 0;
613
+ }
614
+ case 'notify-list': {
615
+ const subs = withBoard((b) => engine.subscriptions(b.db, id ?? null));
616
+ if (args.json) { say(args, '', subs); return 0; }
617
+ process.stdout.write(subs.length ? `${subs.map((s) => `${s.task_id} ${s.platform}:${s.chat_id}${s.thread_id ? `#${s.thread_id}` : ''} ${s.platform === 'supercode' ? s.delivery_metadata?.role ?? 'creator' : s.delivery_mode} cursor=${s.last_event_id}`).join('\n')}\n` : '(no subscriptions)\n');
618
+ return 0;
619
+ }
620
+
208
621
  case 'dispatch': {
209
622
  const { lanes, settings } = await laneState(root);
210
- const reports = tick({ root, lanes, settings });
623
+ if (args['failure-limit']) settings.failureLimit = Number(args['failure-limit']);
624
+ if (args.max) settings.maxInProgress = Number(args.max);
625
+ if (args['dry-run']) {
626
+ const would = withBoard((b) => engine.listCards(b.db, { status: 'ready' }).filter((t) => t.assignee && lanes[t.assignee] && !t.subtask_of).map((t) => ({ id: t.id, assignee: t.assignee, machine: t.machine, surface: lanes[t.assignee].surface })));
627
+ say(args, would.length ? would.map((w) => `would start ${w.id} as ${w.surface} for ${w.assignee}${w.machine ? ` on ${w.machine}` : ''}`).join('\n') : 'nothing would start', would);
628
+ return 0;
629
+ }
630
+ const reports = await tick({ root, lanes, settings });
211
631
  if (args.json) say(args, '', reports);
212
632
  else for (const r of reports) process.stdout.write(`${summary(r)}\n`);
213
633
  return reports.some((r) => r.error) ? 1 : 0;
214
634
  }
215
- case 'serve': return serve(root);
635
+ case 'daemon': case 'serve': return serve(root);
636
+
637
+ case 'gc': {
638
+ const eventDays = Number(args['event-retention-days'] ?? 30);
639
+ const logDays = Number(args['log-retention-days'] ?? 30);
640
+ let removedWs = 0;
641
+ const scratchRoot = slug === DEFAULT_BOARD ? join(root, 'kanban', 'workspaces') : join(boardDir(root, slug), 'workspaces');
642
+ const archived = withBoard((b) => b.db.prepare("SELECT id, workspace_kind, workspace_path FROM tasks WHERE status = 'archived'").all());
643
+ for (const t of archived) {
644
+ if (t.workspace_kind === 'worktree' && t.workspace_path && existsSync(t.workspace_path)) {
645
+ // an archived card's worktree goes, as Hermes's gc removes it (the branch stays)
646
+ const r = spawnSync('git', ['-C', t.workspace_path, 'worktree', 'remove', '--force', t.workspace_path], { encoding: 'utf8' });
647
+ if (r.status === 0) removedWs += 1;
648
+ continue;
649
+ }
650
+ if (t.workspace_kind !== 'scratch') continue;
651
+ const dir = resolve(t.workspace_path || join(scratchRoot, t.id));
652
+ if (!dir.startsWith(`${resolve(scratchRoot)}/`) || !existsSync(dir)) continue;
653
+ rmSync(dir, { recursive: true, force: true });
654
+ removedWs += 1;
655
+ }
656
+ const events = withBoard((b) => engine.gcEvents(b, eventDays * 86400));
657
+ let logs = 0;
658
+ const logDir = slug === DEFAULT_BOARD ? join(root, 'kanban', 'logs') : join(boardDir(root, slug), 'logs');
659
+ if (existsSync(logDir)) for (const f of readdirSync(logDir)) { const p = join(logDir, f); if (statSync(p).isFile() && statSync(p).mtimeMs < Date.now() - logDays * 86400_000) { unlinkSync(p); logs += 1; } }
660
+ say(args, `GC complete: ${removedWs} workspace(s), ${events} event row(s), ${logs} log file(s) removed`, { workspaces: removedWs, events, logs });
661
+ return 0;
662
+ }
663
+ case 'repair': {
664
+ const path = boardPath(root, slug);
665
+ if (!existsSync(path)) { say(args, `No kanban DB at ${path} — nothing to repair.`, { status: 'missing', db_path: path }); return 0; }
666
+ const report = withBoard((b) => {
667
+ const check = () => b.db.prepare('PRAGMA integrity_check').all().map((r) => Object.values(r)[0]);
668
+ const first = check();
669
+ if (first.length === 1 && first[0] === 'ok') return { status: 'ok', messages: first };
670
+ // a copy of the damaged file first, as Hermes keeps one, then the index rebuild
671
+ const backup = `${path}.corrupt-${now()}`;
672
+ b.db.prepare('VACUUM INTO ?').run(backup);
673
+ b.db.exec('REINDEX');
674
+ const after = check();
675
+ return { status: after.length === 1 && after[0] === 'ok' ? 'repaired' : 'corrupt', messages: first, post_repair_messages: after, reindexed: true, backup_path: backup };
676
+ });
677
+ say(args, `${report.status}: ${path}${report.status !== 'ok' ? `\n${report.messages.join('\n')}` : ''}`, { ...report, db_path: path });
678
+ return report.status === 'corrupt' ? 1 : 0;
679
+ }
680
+
681
+ case 'pause': {
682
+ // Hermes's emergency stop: new starts stop on every board of the home; running sessions go on
683
+ writeFileSync(join(root, 'ESTOP'), `${JSON.stringify({ engaged_at: new Date().toISOString(), reason: rest.join(' ') || null })}\n`);
684
+ say(args, `New starts paused (${join(root, 'ESTOP')}); \`supercode workflow resume\` lifts it`, { ok: true, paused: join(root, 'ESTOP') });
685
+ return 0;
686
+ }
687
+ case 'resume': {
688
+ const lifted = [join(root, 'ESTOP'), join(process.env.HOME ?? '', '.hermes', 'ESTOP')].filter((p) => existsSync(p));
689
+ for (const p of lifted) unlinkSync(p);
690
+ say(args, lifted.length ? `Resumed (removed ${lifted.join(', ')})` : 'Not paused', { ok: true, lifted });
691
+ return 0;
692
+ }
693
+ case 'adopt': {
694
+ need(2, 'a card id and a session');
695
+ const { settings } = await laneState(root);
696
+ const got = withBoard((b) => adoptSession(b, id, rest[1], { workspace: args.workspace ?? null, ttl: settings.ttl }));
697
+ say(args, `${id} is worked by ${got.address} (pane ${got.pane ?? '?'} in ${got.workspace ?? 'its card\'s directory'})`, { ok: true, ...got });
698
+ return 0;
699
+ }
700
+ case 'close': {
701
+ need(1, 'a card id');
702
+ const closed = withBoard((b) => closeSession(b, id));
703
+ say(args, closed ? `closed ${closed.session_id} (pane ${closed.pane} on ${closed.machine})` : `${id} has no live session`, { ok: true, closed });
704
+ return 0;
705
+ }
706
+
216
707
  default: throw new Error(`no workflow verb ${verb}; \`supercode workflow help\` lists them`);
217
708
  }
218
709
  }
219
710
 
711
+ // ------------------------------------------------------------------ boards
712
+
713
+ async function boardsVerb(root, rest, args) {
714
+ const [action = 'list', target] = rest;
715
+ switch (action) {
716
+ case 'list': case 'ls': {
717
+ const current = boardOf(root, {});
718
+ const boards = boardsOf(root, { includeArchived: args.all }).map((b) => ({ ...b, is_current: b.slug === current, counts: counts(root, b.slug) }));
719
+ if (args.json) { say(args, '', boards); return 0; }
720
+ process.stdout.write(`${''.padEnd(2)} ${'SLUG'.padEnd(24)} ${'NAME'.padEnd(28)} COUNTS\n${boards.map((b) => `${(b.is_current ? '●' : ' ').padEnd(2)} ${b.slug.padEnd(24)} ${`${b.name}${b.archived ? ' [archived]' : ''}`.padEnd(28)} ${Object.entries(b.counts).sort().map(([k, v]) => `${k}=${v}`).join(', ') || '(empty)'}`).join('\n')}\n\nCurrent board: ${current}\n`);
721
+ return 0;
722
+ }
723
+ case 'create': case 'new': {
724
+ const slug = normalSlug(target);
725
+ const meta = writeMeta(root, slug, { name: args.name, description: args.description, icon: args.icon, color: args.color, default_workdir: args['default-workdir'] });
726
+ openBoard(root, slug, { create: true }).close();
727
+ if (args.switch) { mkdirSync(join(root, 'kanban'), { recursive: true }); writeFileSync(currentFile(root), `${slug}\n`); }
728
+ say(args, `Board '${slug}' ready.\n Display name: ${meta.name}\n DB path: ${meta.db_path}${args.switch ? `\n Switched to '${slug}'.` : ''}`, meta);
729
+ return 0;
730
+ }
731
+ case 'rm': case 'remove': case 'delete': {
732
+ const slug = normalSlug(target);
733
+ if (slug === DEFAULT_BOARD) throw new Error("the 'default' board cannot be removed");
734
+ if (!existsSync(boardDir(root, slug))) throw new Error(`board '${slug}' does not exist`);
735
+ if (boardOf(root, {}) === slug) rmSync(currentFile(root), { force: true });
736
+ if (args.delete || action === 'delete') { rmSync(boardDir(root, slug), { recursive: true, force: true }); say(args, `Board '${slug}' deleted.`, { slug, action: 'deleted' }); return 0; }
737
+ const archive = join(root, 'kanban', 'boards', '_archived');
738
+ mkdirSync(archive, { recursive: true });
739
+ let dest = join(archive, `${slug}-${now()}`);
740
+ for (let n = 1; existsSync(dest); n += 1) dest = join(archive, `${slug}-${now()}-${n}`);
741
+ renameSync(boardDir(root, slug), dest);
742
+ say(args, `Board '${slug}' archived → ${dest}`, { slug, action: 'archived', new_path: dest });
743
+ return 0;
744
+ }
745
+ case 'switch': case 'use': {
746
+ const slug = normalSlug(target);
747
+ if (slug !== DEFAULT_BOARD && !existsSync(boardPath(root, slug))) throw new Error(`board '${slug}' does not exist; \`supercode workflow boards create ${slug}\``);
748
+ mkdirSync(join(root, 'kanban'), { recursive: true });
749
+ writeFileSync(currentFile(root), `${slug}\n`);
750
+ say(args, `Active board is now '${slug}'.`, { ok: true, board: slug });
751
+ return 0;
752
+ }
753
+ case 'show': case 'current': {
754
+ const slug = boardOf(root, args);
755
+ const meta = boardMeta(root, slug);
756
+ const c = counts(root, slug);
757
+ say(args, `Current board: ${slug}\n Display name: ${meta.name}${meta.description ? `\n Description: ${meta.description}` : ''}\n DB path: ${meta.db_path}\n Tasks: ${Object.values(c).reduce((a, b) => a + b, 0)} total${Object.keys(c).length ? ` (${Object.entries(c).sort().map(([k, v]) => `${k}=${v}`).join(', ')})` : ''}`, { ...meta, counts: c });
758
+ return 0;
759
+ }
760
+ case 'rename': {
761
+ const slug = normalSlug(target);
762
+ const meta = writeMeta(root, slug, { name: rest.slice(2).join(' ') || args.name });
763
+ say(args, `Board '${slug}' renamed to '${meta.name}'.`, meta); return 0;
764
+ }
765
+ case 'set-default-workdir': {
766
+ const slug = normalSlug(target);
767
+ const meta = writeMeta(root, slug, { default_workdir: rest[2] || null });
768
+ say(args, meta.default_workdir ? `Board '${slug}' default workdir set to '${meta.default_workdir}'.` : `Board '${slug}' default workdir cleared.`, meta); return 0;
769
+ }
770
+ case 'export': {
771
+ const slug = normalSlug(target ?? boardOf(root, args));
772
+ if (!existsSync(boardPath(root, slug))) throw new Error(`board '${slug}' has no database`);
773
+ const out = resolve(args.output ?? `${slug}.tar.gz`);
774
+ const made = exportBoard(root, slug, out, { attachments: !args['no-attachments'], logs: Boolean(args['include-logs']) });
775
+ say(args, `Exported board '${slug}' → ${out}\n Tasks: ${made.counts.tasks}\n Comments: ${made.counts.task_comments}\n Attachments: ${made.counts.attachment_files}`, made);
776
+ return 0;
777
+ }
778
+ case 'import': {
779
+ const made = importBoard(root, resolve(target ?? ''), args.as ?? null);
780
+ if (args.switch) writeFileSync(currentFile(root), `${made.board}\n`);
781
+ say(args, `Imported board '${made.board}' (${made.name}).${made.renamed ? `\n Renamed from '${made.requested_board}' — that slug was taken.` : ''}\n Path: ${made.path}\n Tasks: ${made.counts.tasks}${made.warnings.map((w) => `\n Note: ${w}`).join('')}`, made);
782
+ return 0;
783
+ }
784
+ default: throw new Error(`boards ${action}: list | create | rm | switch | show | rename | set-default-workdir | export | import`);
785
+ }
786
+ }
787
+
788
+ // ------------------------------------------------------------------ board archives, in Hermes's format
789
+
790
+ const ARCHIVE_FORMAT = 'hermes-kanban-board';
791
+ const TABLES = ['tasks', 'task_links', 'task_comments', 'task_events', 'task_runs', 'task_attachments'];
792
+
793
+ /**
794
+ * Strip what only means something on the machine that wrote it, as Hermes's transfer does: subscriptions, claims,
795
+ * pids, heartbeats, sessions, failure counters; a running card goes back to ready, its run is released. supercode's
796
+ * record of a run's session is such state too.
797
+ */
798
+ function scrub(db) {
799
+ db.exec('DELETE FROM kanban_notify_subs');
800
+ db.exec(`UPDATE tasks SET claim_lock = NULL, claim_expires = NULL, worker_pid = NULL, current_run_id = NULL, last_heartbeat_at = NULL,
801
+ session_id = NULL, project_id = NULL, consecutive_failures = 0, last_failure_error = NULL`);
802
+ db.exec("UPDATE tasks SET status = 'ready' WHERE status = 'running'");
803
+ db.prepare("UPDATE task_runs SET status = 'released', outcome = COALESCE(outcome, 'reclaimed'), ended_at = COALESCE(ended_at, ?), last_heartbeat_at = NULL WHERE status = 'running'").run(now());
804
+ db.exec('UPDATE task_runs SET claim_lock = NULL, worker_pid = NULL');
805
+ for (const r of db.prepare("SELECT id, metadata FROM task_runs WHERE metadata LIKE '%\"supercode\"%'").all()) {
806
+ const m = json(r.metadata, {}) ?? {};
807
+ delete m.supercode;
808
+ db.prepare('UPDATE task_runs SET metadata = ? WHERE id = ?').run(Object.keys(m).length ? JSON.stringify(m) : null, r.id);
809
+ }
810
+ }
811
+
812
+ const rowCounts = (db) => Object.fromEntries(TABLES.map((t) => [t, Number(db.prepare(`SELECT COUNT(*) AS n FROM ${t}`).get().n)]));
813
+
814
+ function copyTree(from, to) {
815
+ if (!existsSync(from)) return 0;
816
+ let n = 0;
817
+ for (const name of readdirSync(from)) {
818
+ const src = join(from, name);
819
+ if (statSync(src).isDirectory()) n += copyTree(src, join(to, name));
820
+ else { mkdirSync(to, { recursive: true }); copyFileSync(src, join(to, name)); n += 1; }
821
+ }
822
+ return n;
823
+ }
824
+
825
+ function exportBoard(root, slug, out, { attachments, logs }) {
826
+ const staging = join(tmpdir(), `board-export-${process.pid}-${now()}`);
827
+ const base = join(staging, slug);
828
+ mkdirSync(base, { recursive: true });
829
+ try {
830
+ const b = openBoard(root, slug);
831
+ try { b.db.prepare('VACUUM INTO ?').run(join(base, 'kanban.db')); } finally { b.close(); }
832
+ const snap = openBoard(base, DEFAULT_BOARD);
833
+ let counts;
834
+ try { tx(snap, (db) => scrub(db)); counts = rowCounts(snap.db); } finally { snap.close(); }
835
+ const { db_path: _, default_workdir: __, project_id: ___, ...meta } = boardMeta(root, slug);
836
+ writeFileSync(join(base, 'board.json'), `${JSON.stringify(meta, null, 2)}\n`);
837
+ const attachmentsDir = slug === DEFAULT_BOARD ? join(root, 'kanban', 'attachments') : join(boardDir(root, slug), 'attachments');
838
+ const logsDir = slug === DEFAULT_BOARD ? join(root, 'kanban', 'logs') : join(boardDir(root, slug), 'logs');
839
+ const files = attachments ? copyTree(attachmentsDir, join(base, 'attachments')) : 0;
840
+ const logFiles = logs ? copyTree(logsDir, join(base, 'logs')) : 0;
841
+ const manifest = { format: ARCHIVE_FORMAT, format_version: 1, board: slug, board_name: meta.name || slug, exported_at: now(), exported_by: 'supercode',
842
+ includes: { attachments, logs }, counts: { ...counts, attachment_files: files, log_files: logFiles } };
843
+ writeFileSync(join(base, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`);
844
+ const r = spawnSync('tar', ['-czf', out, '-C', staging, slug], { encoding: 'utf8' });
845
+ if (r.status !== 0) throw new Error(`tar: ${r.stderr.trim()}`);
846
+ return { board: slug, archive: out, size: statSync(out).size, counts: manifest.counts };
847
+ } finally {
848
+ rmSync(staging, { recursive: true, force: true });
849
+ }
850
+ }
851
+
852
+ function importBoard(root, archive, as) {
853
+ if (!existsSync(archive)) throw new Error(`archive not found: ${archive}`);
854
+ const staging = join(tmpdir(), `board-import-${process.pid}-${now()}`);
855
+ mkdirSync(staging, { recursive: true });
856
+ try {
857
+ const r = spawnSync('tar', ['-xzf', archive, '-C', staging], { encoding: 'utf8' });
858
+ if (r.status !== 0) throw new Error(`tar: ${r.stderr.trim()}`);
859
+ const tops = readdirSync(staging).filter((n) => statSync(join(staging, n)).isDirectory());
860
+ if (tops.length !== 1) throw new Error('a board archive holds exactly one top-level directory');
861
+ const extracted = join(staging, tops[0]);
862
+ const manifest = json(existsSync(join(extracted, 'manifest.json')) ? readFileSync(join(extracted, 'manifest.json'), 'utf8') : null, null);
863
+ if (manifest?.format !== ARCHIVE_FORMAT) throw new Error('the archive is not a Kanban board export (no manifest.json of format hermes-kanban-board)');
864
+ if (!existsSync(join(extracted, 'kanban.db'))) throw new Error('the archive is missing kanban.db');
865
+ const requested = normalSlug(as ?? manifest.board ?? tops[0]);
866
+ let slug = requested === DEFAULT_BOARD ? `${requested}-imported` : requested;
867
+ for (let n = 2; existsSync(boardDir(root, slug)); n += 1) slug = `${requested}-${n}`;
868
+ const dest = boardDir(root, slug);
869
+ mkdirSync(dest, { recursive: true });
870
+ renameSync(join(extracted, 'kanban.db'), join(dest, 'kanban.db'));
871
+ for (const tree of ['attachments', 'logs']) if (existsSync(join(extracted, tree))) renameSync(join(extracted, tree), join(dest, tree));
872
+ const staged = json(existsSync(join(extracted, 'board.json')) ? readFileSync(join(extracted, 'board.json'), 'utf8') : null, {}) ?? {};
873
+ const name = staged.name || manifest.board_name || slug;
874
+ writeMeta(root, slug, { name, description: staged.description ?? '', icon: staged.icon ?? '', color: staged.color ?? '', archived: false });
875
+ const warnings = [];
876
+ const b = openBoard(root, slug);
877
+ let counts;
878
+ try {
879
+ tx(b, (db) => {
880
+ scrub(db);
881
+ let dropped = 0;
882
+ for (const row of db.prepare('SELECT id, task_id, stored_path FROM task_attachments').all()) {
883
+ const landed = join(dest, 'attachments', row.task_id, basename(row.stored_path));
884
+ if (existsSync(landed)) db.prepare('UPDATE task_attachments SET stored_path = ? WHERE id = ?').run(landed, row.id);
885
+ else { db.prepare('DELETE FROM task_attachments WHERE id = ?').run(row.id); dropped += 1; }
886
+ }
887
+ if (dropped) warnings.push(`${dropped} attachment record(s) dropped — the files were not in the archive`);
888
+ const parked = db.prepare("SELECT id FROM tasks WHERE workspace_kind IN ('dir', 'worktree') AND status IN ('ready', 'running', 'todo', 'scheduled')").all().map((t) => t.id);
889
+ db.exec('UPDATE tasks SET workspace_path = NULL, branch_name = NULL');
890
+ for (const t of parked) { db.prepare("UPDATE tasks SET status = 'triage' WHERE id = ?").run(t); event(db, t, 'status', { status: 'triage', reason: 'imported: its directory means nothing on this machine' }); }
891
+ if (parked.length) warnings.push(`${parked.length} card(s) with a directory workspace parked in triage: the path means nothing here`);
892
+ });
893
+ counts = rowCounts(b.db);
894
+ } finally { b.close(); }
895
+ return { board: slug, requested_board: requested, renamed: slug !== requested, name, path: dest, db_path: boardPath(root, slug), counts, warnings };
896
+ } finally {
897
+ rmSync(staging, { recursive: true, force: true });
898
+ }
899
+ }
900
+
901
+ // ------------------------------------------------------------------ a card's own session
902
+
220
903
  /** The card whose latest session is `sessionId` (the caller works it), or null. */
221
904
  function ownCard(db, sessionId) {
222
- const hit = db.prepare("SELECT task_id FROM task_runs WHERE metadata LIKE ? ORDER BY id DESC LIMIT 1").get(`%"session_id":"${sessionId}"%`);
905
+ const hit = db.prepare('SELECT task_id FROM task_runs WHERE metadata LIKE ? ORDER BY id DESC LIMIT 1').get(`%"session_id":"${sessionId}"%`);
223
906
  if (!hit) return null;
224
907
  return engine.lastSession(db, hit.task_id)?.session_id === sessionId ? engine.getTask(db, hit.task_id) : null;
225
908
  }
226
909
 
227
- const READS = new Set(['list', 'show', 'context', 'help', 'init', 'dispatch']);
910
+ const READS = new Set(['list', 'ls', 'show', 'context', 'runs', 'log', 'tail', 'watch', 'stats', 'assignees', 'diagnostics', 'diag', 'attachments', 'notify-list', 'help', 'init']);
228
911
 
229
912
  /**
230
- * What a card's own session may write (the owner's rule): on its card, comment, block with a typed reason, and send
231
- * it to review; on its subtasks, create, complete, block, unblock and comment. Never its arc's completion, and never
232
- * another arc's card: anything across arcs goes through the fleet's manager.
913
+ * What a card's own session may write (the owner's rule): on its card, comment, block with a typed reason, heartbeat,
914
+ * request review, attach a file, specify or decompose it in triage; on its subtasks, file, complete, block, unblock,
915
+ * comment and attach. Never its arc's completion, and never another arc's card: anything across arcs goes through the
916
+ * fleet's manager.
233
917
  */
234
918
  function authorize(db, verb, rest, args, own) {
235
- if (READS.has(verb)) return;
236
919
  const refuse = (why) => { throw new Error(`the session working ${own.id} ${why}`); };
237
920
  if (verb === 'create') {
238
921
  if (args['subtask-of'] !== own.id) refuse(`files subtasks of its own card only (--subtask-of ${own.id})`);
239
922
  return;
240
923
  }
924
+ if (verb === 'boards') { if (!['list', 'ls', 'show', 'current', undefined].includes(rest[0])) refuse('does not change the home\'s boards'); return; }
925
+ // 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
926
+ if (['pause', 'resume', 'swarm', 'dispatch', 'daemon', 'serve', 'gc', 'repair', 'adopt', 'close', 'move', 'reassign', 'assign', 'set-model', 'claim', 'reclaim', 'archive', 'promote', 'schedule', 'link', 'unlink', 'edit', 'reopen-review', 'attach-rm'].includes(verb)) refuse(`does not ${verb}; the fleet's manager does`);
927
+ if (args.all) refuse(`does not ${verb} every card; only its own`);
241
928
  const card = engine.getTask(db, rest[0]);
242
929
  if (!card) return; // the verb itself answers "no card"
243
930
  const mine = card.id === own.id;
244
931
  const sub = card.subtask_of === own.id;
932
+ // a session started from the review lane is the card's reviewer: its verdict is its to give
933
+ const reviewing = mine && json(db.prepare("SELECT payload FROM task_events WHERE task_id = ? AND kind = 'claimed' ORDER BY id DESC LIMIT 1").get(card.id)?.payload, {})?.source_status === 'review';
934
+ if (reviewing && ['complete', 'request-changes'].includes(verb)) return;
245
935
  const allowed = {
246
- comment: mine || sub, block: mine || sub, review: mine || sub, complete: sub, unblock: sub,
247
- subscribe: mine || sub, unsubscribe: mine || sub,
936
+ comment: mine || sub, block: mine || sub, heartbeat: mine, 'request-review': mine || sub, attach: mine || sub,
937
+ specify: mine, decompose: mine, complete: sub, unblock: sub, 'notify-subscribe': mine || sub, 'notify-unsubscribe': mine || sub,
248
938
  }[verb];
249
939
  if (!allowed) refuse(mine ? `cannot ${verb} its own card; its reviewers and the fleet's manager do` : `writes only its own card and its subtasks, not ${card.id}`);
250
940
  if (verb === 'block' && mine && !args.kind) refuse('blocks its card with a typed reason (--kind needs_input|capability|transient|dependency)');
941
+ 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}`);
251
942
  }
252
943
 
253
944
  /** Close the live session of a card that is not running, freeing its machine's place. */
@@ -262,10 +953,6 @@ function closeSession(board, id) {
262
953
  return session;
263
954
  }
264
955
 
265
- function callerName() {
266
- return process.env.HERMES_PROFILE || process.env.USER || 'user';
267
- }
268
-
269
956
  export function summary(r) {
270
957
  const parts = [`board ${r.board}:`];
271
958
  if (r.error) parts.push(`error ${r.error}`);
@@ -288,17 +975,25 @@ async function serve(root) {
288
975
  const stop = () => { stopped = true; };
289
976
  process.on('SIGTERM', stop);
290
977
  process.on('SIGINT', stop);
291
- const log = (line) => process.stdout.write(`${new Date().toISOString()} ${line}\n`);
292
- log(`dispatching the boards of ${root}`);
978
+ const log = (text) => process.stdout.write(`${new Date().toISOString()} ${text}\n`);
979
+ log(`dispatching the boards of ${root} from ${localMachine()}`);
980
+ const stuck = {};
981
+ const warned = {};
982
+ let pausedSaid = false;
293
983
  try {
294
984
  while (!stopped) {
985
+ pausedSaid = Boolean(paused(root)) && pausedSaid;
295
986
  let interval = 60;
296
987
  try {
297
988
  const { lanes, settings } = await laneState(root);
298
989
  interval = settings.interval;
299
- for (const r of tick({ root, lanes, settings })) {
990
+ for (const r of await tick({ root, lanes, settings })) {
300
991
  const said = summary(r);
301
992
  if (!said.endsWith('nothing to do')) log(said);
993
+ if (r.paused && !pausedSaid) { log(`new starts paused by ${r.paused} (\`supercode workflow resume\` lifts it)`); pausedSaid = true; }
994
+ // Hermes's stuck warning: ready work for six ticks running and nothing started
995
+ stuck[r.board] = r.ready_waiting && !r.started.length && !r.paused ? (stuck[r.board] ?? 0) + 1 : 0;
996
+ if (stuck[r.board] >= 6 && Date.now() - (warned[r.board] ?? 0) >= 300_000) { log(`board ${r.board} stuck: ${r.ready_waiting} ready card(s) and nothing started for ${stuck[r.board]} ticks; \`supercode workflow diagnostics\``); warned[r.board] = Date.now(); }
302
997
  for (const [t, why] of r.failed) log(` ${t} did not start: ${why}`);
303
998
  for (const [t, s, m] of r.started) log(` ${t} started ${s} (${m})`);
304
999
  for (const [t, to, why] of r.undelivered) log(` notice for ${t} to ${to} not delivered: ${why}`);