@volter/supercode-orchestrator 0.4.22 → 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 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 { lanes, settings } = await laneState(root);
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;
@@ -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) < 30) return;
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 headlessStarted = 0;
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
- if (!why && lane.surface !== 'pane') {
361
- // a one-shot worker runs on this machine: its memory, and Hermes's respawn guard
362
- if (pressure === 'critical' || (pressure === 'elevated' && headlessStarted >= 1)) why = `this machine's memory pressure is ${pressure}; the card starts when it eases`;
363
- else why = respawnGuard(db, task, settings, row.status);
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') headlessStarted += 1;
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]);
@@ -664,9 +676,9 @@ function notice(kind, task, p, role) {
664
676
  }
665
677
  }
666
678
 
667
- function send(from, label, to, body, key) {
679
+ function send(from, label, to, body, key, { queue = false } = {}) {
668
680
  const request = {
669
- op: 'send', from, from_name: `${label} board`, to, queue: false, id: key,
681
+ op: 'send', from, from_name: `${label} board`, to, queue, id: key,
670
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.)`,
671
683
  };
672
684
  const r = sc(['message', 'remote-deliver'], { input: JSON.stringify(request), timeout: 60_000 });
@@ -707,7 +719,8 @@ function notify(board, fleet, report) {
707
719
  text = c ? `💬 card ${sub.task_id}, ${p.author}:\n${c.body}` : null;
708
720
  } else text = notice(ev.kind, task, p, role);
709
721
  if (text) {
710
- send(from, board.label, sub.chat_id, text, `board-${sub.task_id}-${ev.id}-${sub.chat_id.slice(-12)}`);
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' });
711
724
  report.notified.push([sub.task_id, ev.kind, sub.chat_id]);
712
725
  }
713
726
  cursor = ev.id;
@@ -735,7 +748,7 @@ const CHAT_KINDS = ['completed', 'blocked', 'gave_up', 'crashed', 'timed_out', '
735
748
  * A chat-platform subscription's events, sent through the orchestrator's own channel adapters (the daemon hosts them;
736
749
  * `serve` hosts none, as a Hermes CLI without its gateway delivers none). Answers what was sent and what failed.
737
750
  */
738
- export async function notifyChats(root, adapters, log = () => {}) {
751
+ export async function notifyChats(root, adapters, log = () => {}, wake = null) {
739
752
  const sent = [];
740
753
  for (const slug of listBoards(root)) {
741
754
  const board = openBoard(root, slug);
@@ -755,6 +768,12 @@ export async function notifyChats(root, adapters, log = () => {}) {
755
768
  if (text) {
756
769
  // a completed card's deliverables go with it as uploads, as Hermes's notifier sends them
757
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; }
758
777
  try {
759
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 } : {}) });
760
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 keep = metadata === undefined ? one(db, 'SELECT metadata FROM task_runs WHERE id = ?', runId)?.metadata ?? null : (metadata ? JSON.stringify(metadata) : null);
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') {
@@ -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, tools.kanban_tools
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, kanbanPrompt, memorySnapshot, mirrorSession } from './hermes-cli.mjs';
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
- const board = await kanbanPrompt(profile.dir, { skills: args.skills, task: taskId, env: task });
69
- const instructions = [userHomePersona(profile), memory, board.guidance, board.skills].filter(Boolean).join('\n\n');
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({ root: this.state.root, config: this.state.profiles.default?.residue?.config ?? {}, log: this.log, adapters: this.adapters });
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, hermesTool } from '../hermes-cli.mjs';
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 Hermes's own handler in this process: the task, its run and its claim are
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 hermesTool(profileDir(binding), name, args, { env: binding.kanban });
60
- return { ok: true, result: { ran: name, verbatim: typeof out === 'string' ? out : JSON.stringify(out) } };
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.22",
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": {