@volter/supercode-orchestrator 0.4.21 → 0.4.23
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 +26 -9
- package/board/dispatch.mjs +38 -14
- package/board/engine.mjs +50 -3
- package/board/tools.mjs +169 -0
- package/board/worker-prompt.mjs +36 -0
- package/board.mjs +2 -2
- package/hermes-cli.mjs +1 -14
- package/kanban-worker.mjs +4 -3
- package/loop.mjs +8 -1
- package/mcp/server.mjs +6 -5
- package/package.json +1 -1
package/board/cli.mjs
CHANGED
|
@@ -28,12 +28,12 @@ Hermes Kanban's verbs, with their flags:
|
|
|
28
28
|
comment <id> <text> [--author] [--max-len] attach <id> <path> [--name] [--content-type] attachments <id> attach-rm <n>
|
|
29
29
|
complete <id>... [--result] [--summary] [--metadata JSON] edit <id> --result [--summary] [--metadata]
|
|
30
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>...
|
|
31
|
+
unblock <id>... [--reason] request-review <id> [--summary] [--reviewer] [--force] request-changes <id> <reason> reopen-review <id>... reopen <id>... [--reason]
|
|
32
32
|
promote <id> [reason] [--ids ...] [--force] [--dry-run] archive <id>... [--rm <id>...]
|
|
33
33
|
specify <id> [--title --body] [--all] decompose <id> [--child "<title>[@<profile>]"]... [--all]
|
|
34
34
|
diagnostics [--task] [--severity] stats assignees log <id> [--tail N] tail <id> watch [--assignee] [--kinds]
|
|
35
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
|
|
36
|
+
dispatch [--dry-run] [--max N] [--failure-limit N] daemon [--interval S] [--max N] [--failure-limit N] [--pidfile F] [--verbose] gc [--event-retention-days] [--log-retention-days] repair
|
|
37
37
|
pause [reason] · resume (Hermes's emergency stop, the ESTOP file: new starts stop, running sessions go on)
|
|
38
38
|
|
|
39
39
|
supercode's own:
|
|
@@ -462,10 +462,18 @@ export async function runWorkflow(argv) {
|
|
|
462
462
|
case 'unblock': {
|
|
463
463
|
need(1, 'a card id');
|
|
464
464
|
const results = ids().map((t) => [t, withBoard((b) => engine.unblock(b, t))]);
|
|
465
|
+
// Hermes records the reason as a comment on each card it unblocks
|
|
466
|
+
if (args.reason) for (const [t] of results) withBoard((b) => engine.comment(b, t, callerName(), `UNBLOCK: ${args.reason}`));
|
|
465
467
|
say(args, results.map(([t, s]) => `Unblocked ${t} → ${s}`).join('\n'), { ok: true, results }); return 0;
|
|
466
468
|
}
|
|
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;
|
|
469
|
+
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, force: Boolean(args.force) })); say(args, `${id} → review`, { ok: true }); return 0;
|
|
468
470
|
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;
|
|
471
|
+
case 'reopen': {
|
|
472
|
+
// a done card back to work; the cards that built on it are invalidated with it (Hermes's reopen)
|
|
473
|
+
need(1, 'a card id');
|
|
474
|
+
const results = ids().map((t) => [t, withBoard((b) => engine.reopen(b, t, { author: callerName(), reason: args.reason ?? null }))]);
|
|
475
|
+
say(args, results.map(([t, r]) => `Reopened ${t} → ${r.status}${r.invalidated?.length ? ` (invalidated ${r.invalidated.map((d) => d.id).join(', ')})` : ''}`).join('\n'), { ok: true, results }); return 0;
|
|
476
|
+
}
|
|
469
477
|
case 'reopen-review': {
|
|
470
478
|
need(1, 'a card id');
|
|
471
479
|
const results = ids().map((t) => [t, withBoard((b) => engine.reopenReview(b, t))]);
|
|
@@ -632,7 +640,7 @@ export async function runWorkflow(argv) {
|
|
|
632
640
|
else for (const r of reports) process.stdout.write(`${summary(r)}\n`);
|
|
633
641
|
return reports.some((r) => r.error) ? 1 : 0;
|
|
634
642
|
}
|
|
635
|
-
case 'daemon': case 'serve': return serve(root);
|
|
643
|
+
case 'daemon': case 'serve': return serve(root, args);
|
|
636
644
|
|
|
637
645
|
case 'gc': {
|
|
638
646
|
const eventDays = Number(args['event-retention-days'] ?? 30);
|
|
@@ -923,7 +931,7 @@ function authorize(db, verb, rest, args, own) {
|
|
|
923
931
|
}
|
|
924
932
|
if (verb === 'boards') { if (!['list', 'ls', 'show', 'current', undefined].includes(rest[0])) refuse('does not change the home\'s boards'); return; }
|
|
925
933
|
// 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`);
|
|
934
|
+
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', 'reopen', 'attach-rm'].includes(verb)) refuse(`does not ${verb}; the fleet's manager does`);
|
|
927
935
|
if (args.all) refuse(`does not ${verb} every card; only its own`);
|
|
928
936
|
const card = engine.getTask(db, rest[0]);
|
|
929
937
|
if (!card) return; // the verb itself answers "no card"
|
|
@@ -969,8 +977,15 @@ export function summary(r) {
|
|
|
969
977
|
* re-read every tick, so a profile's change takes effect without a restart). It holds the board's one-dispatcher
|
|
970
978
|
* lock, as the daemon and Hermes's gateways do, so one dispatcher serves a board.
|
|
971
979
|
*/
|
|
972
|
-
async function serve(root) {
|
|
980
|
+
async function serve(root, args = {}) {
|
|
973
981
|
const lock = await holdDispatcherLock(root);
|
|
982
|
+
// Hermes's daemon flags: --interval seconds, --max starts per tick, --failure-limit, --pidfile, --verbose
|
|
983
|
+
const override = {
|
|
984
|
+
...(Number(args.interval) > 0 ? { interval: Number(args.interval) } : {}),
|
|
985
|
+
...(Number(args.max) > 0 ? { maxSpawn: Number(args.max) } : {}),
|
|
986
|
+
...(Number(args['failure-limit']) > 0 ? { failureLimit: Number(args['failure-limit']) } : {}),
|
|
987
|
+
};
|
|
988
|
+
if (args.pidfile) writeFileSync(args.pidfile, `${process.pid}\n`);
|
|
974
989
|
let stopped = false;
|
|
975
990
|
const stop = () => { stopped = true; };
|
|
976
991
|
process.on('SIGTERM', stop);
|
|
@@ -985,11 +1000,12 @@ async function serve(root) {
|
|
|
985
1000
|
pausedSaid = Boolean(paused(root)) && pausedSaid;
|
|
986
1001
|
let interval = 60;
|
|
987
1002
|
try {
|
|
988
|
-
const
|
|
1003
|
+
const state = await laneState(root);
|
|
1004
|
+
const settings = { ...state.settings, ...override };
|
|
989
1005
|
interval = settings.interval;
|
|
990
|
-
for (const r of await tick({ root, lanes, settings })) {
|
|
1006
|
+
for (const r of await tick({ root, lanes: state.lanes, settings })) {
|
|
991
1007
|
const said = summary(r);
|
|
992
|
-
if (!said.endsWith('nothing to do')) log(said);
|
|
1008
|
+
if (!said.endsWith('nothing to do') || args.verbose) log(said);
|
|
993
1009
|
if (r.paused && !pausedSaid) { log(`new starts paused by ${r.paused} (\`supercode workflow resume\` lifts it)`); pausedSaid = true; }
|
|
994
1010
|
// Hermes's stuck warning: ready work for six ticks running and nothing started
|
|
995
1011
|
stuck[r.board] = r.ready_waiting && !r.started.length && !r.paused ? (stuck[r.board] ?? 0) + 1 : 0;
|
|
@@ -1005,6 +1021,7 @@ async function serve(root) {
|
|
|
1005
1021
|
}
|
|
1006
1022
|
} finally {
|
|
1007
1023
|
lock.release();
|
|
1024
|
+
if (args.pidfile) { try { unlinkSync(args.pidfile); } catch { /* gone */ } }
|
|
1008
1025
|
}
|
|
1009
1026
|
log('stopped');
|
|
1010
1027
|
return 0;
|
package/board/dispatch.mjs
CHANGED
|
@@ -61,7 +61,7 @@ export const supercodeBin = () => process.env.SUPERCODE_BIN || 'supercode';
|
|
|
61
61
|
|
|
62
62
|
function sc(args, { input = null, timeout = 120_000 } = {}) {
|
|
63
63
|
// a neutral directory: `supercode teams …` resolves its entry from a checkout at the cwd
|
|
64
|
-
const r = spawnSync(supercodeBin(), args, { cwd: tmpdir(), input: input ?? undefined, encoding: 'utf8', timeout, env: process.env });
|
|
64
|
+
const r = spawnSync(supercodeBin(), args, { cwd: tmpdir(), input: input ?? undefined, encoding: 'utf8', timeout, env: process.env, maxBuffer: 512 * 1024 * 1024 });
|
|
65
65
|
return { code: r.status, stdout: r.stdout ?? '', stderr: (r.stderr ?? '') + (r.error ? r.error.message : '') };
|
|
66
66
|
}
|
|
67
67
|
|
|
@@ -94,12 +94,24 @@ function pidAlive(pid) {
|
|
|
94
94
|
|
|
95
95
|
// ------------------------------------------------------------------ settings
|
|
96
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Hermes's default for kanban.max_in_progress when none is set: one worker per 512 MiB of the host's memory, between
|
|
99
|
+
* two and eight, from /proc/meminfo; none where that cannot be read (which caps nothing).
|
|
100
|
+
*/
|
|
101
|
+
function derivedMaxInProgress() {
|
|
102
|
+
try {
|
|
103
|
+
const total = Number(/^MemTotal:\s+(\d+)/m.exec(readFileSync('/proc/meminfo', 'utf8'))?.[1]);
|
|
104
|
+
return Number.isFinite(total) && total > 0 ? Math.max(2, Math.min(Math.floor(total / 1024 / 512), 8)) : null;
|
|
105
|
+
} catch { return null; }
|
|
106
|
+
}
|
|
107
|
+
|
|
97
108
|
/** The board's settings: the home's `kanban:` block, Hermes's keys and supercode's own (`machines`, `session_grace_seconds`). */
|
|
98
109
|
export function boardSettings(kanban = {}) {
|
|
99
110
|
const pos = (v) => (Number.isInteger(Number(v)) && Number(v) >= 1 ? Number(v) : null);
|
|
100
111
|
return {
|
|
101
112
|
failureLimit: pos(kanban.failure_limit) ?? DEFAULT_FAILURE_LIMIT,
|
|
102
|
-
maxInProgress: pos(kanban.max_in_progress),
|
|
113
|
+
maxInProgress: pos(kanban.max_in_progress) ?? derivedMaxInProgress(),
|
|
114
|
+
crashGrace: Number.isInteger(Number(process.env.HERMES_KANBAN_CRASH_GRACE_SECONDS)) && process.env.HERMES_KANBAN_CRASH_GRACE_SECONDS !== '' && Number(process.env.HERMES_KANBAN_CRASH_GRACE_SECONDS) >= 0 ? Number(process.env.HERMES_KANBAN_CRASH_GRACE_SECONDS) : 30,
|
|
103
115
|
perProfile: pos(kanban.max_in_progress_per_profile),
|
|
104
116
|
defaultAssignee: String(kanban.default_assignee ?? '').trim() || null,
|
|
105
117
|
reviewDispatch: kanban.review_dispatch !== false,
|
|
@@ -171,7 +183,7 @@ function supervise(board, fleet, settings, report) {
|
|
|
171
183
|
if (Number(task.claim_expires) <= t + 60) extendClaim(db, id, { ttl: settings.ttl, reason: 'pid_alive', extra: { worker_pid: session.pid } });
|
|
172
184
|
return;
|
|
173
185
|
}
|
|
174
|
-
if (t - Number(session.launched_at ?? 0) <
|
|
186
|
+
if (t - Number(session.launched_at ?? 0) < settings.crashGrace) return;
|
|
175
187
|
// how the worker ended, from the exit code its wrapper wrote (Hermes reads its reaped children's)
|
|
176
188
|
const code = session.exit_file && existsSync(session.exit_file) ? Number(readFileSync(session.exit_file, 'utf8').trim()) : null;
|
|
177
189
|
if (code === 75) {
|
|
@@ -337,7 +349,7 @@ function start(board, fleet, lanes, settings, report, { only = null, hostRunning
|
|
|
337
349
|
const stop = paused(board.root);
|
|
338
350
|
if (stop) { report.paused = stop; return; }
|
|
339
351
|
const pressure = memoryPressure();
|
|
340
|
-
let
|
|
352
|
+
let localStarted = 0;
|
|
341
353
|
const order = 'ORDER BY priority DESC, created_at ASC';
|
|
342
354
|
const ready = board.db.prepare(`SELECT id, status, assignee FROM tasks WHERE status = 'ready' AND claim_lock IS NULL ${order}`).all();
|
|
343
355
|
const review = settings.reviewDispatch ? board.db.prepare(`SELECT id, status, assignee FROM tasks WHERE status = 'review' AND claim_lock IS NULL ${order}`).all() : [];
|
|
@@ -357,11 +369,11 @@ function start(board, fleet, lanes, settings, report, { only = null, hostRunning
|
|
|
357
369
|
const task = getTask(db, row.id);
|
|
358
370
|
if (!task || task.status !== row.status || task.claim_lock) return null;
|
|
359
371
|
let why = guard(db, task, lane, fleet, settings, lanesBudget);
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
372
|
+
// this machine's memory bounds what starts here: every one-shot worker, and a pane session on this machine
|
|
373
|
+
const here = lane.surface !== 'pane' || normalMachine(task.machine ?? '') === localMachine();
|
|
374
|
+
if (!why && here && (pressure === 'critical' || (pressure === 'elevated' && localStarted >= 1))) why = `this machine's memory pressure is ${pressure}; the card starts when it eases`;
|
|
375
|
+
// Hermes's respawn guard, on every lane (a pane card whose session is still live is kept, never started twice)
|
|
376
|
+
if (!why) why = respawnGuard(db, task, settings, row.status);
|
|
365
377
|
if (why) { noteGuard(db, row.id, why); report.queued.push([row.id, why]); return null; }
|
|
366
378
|
if (!row.assignee) {
|
|
367
379
|
db.prepare('UPDATE tasks SET assignee = ? WHERE id = ?').run(assignee, row.id);
|
|
@@ -375,7 +387,7 @@ function start(board, fleet, lanes, settings, report, { only = null, hostRunning
|
|
|
375
387
|
const session = lane.surface === 'pane'
|
|
376
388
|
? launchPane(board, claimed.task, claimed.runId, lane, fleet, { input: row.status === 'review' ? reviewAsk(board, claimed.task) : null })
|
|
377
389
|
: launchHeadless(board, claimed.task, claimed.runId, lane, settings);
|
|
378
|
-
if (lane.surface !== 'pane')
|
|
390
|
+
if (lane.surface !== 'pane' || normalMachine(claimed.task.machine ?? '') === localMachine()) localStarted += 1;
|
|
379
391
|
if (budget) budget.left -= 1;
|
|
380
392
|
if (readyBudget && readyBudget !== budget) readyBudget.left -= 1;
|
|
381
393
|
report.started.push([row.id, session.address ?? `pid ${session.pid}`, session.mode]);
|
|
@@ -590,7 +602,12 @@ export function adoptSession(board, id, who, { workspace = null, ttl, assignee =
|
|
|
590
602
|
if (!found) throw new Error(`the fleet lists no live session ${who}`);
|
|
591
603
|
const [, host, harness, sessionId] = found.address.split(':');
|
|
592
604
|
if (harness !== 'claude-code') throw new Error(`${found.address} is ${harness}; the board resumes claude-code sessions by id`);
|
|
605
|
+
// the session's own working directory, as discovery records it (where `--resume` finds it), else its pane's
|
|
593
606
|
let dir = workspace;
|
|
607
|
+
if (!dir) {
|
|
608
|
+
const r = sc(['discover', '--on', host, '--harness', harness, '--limit', '2000'], { timeout: 120_000 });
|
|
609
|
+
dir = (json(r.stdout.trim(), {})?.sessions ?? []).find((x) => x.locator?.session_id === sessionId)?.cwd ?? null;
|
|
610
|
+
}
|
|
594
611
|
if (!dir && found.pane) {
|
|
595
612
|
const r = sc(['teams', 'panes', 'ls', '--machine', host], { timeout: 60_000 });
|
|
596
613
|
dir = (json(r.stdout.trim(), []) ?? []).find((p) => p.pane === found.pane)?.cwd ?? null;
|
|
@@ -659,9 +676,9 @@ function notice(kind, task, p, role) {
|
|
|
659
676
|
}
|
|
660
677
|
}
|
|
661
678
|
|
|
662
|
-
function send(from, label, to, body, key) {
|
|
679
|
+
function send(from, label, to, body, key, { queue = false } = {}) {
|
|
663
680
|
const request = {
|
|
664
|
-
op: 'send', from, from_name: `${label} board`, to, queue
|
|
681
|
+
op: 'send', from, from_name: `${label} board`, to, queue, id: key,
|
|
665
682
|
body: `[Board notice from the ${label} board] ${body}\n\n(An automated notice from the board's dispatcher; replies to this address go nowhere.)`,
|
|
666
683
|
};
|
|
667
684
|
const r = sc(['message', 'remote-deliver'], { input: JSON.stringify(request), timeout: 60_000 });
|
|
@@ -702,7 +719,8 @@ function notify(board, fleet, report) {
|
|
|
702
719
|
text = c ? `💬 card ${sub.task_id}, ${p.author}:\n${c.body}` : null;
|
|
703
720
|
} else text = notice(ev.kind, task, p, role);
|
|
704
721
|
if (text) {
|
|
705
|
-
|
|
722
|
+
// Hermes's delivery modes: `notify` is passive (read at the session's next turn), `wake`/`notify+wake` start its turn
|
|
723
|
+
send(from, board.label, sub.chat_id, text, `board-${sub.task_id}-${ev.id}-${sub.chat_id.slice(-12)}`, { queue: sub.delivery_mode === 'notify' });
|
|
706
724
|
report.notified.push([sub.task_id, ev.kind, sub.chat_id]);
|
|
707
725
|
}
|
|
708
726
|
cursor = ev.id;
|
|
@@ -730,7 +748,7 @@ const CHAT_KINDS = ['completed', 'blocked', 'gave_up', 'crashed', 'timed_out', '
|
|
|
730
748
|
* A chat-platform subscription's events, sent through the orchestrator's own channel adapters (the daemon hosts them;
|
|
731
749
|
* `serve` hosts none, as a Hermes CLI without its gateway delivers none). Answers what was sent and what failed.
|
|
732
750
|
*/
|
|
733
|
-
export async function notifyChats(root, adapters, log = () => {}) {
|
|
751
|
+
export async function notifyChats(root, adapters, log = () => {}, wake = null) {
|
|
734
752
|
const sent = [];
|
|
735
753
|
for (const slug of listBoards(root)) {
|
|
736
754
|
const board = openBoard(root, slug);
|
|
@@ -750,6 +768,12 @@ export async function notifyChats(root, adapters, log = () => {}) {
|
|
|
750
768
|
if (text) {
|
|
751
769
|
// a completed card's deliverables go with it as uploads, as Hermes's notifier sends them
|
|
752
770
|
const files = ev.kind === 'completed' ? artifactsOf(payload, task).map((path) => ({ path })) : [];
|
|
771
|
+
const mode = sub.delivery_mode || 'notify';
|
|
772
|
+
// `wake`/`notify+wake`: the chat's own session gets the event as a turn (Hermes's wake), through the daemon
|
|
773
|
+
if (mode !== 'notify' && wake) {
|
|
774
|
+
try { await wake(sub, `${slug !== 'default' ? `[${slug}] ` : ''}${text}`); } catch (error) { log('warn', `board ${slug}: waking ${sub.platform}:${sub.chat_id} for ${sub.task_id} failed: ${error.message}`); }
|
|
775
|
+
}
|
|
776
|
+
if (mode === 'wake') { sent.push([sub.task_id, ev.kind, `${sub.platform}:${sub.chat_id} (wake)`]); cursor = ev.id; continue; }
|
|
753
777
|
try {
|
|
754
778
|
await adapter.send({ kind: 'explicit', platform: sub.platform, chat_id: sub.chat_id, ...(sub.thread_id ? { thread_id: sub.thread_id } : {}) }, { text: `${slug !== 'default' ? `[${slug}] ` : ''}${text}`, ...(files.length ? { files } : {}) });
|
|
755
779
|
sent.push([sub.task_id, ev.kind, `${sub.platform}:${sub.chat_id}`]);
|
package/board/engine.mjs
CHANGED
|
@@ -65,7 +65,14 @@ export function endRun(db, taskId, { outcome, status = null, summary = null, err
|
|
|
65
65
|
const row = one(db, 'SELECT current_run_id FROM tasks WHERE id = ?', taskId);
|
|
66
66
|
if (!row?.current_run_id) return null;
|
|
67
67
|
const runId = Number(row.current_run_id);
|
|
68
|
-
const
|
|
68
|
+
const was = one(db, 'SELECT metadata FROM task_runs WHERE id = ?', runId)?.metadata ?? null;
|
|
69
|
+
const keep = metadata === undefined ? was : (metadata ? JSON.stringify(metadata) : null);
|
|
70
|
+
// a run taken back (reclaim, archive, a reopened ancestor) stops its one-shot worker on this machine, as Hermes
|
|
71
|
+
// terminates a reclaimed worker (a board's one-shot workers run where the board is); a pane session is a person-visible session and is never killed from the board
|
|
72
|
+
const worker = outcome === 'reclaimed' ? json(was, {})?.supercode : null;
|
|
73
|
+
if (worker?.surface === 'headless' && worker.pid) {
|
|
74
|
+
try { process.kill(-worker.pid, 'SIGTERM'); } catch { try { process.kill(worker.pid, 'SIGTERM'); } catch { /* gone */ } }
|
|
75
|
+
}
|
|
69
76
|
run(db, `UPDATE task_runs SET status = ?, outcome = ?, summary = ?, error = ?, metadata = ?, ended_at = ?, claim_lock = NULL, claim_expires = NULL, worker_pid = NULL
|
|
70
77
|
WHERE id = ? AND ended_at IS NULL`, status ?? outcome, outcome, summary, error, keep, now(), runId);
|
|
71
78
|
run(db, 'UPDATE tasks SET current_run_id = NULL WHERE id = ?', taskId);
|
|
@@ -417,11 +424,22 @@ export function unblock(board, id) {
|
|
|
417
424
|
}
|
|
418
425
|
|
|
419
426
|
/** Implementation done, awaiting review (not a block): the review lane, optionally under a reviewer profile. */
|
|
420
|
-
export function requestReview(board, id, { summary = null, metadata = null, reviewer = null } = {}) {
|
|
427
|
+
export function requestReview(board, id, { summary = null, metadata = null, reviewer = null, force = false } = {}) {
|
|
421
428
|
return tx(board, (db) => {
|
|
422
429
|
const task = must(db, id);
|
|
423
430
|
if (!parentsDone(db, id)) throw new Error(`${id} waits on parents that are not done`);
|
|
424
431
|
if (!['running', 'ready'].includes(task.status)) throw new Error(`${id} is ${task.status}; only a running or ready card goes to review`);
|
|
432
|
+
// a live claim is its worker's to hand off; anyone else says so on purpose (Hermes's guard)
|
|
433
|
+
if (task.status === 'running' && task.claim_lock && !force) throw new Error(`${id} is running under a live claim; its worker requests review, or pass --force to override on purpose`);
|
|
434
|
+
// a re-review goes back to the reviewer who asked for the changes
|
|
435
|
+
if (!reviewer) {
|
|
436
|
+
const changed = one(db, "SELECT id FROM task_runs WHERE task_id = ? AND outcome = 'changes_requested' ORDER BY id DESC LIMIT 1", id);
|
|
437
|
+
if (changed) {
|
|
438
|
+
const asked = json(one(db, "SELECT payload FROM task_events WHERE task_id = ? AND run_id = ? AND kind = 'changes_requested' ORDER BY id DESC LIMIT 1", id, changed.id)?.payload, {}) ?? {};
|
|
439
|
+
if (!asked.reviewer) throw new Error(`${id}'s last changes request names no reviewer; pass --reviewer`);
|
|
440
|
+
reviewer = asked.reviewer;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
425
443
|
run(db, `UPDATE tasks SET status = 'review', claim_lock = NULL, claim_expires = NULL, worker_pid = NULL${reviewer ? ', assignee = ?' : ''} WHERE id = ?`, ...(reviewer ? [reviewer, id] : [id]));
|
|
426
444
|
const runId = endRun(db, id, { outcome: 'review_requested', status: 'review', summary, metadata: metadata ?? undefined }) ?? (summary || metadata ? synthesizeRun(db, id, { outcome: 'review_requested', summary, metadata }) : null);
|
|
427
445
|
event(db, id, 'review_requested', { summary: String(summary ?? '').trim().split('\n')[0].slice(0, 400) || null, implementer: task.assignee, reviewer }, runId);
|
|
@@ -442,6 +460,35 @@ export function requestChanges(board, id, { reason = null, reviewer = null, impl
|
|
|
442
460
|
});
|
|
443
461
|
}
|
|
444
462
|
|
|
463
|
+
/**
|
|
464
|
+
* A done (or archived) card reopened: back to ready (todo while a parent is open), and every descendant that had moved on
|
|
465
|
+
* because of it pulled back to todo (Hermes's dashboard reopen, `invalidate_descendants_for_parent_reopen`).
|
|
466
|
+
*/
|
|
467
|
+
export function reopen(board, id, { author = 'user', reason = null } = {}) {
|
|
468
|
+
return tx(board, (db) => {
|
|
469
|
+
const task = must(db, id);
|
|
470
|
+
if (!['done', 'archived'].includes(task.status)) throw new Error(`${id} is ${task.status}; only a done or archived card is reopened`);
|
|
471
|
+
const status = parentsDone(db, id) ? 'ready' : 'todo';
|
|
472
|
+
run(db, 'UPDATE tasks SET status = ?, completed_at = NULL WHERE id = ?', status, id);
|
|
473
|
+
event(db, id, 'status', { status, reason: reason ?? 'reopened', previous_status: task.status });
|
|
474
|
+
const invalidated = [];
|
|
475
|
+
const t = now();
|
|
476
|
+
const rows = all(db, `WITH RECURSIVE d(id) AS (SELECT child_id FROM task_links WHERE parent_id = ? UNION SELECT l.child_id FROM task_links l JOIN d ON d.id = l.parent_id)
|
|
477
|
+
SELECT t.id, t.status FROM d JOIN tasks t ON t.id = d.id ORDER BY t.id`, id);
|
|
478
|
+
for (const row of rows) {
|
|
479
|
+
if (!['ready', 'review', 'running', 'done'].includes(row.status)) continue;
|
|
480
|
+
const resume = row.status === 'review' ? 'review' : row.status === 'running' ? retryStatus(db, row.id) : 'ready';
|
|
481
|
+
const runId = row.status === 'running' ? endRun(db, row.id, { outcome: 'reclaimed', status: 'todo', summary: `ancestor ${id} reopened` }) : null;
|
|
482
|
+
run(db, "UPDATE tasks SET status = 'todo', completed_at = NULL, claim_lock = NULL, claim_expires = NULL, worker_pid = NULL, current_run_id = NULL, consecutive_failures = 0 WHERE id = ?", row.id);
|
|
483
|
+
event(db, row.id, 'descendant_invalidated', { ancestor: id, prior_status: row.status, new_status: 'todo', resume_status: resume }, runId);
|
|
484
|
+
event(db, row.id, 'status', { status: 'todo', reason: 'ancestor_reopened', parent: id, previous_status: row.status, resume_status: resume }, runId);
|
|
485
|
+
run(db, 'INSERT INTO task_comments (task_id, author, body, created_at) VALUES (?, ?, ?, ?)', row.id, author, `Invalidated: ancestor ${id} was reopened; retracted from '${row.status}' to 'todo' (will resume via '${resume}').`, t);
|
|
486
|
+
invalidated.push({ id: row.id, prior_status: row.status });
|
|
487
|
+
}
|
|
488
|
+
return { status, invalidated };
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
445
492
|
export function schedule(board, id, reason = null) {
|
|
446
493
|
return tx(board, (db) => {
|
|
447
494
|
const task = must(db, id);
|
|
@@ -516,7 +563,7 @@ function recomputeIn(db, failureLimit = DEFAULT_FAILURE_LIMIT) {
|
|
|
516
563
|
export function subscribeIn(db, id, address, role = 'creator') {
|
|
517
564
|
const cursor = one(db, 'SELECT COALESCE(MAX(id), 0) AS c FROM task_events WHERE task_id = ?', id).c;
|
|
518
565
|
run(db, `INSERT OR IGNORE INTO kanban_notify_subs (task_id, platform, chat_id, thread_id, chat_type, delivery_mode, delivery_metadata, created_at, last_event_id)
|
|
519
|
-
VALUES (?, 'supercode', ?, '', 'dm', 'notify', ?, ?, ?)`, id, address, JSON.stringify({ role }), now(), cursor);
|
|
566
|
+
VALUES (?, 'supercode', ?, '', 'dm', 'notify+wake', ?, ?, ?)`, id, address, JSON.stringify({ role }), now(), cursor);
|
|
520
567
|
}
|
|
521
568
|
|
|
522
569
|
export function subscribe(board, id, address, role = 'creator') {
|
package/board/tools.mjs
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// The board's tools for a worker the dispatcher started (docs/architecture/orchestrator.md §2.9): Hermes's fourteen
|
|
2
|
+
// `kanban_*` tools, by the same names and arguments, answered by supercode's own engine on the board file. A worker is
|
|
3
|
+
// scoped as Hermes scopes one: `HERMES_KANBAN_TASK` is its task (the default for `task_id`, and the only task its
|
|
4
|
+
// lifecycle tools may change), `HERMES_KANBAN_RUN_ID` its run (a superseded run changes nothing), and the listing and
|
|
5
|
+
// unblocking tools are the orchestrator's, not a worker's. Answers are JSON text: `{ ok: true, … }` or `{ error }`.
|
|
6
|
+
import { mkdirSync, statSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { basename, join } from 'node:path';
|
|
8
|
+
import { DEFAULT_BOARD, event, json, openBoard, tx } from './store.mjs';
|
|
9
|
+
import * as engine from './engine.mjs';
|
|
10
|
+
|
|
11
|
+
const ok = (fields = {}) => JSON.stringify({ ok: true, ...fields });
|
|
12
|
+
const error = (message) => JSON.stringify({ error: message });
|
|
13
|
+
|
|
14
|
+
// secrets a worker may paste into a handoff, masked before they are stored (Hermes redacts a handoff the same way)
|
|
15
|
+
const SECRET = [
|
|
16
|
+
/\b(sk-[A-Za-z0-9_-]{16,}|sk-ant-[A-Za-z0-9_-]{16,}|ghp_[A-Za-z0-9]{20,}|gho_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|xox[abpr]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{30,})\b/g,
|
|
17
|
+
/\b(Bearer\s+)[A-Za-z0-9._~+/-]{16,}=*/g,
|
|
18
|
+
/\b((?:api[_-]?key|token|secret|password|passwd)\s*[:=]\s*)["']?[^\s"',;]{8,}/gi,
|
|
19
|
+
];
|
|
20
|
+
export function redact(value) {
|
|
21
|
+
if (typeof value === 'string') return SECRET.reduce((text, re) => text.replace(re, (m, a) => (m.startsWith(a) && a !== m ? `${a}***` : '***')), value);
|
|
22
|
+
if (Array.isArray(value)) return value.map(redact);
|
|
23
|
+
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, redact(v)]));
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const LIFECYCLE = new Set(['kanban_complete', 'kanban_block', 'kanban_request_review', 'kanban_request_changes', 'kanban_heartbeat', 'kanban_attach', 'kanban_attach_url', 'kanban_unblock']);
|
|
28
|
+
const ORCHESTRATOR_ONLY = new Set(['kanban_list', 'kanban_unblock']);
|
|
29
|
+
|
|
30
|
+
/** One board tool call. `root` is the home; `env` the worker's task environment (`HERMES_KANBAN_*`). */
|
|
31
|
+
export async function boardTool(root, name, args = {}, env = {}) {
|
|
32
|
+
const own = env.HERMES_KANBAN_TASK || null;
|
|
33
|
+
const runId = env.HERMES_KANBAN_RUN_ID ? Number(env.HERMES_KANBAN_RUN_ID) : null;
|
|
34
|
+
const author = env.HERMES_PROFILE || 'worker';
|
|
35
|
+
if (own && ORCHESTRATOR_ONLY.has(name)) return error(`${name} is the orchestrator's; a dispatched worker uses kanban_complete, kanban_block, kanban_heartbeat or kanban_comment for its own task`);
|
|
36
|
+
const tid = args.task_id || own;
|
|
37
|
+
if (!['kanban_list', 'kanban_create', 'kanban_link'].includes(name) && !tid) return error('task_id is required (or HERMES_KANBAN_TASK in the environment)');
|
|
38
|
+
if (own && LIFECYCLE.has(name) && tid !== own) return error(`worker is scoped to task ${own}; refusing to change ${tid}. Use kanban_comment to hand off to other tasks, or kanban_create for follow-up work.`);
|
|
39
|
+
const slug = args.board || env.HERMES_KANBAN_BOARD || DEFAULT_BOARD;
|
|
40
|
+
let board;
|
|
41
|
+
try { board = openBoard(root, slug); } catch (e) { return error(e.message); }
|
|
42
|
+
board.label = slug;
|
|
43
|
+
const current = () => tx(board, (db) => engine.getTask(db, tid));
|
|
44
|
+
// a worker whose run was superseded (reclaimed, re-dispatched) no longer speaks for the card
|
|
45
|
+
const ownRun = () => {
|
|
46
|
+
if (!own || tid !== own || !runId) return null;
|
|
47
|
+
const t = current();
|
|
48
|
+
return t && Number(t.current_run_id) !== runId ? `run ${runId} of ${tid} is no longer its current run; nothing was changed` : null;
|
|
49
|
+
};
|
|
50
|
+
try {
|
|
51
|
+
switch (name) {
|
|
52
|
+
case 'kanban_show': {
|
|
53
|
+
const card = tx(board, (db) => { const c = engine.showCard(db, tid); c.context = engine.cardContext(db, tid); return c; });
|
|
54
|
+
const t = card.task;
|
|
55
|
+
return JSON.stringify({
|
|
56
|
+
task: { id: t.id, title: t.title, body: t.body, assignee: t.assignee, status: t.status, tenant: t.tenant, priority: t.priority, workspace_kind: t.workspace_kind,
|
|
57
|
+
workspace_path: t.workspace_path, created_by: t.created_by, created_at: t.created_at, started_at: t.started_at, completed_at: t.completed_at, result: t.result,
|
|
58
|
+
current_run_id: t.current_run_id, model_override: t.model_override, provider_override: t.provider_override },
|
|
59
|
+
parents: card.parents.map((p) => p.id), children: card.children,
|
|
60
|
+
comments: card.comments, events: card.events.slice(-50), runs: card.runs.map((r) => ({ id: r.id, profile: r.profile, status: r.status, outcome: r.outcome, summary: r.summary, error: r.error, metadata: r.metadata, started_at: r.started_at, ended_at: r.ended_at })),
|
|
61
|
+
worker_context: card.context,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
case 'kanban_list': {
|
|
65
|
+
const limit = args.limit === undefined ? 100 : Number(args.limit);
|
|
66
|
+
if (!Number.isInteger(limit) || limit < 1) return error('limit must be an integer >= 1');
|
|
67
|
+
const cards = tx(board, (db) => engine.listCards(db, { status: args.status ?? null, assignee: args.assignee ?? null, includeArchived: Boolean(args.include_archived) }))
|
|
68
|
+
.filter((t) => !args.tenant || t.tenant === args.tenant).slice(0, limit);
|
|
69
|
+
return ok({ tasks: cards.map((t) => ({ id: t.id, title: t.title, assignee: t.assignee, status: t.status, priority: t.priority, tenant: t.tenant, machine: t.machine })), count: cards.length });
|
|
70
|
+
}
|
|
71
|
+
case 'kanban_complete': {
|
|
72
|
+
const stale = ownRun(); if (stale) return error(stale);
|
|
73
|
+
const summary = args.summary ? redact(String(args.summary)) : null;
|
|
74
|
+
const result = args.result ? redact(String(args.result)) : null;
|
|
75
|
+
if (!summary && !result) return error('provide at least one of: summary (preferred), result');
|
|
76
|
+
let metadata = args.metadata === undefined || args.metadata === null ? null : redact(args.metadata);
|
|
77
|
+
if (metadata !== null && (typeof metadata !== 'object' || Array.isArray(metadata))) return error('metadata must be an object');
|
|
78
|
+
const artifacts = [args.artifacts].flat().filter((a) => typeof a === 'string' && a.trim()).map((a) => a.trim());
|
|
79
|
+
if (artifacts.length) metadata = { ...(metadata ?? {}), artifacts: [...new Set([...(metadata?.artifacts ?? []), ...artifacts])] };
|
|
80
|
+
// cards the handoff says it created must exist (Hermes refuses a handoff naming cards nobody made)
|
|
81
|
+
const created = [args.created_cards].flat().filter(Boolean).map(String);
|
|
82
|
+
if (created.length) {
|
|
83
|
+
const phantom = tx(board, (db) => created.filter((c) => !engine.getTask(db, c)));
|
|
84
|
+
if (phantom.length) {
|
|
85
|
+
tx(board, (db) => event(db, tid, 'completion_blocked_hallucination', { phantom_cards: phantom, verified_cards: created.filter((c) => !phantom.includes(c)) }));
|
|
86
|
+
return error(`created_cards names cards that do not exist: ${phantom.join(', ')}. Create them with kanban_create first, or leave them out.`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (env.HERMES_SESSION_ID && own === tid) metadata = { ...(metadata ?? {}), worker_session_id: env.HERMES_SESSION_ID };
|
|
90
|
+
engine.complete(board, tid, { summary, result, metadata });
|
|
91
|
+
return ok({ task_id: tid, status: 'done' });
|
|
92
|
+
}
|
|
93
|
+
case 'kanban_block': {
|
|
94
|
+
const stale = ownRun(); if (stale) return error(stale);
|
|
95
|
+
const status = engine.block(board, tid, { reason: args.reason ? redact(String(args.reason)) : null, kind: args.kind ?? null });
|
|
96
|
+
return ok({ task_id: tid, status });
|
|
97
|
+
}
|
|
98
|
+
case 'kanban_request_review': {
|
|
99
|
+
const stale = ownRun(); if (stale) return error(stale);
|
|
100
|
+
engine.requestReview(board, tid, { summary: args.summary ? redact(String(args.summary)) : null, metadata: args.metadata ? redact(args.metadata) : null, reviewer: args.reviewer ?? null, force: own === tid });
|
|
101
|
+
return ok({ task_id: tid, status: 'review' });
|
|
102
|
+
}
|
|
103
|
+
case 'kanban_request_changes': {
|
|
104
|
+
const stale = ownRun(); if (stale) return error(stale);
|
|
105
|
+
engine.requestChanges(board, tid, { reason: args.reason ? redact(String(args.reason)) : null, reviewer: author });
|
|
106
|
+
return ok({ task_id: tid });
|
|
107
|
+
}
|
|
108
|
+
case 'kanban_heartbeat': {
|
|
109
|
+
const stale = ownRun(); if (stale) return error(stale);
|
|
110
|
+
engine.heartbeat(board, tid, args.note ?? null);
|
|
111
|
+
return ok({ task_id: tid });
|
|
112
|
+
}
|
|
113
|
+
case 'kanban_comment': {
|
|
114
|
+
if (!String(args.body ?? '').trim()) return error('body is required');
|
|
115
|
+
const n = engine.comment(board, tid, author, redact(String(args.body)));
|
|
116
|
+
return ok({ task_id: tid, comment_id: n });
|
|
117
|
+
}
|
|
118
|
+
case 'kanban_attach': case 'kanban_attach_url': {
|
|
119
|
+
let data;
|
|
120
|
+
let filename = args.filename;
|
|
121
|
+
if (name === 'kanban_attach') {
|
|
122
|
+
if (!args.content_base64) return error('content_base64 is required');
|
|
123
|
+
data = Buffer.from(String(args.content_base64), 'base64');
|
|
124
|
+
} else {
|
|
125
|
+
if (!/^https?:\/\//.test(String(args.url ?? ''))) return error('url must be http(s)');
|
|
126
|
+
const res = await fetch(args.url);
|
|
127
|
+
if (!res.ok) return error(`fetching ${args.url}: HTTP ${res.status}`);
|
|
128
|
+
data = Buffer.from(await res.arrayBuffer());
|
|
129
|
+
filename ??= basename(new URL(args.url).pathname) || 'download';
|
|
130
|
+
}
|
|
131
|
+
if (data.length > 25 * 1024 * 1024) return error('an attachment is at most 25 MiB');
|
|
132
|
+
const safe = basename(String(filename ?? 'attachment')).replace(/[^A-Za-z0-9._ -]/g, '_') || 'attachment';
|
|
133
|
+
const dir = join(slug === DEFAULT_BOARD ? join(root, 'kanban', 'attachments') : join(root, 'kanban', 'boards', slug, 'attachments'), tid);
|
|
134
|
+
mkdirSync(dir, { recursive: true });
|
|
135
|
+
let path = join(dir, safe);
|
|
136
|
+
for (let n = 1; (() => { try { statSync(path); return true; } catch { return false; } })(); n += 1) path = join(dir, `${n}-${safe}`);
|
|
137
|
+
writeFileSync(path, data);
|
|
138
|
+
const id = engine.addAttachment(board, tid, { file: { path, size: data.length }, name: safe, contentType: args.content_type ?? null, author });
|
|
139
|
+
return ok({ task_id: tid, attachment_id: id, path });
|
|
140
|
+
}
|
|
141
|
+
case 'kanban_attachments':
|
|
142
|
+
return ok({ task_id: tid, attachments: tx(board, (db) => engine.attachments(db, tid)).map((a) => ({ id: a.id, filename: a.filename, size: a.size, content_type: a.content_type, path: a.stored_path })) });
|
|
143
|
+
case 'kanban_create': {
|
|
144
|
+
const workspace = args.workspace_kind ? `${args.workspace_kind}${args.workspace_path ? `:${args.workspace_path}` : ''}` : undefined;
|
|
145
|
+
const made = engine.createCard(board, {
|
|
146
|
+
title: args.title, body: args.body ? redact(String(args.body)) : null, assignee: args.assignee ?? null, parents: [args.parents ?? []].flat(), tenant: args.tenant ?? null,
|
|
147
|
+
priority: args.priority ?? 0, workspace, project: args.project ?? null, triage: Boolean(args.triage), idempotencyKey: args.idempotency_key ?? null,
|
|
148
|
+
maxRuntime: args.max_runtime_seconds ?? null, initialStatus: args.initial_status ?? null, skills: args.skills ?? null, model: args.model ?? null, provider: args.provider ?? null,
|
|
149
|
+
goal: args.goal_mode ? { maxTurns: args.goal_max_turns ?? null } : null, createdBy: author,
|
|
150
|
+
});
|
|
151
|
+
return ok({ task_id: made.id, existing: made.existing, status: tx(board, (db) => engine.getTask(db, made.id)).status });
|
|
152
|
+
}
|
|
153
|
+
case 'kanban_unblock':
|
|
154
|
+
return ok({ task_id: tid, status: engine.unblock(board, tid) });
|
|
155
|
+
case 'kanban_link':
|
|
156
|
+
if (!args.parent_id || !args.child_id) return error('parent_id and child_id are required');
|
|
157
|
+
engine.link(board, args.parent_id, args.child_id);
|
|
158
|
+
return ok({ parent_id: args.parent_id, child_id: args.child_id });
|
|
159
|
+
default:
|
|
160
|
+
return error(`no board tool ${name}`);
|
|
161
|
+
}
|
|
162
|
+
} catch (e) {
|
|
163
|
+
return error(`${name}: ${e.message}`);
|
|
164
|
+
} finally {
|
|
165
|
+
board.close();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export { json };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// What a headless worker the board started reads in its system prompt (docs/architecture/orchestrator.md §2.9): the
|
|
2
|
+
// task protocol, in supercode's words, and the skills its card pins, read from the profile's own skill folders. Hermes
|
|
3
|
+
// gives its workers the same two things; this is supercode's version of both.
|
|
4
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { skillSources } from '../hermes-home.mjs';
|
|
7
|
+
|
|
8
|
+
/** The task protocol: how a worker the board started reads and closes its task. */
|
|
9
|
+
export function taskProtocol(taskId) {
|
|
10
|
+
return [
|
|
11
|
+
'# Your task comes from the board',
|
|
12
|
+
`You were started for board task ${taskId} (it is also in HERMES_KANBAN_TASK). The board's tools are yours:`,
|
|
13
|
+
'- `kanban_show` reads your task: its body, its parents\' handoffs, earlier attempts and its thread. Read it first.',
|
|
14
|
+
'- `kanban_heartbeat` says you are still working on a long step; `kanban_comment` leaves a note on any task.',
|
|
15
|
+
'- `kanban_create` files follow-up work (with `parents` to make it wait on another task); `kanban_link` adds a dependency.',
|
|
16
|
+
'- When the work is done, `kanban_complete` with a `summary` a person can read (and `metadata`, `artifacts`, `created_cards` where they apply).',
|
|
17
|
+
'- If the work needs review, `kanban_request_review` instead. If you cannot go on, `kanban_block` with a `reason` and its `kind`',
|
|
18
|
+
' (needs_input, capability, transient, or dependency when it waits on another task).',
|
|
19
|
+
'End every run with exactly one of complete, request_review or block: a run that ends without one is counted as a failure.',
|
|
20
|
+
'You may change only your own task\'s lifecycle; other tasks hear from you through comments.',
|
|
21
|
+
].join('\n');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The skills a card pins, each as its SKILL.md reads, from the profile's skill folders; '' when none resolve. */
|
|
25
|
+
export function pinnedSkills(profileDir, names = []) {
|
|
26
|
+
if (!names.length) return '';
|
|
27
|
+
const sources = skillSources(profileDir);
|
|
28
|
+
const out = [];
|
|
29
|
+
for (const name of names) {
|
|
30
|
+
const dir = sources.get(name);
|
|
31
|
+
const file = dir && join(dir, 'SKILL.md');
|
|
32
|
+
if (file && existsSync(file)) out.push(`## Skill: ${name}\n\n${readFileSync(file, 'utf8').trim()}`);
|
|
33
|
+
else out.push(`## Skill: ${name}\n\n(This profile has no skill named ${name}.)`);
|
|
34
|
+
}
|
|
35
|
+
return `# Skills this task pins\n\n${out.join('\n\n')}`;
|
|
36
|
+
}
|
package/board.mjs
CHANGED
|
@@ -31,7 +31,7 @@ function dispatchOnce(root) {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
/** Start the board's dispatcher for the home at `root`; answers `{ tick, stop }`, or null when it does not dispatch. */
|
|
34
|
-
export async function startBoard({ root, config = {}, log = () => {}, adapters = {} }) {
|
|
34
|
+
export async function startBoard({ root, config = {}, log = () => {}, adapters = {}, wake = null }) {
|
|
35
35
|
const kanban = config.kanban ?? {};
|
|
36
36
|
const off = String(process.env.HERMES_KANBAN_DISPATCH_IN_GATEWAY ?? '').trim().toLowerCase();
|
|
37
37
|
if (['0', 'false', 'no', 'off'].includes(off)) { log('info', 'board: not dispatched (HERMES_KANBAN_DISPATCH_IN_GATEWAY)'); return null; }
|
|
@@ -52,7 +52,7 @@ export async function startBoard({ root, config = {}, log = () => {}, adapters =
|
|
|
52
52
|
const { reports, error } = await dispatchOnce(root);
|
|
53
53
|
if (error) { log('warn', `board: tick failed: ${error}`); return; }
|
|
54
54
|
// chat-platform subscriptions are delivered here, through the daemon's own channel adapters
|
|
55
|
-
try { await notifyChats(root, adapters, log); } catch (error) { log('warn', `board: chat notices failed: ${error.message}`); }
|
|
55
|
+
try { await notifyChats(root, adapters, log, wake); } catch (error) { log('warn', `board: chat notices failed: ${error.message}`); }
|
|
56
56
|
for (const r of reports) {
|
|
57
57
|
const line = summary(r);
|
|
58
58
|
stuck[r.board] = r.ready_waiting && !r.started?.length && !r.paused ? (stuck[r.board] ?? 0) + 1 : 0;
|
package/hermes-cli.mjs
CHANGED
|
@@ -276,17 +276,12 @@ elif op == "approval":
|
|
|
276
276
|
elif out["verdict"] == "deny":
|
|
277
277
|
out["message"] = (f"BLOCKED: Command flagged as dangerous ({danger[2]}) but single-query mode (-q) runs without a user present to approve it. "
|
|
278
278
|
"Find an alternative approach that avoids this command. To allow dangerous commands in single-query mode, set approvals.single_query_mode: approve in config.yaml.")
|
|
279
|
-
elif op == "kanban_prompt":
|
|
280
|
-
from agent.prompt_builder import KANBAN_GUIDANCE
|
|
281
|
-
from agent.skill_commands import build_preloaded_skills_prompt
|
|
282
|
-
skills = build_preloaded_skills_prompt(a.get("skills") or [], task_id=a.get("task"))[0] if a.get("skills") else ""
|
|
283
|
-
out = {"guidance": KANBAN_GUIDANCE, "skills": skills or ""}
|
|
284
279
|
elif op == "schedule":
|
|
285
280
|
from cron.jobs import parse_schedule
|
|
286
281
|
try: out = parse_schedule(a["text"])
|
|
287
282
|
except ValueError as error: out = {"error": str(error)}
|
|
288
283
|
else:
|
|
289
|
-
import tools.memory_tool, tools.session_search_tool, tools.skills_tool
|
|
284
|
+
import tools.memory_tool, tools.session_search_tool, tools.skills_tool
|
|
290
285
|
from tools.registry import registry
|
|
291
286
|
kw = {}
|
|
292
287
|
if a["name"] == "memory": kw["store"] = memory_store()[0]
|
|
@@ -340,14 +335,6 @@ export async function hermesApproval(profileDir, { command = null, context = 'co
|
|
|
340
335
|
return runHermes(profileDir, { op: 'approval', command, context });
|
|
341
336
|
}
|
|
342
337
|
|
|
343
|
-
/**
|
|
344
|
-
* What Hermes's agent adds to a dispatched worker's system prompt: `KANBAN_GUIDANCE` (the task protocol)
|
|
345
|
-
* and the task's force-loaded skills (`build_preloaded_skills_prompt`, as `hermes --skills` loads them).
|
|
346
|
-
*/
|
|
347
|
-
export function kanbanPrompt(profileDir, { skills = [], task = null, env = {} } = {}) {
|
|
348
|
-
return runHermes(profileDir, { op: 'kanban_prompt', skills, task }, env);
|
|
349
|
-
}
|
|
350
|
-
|
|
351
338
|
/** The memory Hermes's agent would carry into a session starting now, as its system prompt carries it; '' for none. */
|
|
352
339
|
export async function memorySnapshot(profileDir) {
|
|
353
340
|
if (!hermesInstall()?.python) return '';
|
package/kanban-worker.mjs
CHANGED
|
@@ -12,7 +12,8 @@ import { SupercodeHarnessClient } from '@volter/supercode-harness-sdk';
|
|
|
12
12
|
import { load } from './orchestration.mjs';
|
|
13
13
|
import { INSTRUCTION_ARGUMENTS, activationCwd, activationEnv, sandboxArguments, approvalPolicy, limitArguments, materializePersona, materializeSkills, modelArguments, turnCounter, userHomeArguments, userHomePersona } from './activation.mjs';
|
|
14
14
|
import { workerRoute } from './worker-route.mjs';
|
|
15
|
-
import { KANBAN_ENV, untrustedServerVerdict, hermesApproval, hermesInstall, hermesToolEnv,
|
|
15
|
+
import { KANBAN_ENV, untrustedServerVerdict, hermesApproval, hermesInstall, hermesToolEnv, memorySnapshot, mirrorSession } from './hermes-cli.mjs';
|
|
16
|
+
import { pinnedSkills, taskProtocol } from './board/worker-prompt.mjs';
|
|
16
17
|
import { mountOrchestratorMcp } from './mcp/mount.mjs';
|
|
17
18
|
import { normalizeWorkerEvent, WORKER_STORE } from './loop.mjs';
|
|
18
19
|
import { permissionResponse } from './reducer.mjs';
|
|
@@ -65,8 +66,8 @@ export async function runKanbanWorker(argv, env = process.env) {
|
|
|
65
66
|
const runEnv = { ...hermesToolEnv(profile.dir), ...activationEnv({ root, profile, surfaceKey, vault }), ...route.env, ...task };
|
|
66
67
|
// Hermes's system prompt for a dispatched worker: the home's memory, the task protocol, the task's skills
|
|
67
68
|
const memory = await memorySnapshot(profile.dir).catch(() => '');
|
|
68
|
-
|
|
69
|
-
const instructions = [userHomePersona(profile), memory,
|
|
69
|
+
// the task protocol and the pinned skills, supercode's own (board/worker-prompt.mjs)
|
|
70
|
+
const instructions = [userHomePersona(profile), memory, taskProtocol(taskId), pinnedSkills(profile.dir, args.skills)].filter(Boolean).join('\n\n');
|
|
70
71
|
|
|
71
72
|
const client = new SupercodeHarnessClient(env.SUPERCODE_BIN ? { command: env.SUPERCODE_BIN } : {});
|
|
72
73
|
try {
|
package/loop.mjs
CHANGED
|
@@ -85,7 +85,14 @@ export class Orchestrator {
|
|
|
85
85
|
// reject unhandled, which Node treats as fatal and which stopped the daemon
|
|
86
86
|
if (this.autoTick) { this.timer = setInterval(() => { this.tick().catch(() => undefined); }, TICK_MS); this.timer.unref?.(); }
|
|
87
87
|
// the home's board, dispatched as a Hermes gateway dispatches it (docs/plans/hermes-compat.md row 14)
|
|
88
|
-
if (this.autoTick) this.board = await startBoard({
|
|
88
|
+
if (this.autoTick) this.board = await startBoard({
|
|
89
|
+
root: this.state.root, config: this.state.profiles.default?.residue?.config ?? {}, log: this.log, adapters: this.adapters,
|
|
90
|
+
// a board notice as a turn in the subscribed chat's own session (Hermes's wake), sent as the subscriber
|
|
91
|
+
wake: (sub, text) => this.dispatchLogged(events.inbound({
|
|
92
|
+
profile: sub.notifier_profile || 'default', key: { platform: sub.platform, kind: sub.chat_type || 'dm', chat_id: sub.chat_id, ...(sub.thread_id ? { thread_id: sub.thread_id } : {}) },
|
|
93
|
+
sender: sub.user_id || 'kanban-notifier', sender_name: 'board', text, at: this.clock(),
|
|
94
|
+
})),
|
|
95
|
+
});
|
|
89
96
|
}
|
|
90
97
|
|
|
91
98
|
/**
|
package/mcp/server.mjs
CHANGED
|
@@ -20,7 +20,8 @@ import { createInterface } from 'node:readline';
|
|
|
20
20
|
import { connect } from 'node:net';
|
|
21
21
|
import { join } from 'node:path';
|
|
22
22
|
import { KANBAN_TOOLS, PROTOCOL_VERSION, SERVER_NAME, TOOLS } from './tools.mjs';
|
|
23
|
-
import { KANBAN_ENV
|
|
23
|
+
import { KANBAN_ENV } from '../hermes-cli.mjs';
|
|
24
|
+
import { boardTool } from '../board/tools.mjs';
|
|
24
25
|
|
|
25
26
|
/** Env var naming the socket, when the loop wants one off the root's default. */
|
|
26
27
|
export const SOCKET_ENV = 'SUPERCODE_ORCHESTRATOR_SOCK';
|
|
@@ -51,13 +52,13 @@ const profileDir = (binding) => (binding.profile === 'default' ? binding.root :
|
|
|
51
52
|
const published = (binding) => (binding.kanban ? [...TOOLS, ...KANBAN_TOOLS] : TOOLS);
|
|
52
53
|
|
|
53
54
|
/**
|
|
54
|
-
* A board tool, answered by
|
|
55
|
-
* this worker's environment, as they are a Hermes worker's, never the daemon's.
|
|
55
|
+
* A board tool, answered by supercode's own board (board/tools.mjs) on the home's board file: the task, its run and
|
|
56
|
+
* its claim are this worker's environment, as they are a Hermes worker's, never the daemon's.
|
|
56
57
|
*/
|
|
57
58
|
async function kanbanCall(binding, name, args) {
|
|
58
59
|
try {
|
|
59
|
-
const out = await
|
|
60
|
-
return { ok: true, result: { ran: name, verbatim:
|
|
60
|
+
const out = await boardTool(binding.root, name, args, binding.kanban ?? {});
|
|
61
|
+
return { ok: true, result: { ran: name, verbatim: out } };
|
|
61
62
|
} catch (error) {
|
|
62
63
|
return { ok: false, error: error.message };
|
|
63
64
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-orchestrator",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.23",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The orchestrator runtime over the Volter Harness ontology: one typed operational model whose folder is its serialization, read and written through the harness orchestration doors (docs/ORCHESTRATOR-IR.md)",
|
|
6
6
|
"exports": {
|