@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/adapters/chat-sdk.mjs +9 -5
- package/board/cli.mjs +818 -123
- package/board/dispatch.mjs +423 -31
- package/board/engine.mjs +273 -9
- package/board/store.mjs +7 -2
- package/board.mjs +12 -3
- package/hermes-locks.mjs +9 -1
- package/loop.mjs +1 -1
- package/package.json +1 -1
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.
|
|
3
|
-
//
|
|
4
|
-
// `--
|
|
5
|
-
|
|
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 {
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
[--
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
163
|
+
const callerName = () => process.env.HERMES_PROFILE || process.env.USER || 'user';
|
|
83
164
|
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
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 '
|
|
107
|
-
|
|
108
|
-
const
|
|
109
|
-
say(args,
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
126
|
-
triage: args.triage, idempotencyKey: args['idempotency-key'], maxRuntime:
|
|
127
|
-
maxRetries: args
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
143
|
-
|
|
144
|
-
const
|
|
145
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
|
159
|
-
|
|
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
|
|
162
|
-
if (
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
case '
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
198
|
-
if (
|
|
199
|
-
|
|
200
|
-
|
|
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 '
|
|
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
|
|
205
|
-
const
|
|
206
|
-
say(args,
|
|
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
|
-
|
|
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(
|
|
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', '
|
|
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,
|
|
231
|
-
* it
|
|
232
|
-
* another arc's card: anything across arcs goes through the
|
|
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,
|
|
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 = (
|
|
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}`);
|