@volter/supercode-orchestrator 0.4.17 → 0.4.18

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