@volter/supercode-orchestrator 0.4.32 → 0.4.33

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
@@ -13,7 +13,7 @@ import { holdDispatcherLock } from '../hermes-locks.mjs';
13
13
  import { boardPath, DEFAULT_BOARD, event, json, listBoards, now, openBoard, tx } from './store.mjs';
14
14
  import * as engine from './engine.mjs';
15
15
  import { adoptSession, boardLabel, boardSettings, lanesOf, localMachine, observeFleet, paused, startCard, tick } from './dispatch.mjs';
16
- import { loadWorkflow, useWorkflow, workflowEntry } from './workflow.mjs';
16
+ import { loadWorkflow, statusOf, useWorkflow, workflowEntry, workflowFor } from './workflow.mjs';
17
17
  import { evaluate } from './expr.mjs';
18
18
 
19
19
  const HELP = `supercode workflow <verb> [--root <home>] [--board <slug>] [--json]
@@ -26,7 +26,7 @@ Hermes Kanban's verbs, with their flags:
26
26
  swarm <goal> --worker PROFILE:TITLE[:SKILL,SKILL]... --verifier P --synthesizer P
27
27
  list [--mine] [--assignee] [--status] [--tenant] [--session] [--archived] [--sort] show <id> context <id> runs <id>
28
28
  assign <id> <profile|none> reassign <id> <profile> [--reclaim] [--reason] set-model <id> [model] [--provider] set-goal <id>... [--goal-max-turns N] [--off]
29
- claim <id> [--ttl] reclaim <id> [--reason] heartbeat <id> [--note] link|unlink <parent> <child>
29
+ claim <id> [--ttl] reclaim <id> [--reason] heartbeat <id> [--note] [--until <time|30m>] link|unlink <parent> <child>
30
30
  comment <id> <text> [--author] [--max-len] attach <id> <path> [--name] [--content-type] attachments <id> attach-rm <n>
31
31
  complete <id>... [--result] [--summary] [--metadata JSON] edit <id> --result [--summary] [--metadata]
32
32
  block <id> [reason] [--kind dependency|needs_input|capability|transient] [--ids ...] schedule <id> [reason] [--ids ...]
@@ -178,7 +178,8 @@ const ts = (t) => {
178
178
  const p = (n) => String(n).padStart(2, '0');
179
179
  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
180
180
  };
181
- const line = (t) => `${ICONS[t.status] ?? '?'} ${t.id} ${t.status.padEnd(8)} ${(t.assignee || '(unassigned)').padEnd(20)}${t.tenant ? ` [${t.tenant}]` : ''} ${t.title}${t.machine ? ` @${t.machine}` : ''}`;
181
+ // a card's status is the workflow's (triaging, reviewing, …); kanban.db stores the Hermes status it maps to
182
+ const line = (t) => `${ICONS[t.status] ?? '?'} ${t.id} ${(t.stage ?? t.status).padEnd(8)} ${(t.assignee || '(unassigned)').padEnd(20)}${t.tenant ? ` [${t.tenant}]` : ''} ${t.title}${t.machine ? ` @${t.machine}` : ''}`;
182
183
  const cardDict = (t) => ({
183
184
  id: t.id, title: t.title, body: t.body, assignee: t.assignee, status: t.status, priority: t.priority, tenant: t.tenant,
184
185
  workspace_kind: t.workspace_kind, workspace_path: t.workspace_path, branch_name: t.branch_name, project_id: t.project_id,
@@ -189,6 +190,15 @@ const cardDict = (t) => ({
189
190
  });
190
191
 
191
192
  /** Hermes's durations: seconds, or 30s / 5m / 2h / 1d. */
193
+ // a moment the caller names: an ISO time, or a duration from now (30m, 2h); epoch seconds, or null
194
+ function untilOf(v) {
195
+ if (v === undefined || v === null || v === '' || v === true) return null;
196
+ if (/^\d+(?:\.\d+)?[smhd]?$/i.test(String(v).trim())) return now() + duration(v);
197
+ const t = Date.parse(String(v));
198
+ if (Number.isNaN(t)) throw new Error(`malformed --until ${v} (expected an ISO time such as 2026-09-29T04:35Z, or a duration such as 2h)`);
199
+ return Math.floor(t / 1000);
200
+ }
201
+
192
202
  function duration(v) {
193
203
  if (v === undefined || v === null || v === '') return null;
194
204
  const s = String(v).trim().toLowerCase();
@@ -224,8 +234,9 @@ export async function runWorkflow(argv) {
224
234
  withBoard((b) => { const own = ownCard(b.db, sid); if (own) authorize(b.db, verb, rest, args, own); });
225
235
  }
226
236
  // the board's workflow (the IR's): what each verb does, and who may send it, is its
227
- if (!READS.has(verb) && verb !== 'help') await loadWorkflow(root).catch((error) => { if (verb !== 'init' && verb !== 'boards') throw error; });
228
- engine.setCaller((db, cardId) => rolesFor(db, cardId, sid));
237
+ // every verb reads the board through its workflow (a list settles cards too), so every verb loads it
238
+ if (verb !== 'help') await loadWorkflow(root).catch((error) => { if (verb !== 'init' && verb !== 'boards') throw error; });
239
+ engine.setCaller((db, cardId) => rolesFor(db, cardId, sid, workflowFor(root).params));
229
240
 
230
241
  switch (verb) {
231
242
  case 'init': {
@@ -245,15 +256,14 @@ export async function runWorkflow(argv) {
245
256
  const meta = boardMeta(root, slug);
246
257
  let workspace = args.workspace;
247
258
  if ((workspace === 'dir' || workspace === 'worktree') && meta.default_workdir) workspace = `${workspace}:${meta.default_workdir}`;
248
- // a home whose `kanban.goal_mode_default` is true makes every new card a goal card (a subtask is never started alone)
249
- const goalDefault = !args.goal && !args['no-goal'] && !args['subtask-of'] && (await laneState(root)).kanban.goal_mode_default === true;
259
+ // --goal / --no-goal are the create's words; without either, the workflow's initial decides
250
260
  const created = withBoard((b) => engine.createCard(b, {
251
261
  title: rest.join(' '), body: args.body, assignee: args.assignee === 'none' ? null : args.assignee, machine: args.machine,
252
262
  workspace, branch: args.branch, parents: args.parent, priority: args.priority, tenant: args.tenant,
253
263
  triage: args.triage, idempotencyKey: args['idempotency-key'], maxRuntime: duration(args['max-runtime']),
254
264
  maxRetries, skills: args.skill, model: args.model, provider: args.provider, createdBy: args['created-by'] ?? callerName(),
255
265
  sessionId: process.env.CLAUDE_CODE_SESSION_ID ?? null, subtaskOf: args['subtask-of'] ?? null, initialStatus: args['initial-status'] ?? null,
256
- project: args.project ?? meta.project_id ?? null, goal: args.goal || goalDefault ? { maxTurns: args['goal-max-turns'] ? Number(args['goal-max-turns']) : null } : null,
266
+ project: args.project ?? meta.project_id ?? null, goal: args.goal ? { maxTurns: args['goal-max-turns'] ? Number(args['goal-max-turns']) : null } : args['no-goal'] ? false : null,
257
267
  }), true);
258
268
  let answer = { id: created.id, existing: created.existing };
259
269
  if (!args['no-start'] && !created.existing && !args['subtask-of']) {
@@ -318,6 +328,7 @@ export async function runWorkflow(argv) {
318
328
  const got = engine.showCard(b.db, id);
319
329
  got.runs = engine.runs(b.db, id, { stateType: args['state-type'] ?? null, stateName: args['state-name'] ?? null });
320
330
  got.diagnostics = engine.diagnose(b.db, got.task, { failureLimit: settings.failureLimit });
331
+ got.stage = statusOf(b.db, got.task);
321
332
  return got;
322
333
  });
323
334
  const t = card.task;
@@ -326,7 +337,7 @@ export async function runWorkflow(argv) {
326
337
  say(args, '', { task: cardDict(t), latest_summary: latest, parents: card.parents.map((p) => p.id), children: card.children, subtasks: card.subtasks, comments: card.comments, events: card.events, runs: card.runs, subscriptions: card.subscriptions, diagnostics: card.diagnostics });
327
338
  return 0;
328
339
  }
329
- const out = [`Task ${t.id}: ${t.title}`, ` status: ${t.status}`, ` assignee: ${t.assignee || '-'}`];
340
+ const out = [`Task ${t.id}: ${t.title}`, ` status: ${card.stage}${card.stage !== t.status ? ` (${t.status} in kanban.db)` : ''}`, ` assignee: ${t.assignee || '-'}`];
330
341
  if (t.machine) out.push(` machine: ${t.machine}`);
331
342
  if (t.subtask_of) out.push(` subtask of: ${t.subtask_of}`);
332
343
  if (t.tenant) out.push(` tenant: ${t.tenant}`);
@@ -416,7 +427,7 @@ export async function runWorkflow(argv) {
416
427
  return 0;
417
428
  }
418
429
  case 'reclaim': need(1, 'a card id'); await withBoardAsync((b) => engine.reclaim(b, id, args.reason ?? (rest.slice(1).join(' ') || null))); say(args, `Reclaimed ${id}`, { ok: true }); return 0;
419
- case 'heartbeat': need(1, 'a card id'); await withBoardAsync((b) => engine.heartbeat(b, id, args.note ?? null)); say(args, `Heartbeat recorded for ${id}`, { ok: true }); return 0;
430
+ case 'heartbeat': need(1, 'a card id'); await withBoardAsync((b) => engine.heartbeat(b, id, args.note ?? null, { until: untilOf(args.until) })); say(args, `Heartbeat recorded for ${id}`, { ok: true }); return 0;
420
431
 
421
432
  case 'link': need(2, 'a parent and a child'); await withBoardAsync((b) => engine.link(b, rest[0], rest[1])); say(args, `Linked ${rest[0]} -> ${rest[1]}`, { ok: true }); return 0;
422
433
  case 'unlink': { need(2, 'a parent and a child'); const gone = await withBoardAsync((b) => engine.unlink(b, rest[0], rest[1])); say(args, gone ? `Unlinked ${rest[0]} -> ${rest[1]}` : `no link ${rest[0]} -> ${rest[1]}`, { ok: gone }); return gone ? 0 : 1; }
@@ -489,7 +500,7 @@ export async function runWorkflow(argv) {
489
500
  say(args, results.map(([t, s]) => `Unblocked ${t} → ${s}`).join('\n'), { ok: true, results }); return 0;
490
501
  }
491
502
  case 'request-review': need(1, 'a card id'); await withBoardAsync((b) => engine.requestReview(b, id, { summary: args.summary ?? null, reviewer: args.reviewer ?? null, metadata: metadataOf(args.metadata) ?? null, force: Boolean(args.force), expectedRunId: workerRun(b.db, id, sid) })); say(args, `${id} → review`, { ok: true }); return 0;
492
- case 'request-changes': need(2, 'a card id and the reason'); await withBoardAsync((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;
503
+ case 'request-changes': need(2, 'a card id and the reason'); await withBoardAsync((b) => engine.requestChanges(b, id, { reason: rest.slice(1).join(' '), reviewer: args.reviewer ?? null })); say(args, `${id} sent back for changes`, { ok: true }); return 0;
493
504
  case 'reopen': {
494
505
  // a done card back to work; the cards that built on it are invalidated with it (Hermes's reopen)
495
506
  need(1, 'a card id');
@@ -652,14 +663,13 @@ export async function runWorkflow(argv) {
652
663
 
653
664
  case 'dispatch': {
654
665
  const { lanes, settings } = await laneState(root);
655
- if (args['failure-limit']) settings.failureLimit = Number(args['failure-limit']);
656
- if (args.max) settings.maxInProgress = Number(args.max);
666
+ const params = { ...(args['failure-limit'] ? { failure_limit: Number(args['failure-limit']) } : {}), ...(args.max ? { max_spawn: Number(args.max) } : {}) };
657
667
  if (args['dry-run']) {
658
668
  const would = withBoard((b) => engine.listCards(b.db, { status: 'ready' }).filter((t) => t.assignee && lanes[t.assignee] && !t.subtask_of).map((t) => ({ id: t.id, assignee: t.assignee, machine: t.machine, surface: lanes[t.assignee].surface })));
659
669
  say(args, would.length ? would.map((w) => `would start ${w.id} as ${w.surface} for ${w.assignee}${w.machine ? ` on ${w.machine}` : ''}`).join('\n') : 'nothing would start', would);
660
670
  return 0;
661
671
  }
662
- const reports = await tick({ root, lanes, settings });
672
+ const reports = await tick({ root, lanes, settings, params });
663
673
  if (args.json) say(args, '', reports);
664
674
  else for (const r of reports) process.stdout.write(`${summary(r)}\n`);
665
675
  return reports.some((r) => r.error) ? 1 : 0;
@@ -963,8 +973,11 @@ const READS = new Set(['list', 'ls', 'show', 'context', 'runs', 'log', 'tail', '
963
973
  * is `manager`; the session of the card's current run has that run's role; the card's working session between runs
964
974
  * is its `implementer`; the session working a subtask's arc is `arc`; any other session is nobody on it.
965
975
  */
966
- function rolesFor(db, cardId, sessionId) {
976
+ function rolesFor(db, cardId, sessionId, params = {}) {
967
977
  if (!sessionId) return ['manager'];
978
+ // a session the workflow names as the board's manager (params.managers: its addresses) is the manager on every card
979
+ const managers = Array.isArray(params.managers) ? params.managers.map(String) : [];
980
+ if (managers.some((m) => m === sessionId || m.endsWith(`:${sessionId}`))) return ['manager'];
968
981
  const task = engine.getTask(db, cardId);
969
982
  if (!task) return [];
970
983
  const roles = [];
@@ -1024,11 +1037,9 @@ export function summary(r) {
1024
1037
  async function serve(root, args = {}) {
1025
1038
  const lock = await holdDispatcherLock(root);
1026
1039
  // Hermes's daemon flags: --interval seconds, --max starts per tick, --failure-limit, --pidfile, --verbose
1027
- const override = {
1028
- ...(Number(args.interval) > 0 ? { interval: Number(args.interval) } : {}),
1029
- ...(Number(args.max) > 0 ? { maxSpawn: Number(args.max) } : {}),
1030
- ...(Number(args['failure-limit']) > 0 ? { failureLimit: Number(args['failure-limit']) } : {}),
1031
- };
1040
+ const override = Number(args.interval) > 0 ? { interval: Number(args.interval) } : {};
1041
+ // --max and --failure-limit are params of every tick, read by the workflow as its own
1042
+ const params = { ...(Number(args.max) > 0 ? { max_spawn: Number(args.max) } : {}), ...(Number(args['failure-limit']) > 0 ? { failure_limit: Number(args['failure-limit']) } : {}) };
1032
1043
  if (args.pidfile) writeFileSync(args.pidfile, `${process.pid}\n`);
1033
1044
  let stopped = false;
1034
1045
  const stop = () => { stopped = true; };
@@ -1048,7 +1059,7 @@ async function serve(root, args = {}) {
1048
1059
  const state = await laneState(root);
1049
1060
  const settings = { ...state.settings, ...override };
1050
1061
  interval = settings.interval;
1051
- for (const r of await tick({ root, lanes: state.lanes, settings })) {
1062
+ for (const r of await tick({ root, lanes: state.lanes, settings, params })) {
1052
1063
  const said = summary(r);
1053
1064
  if (!said.endsWith('nothing to do') || args.verbose) log(said);
1054
1065
  if (args.verbose && r.ms) log(` took ${Object.entries(r.ms).map(([k, v]) => `${k} ${(v / 1000).toFixed(1)}s`).join(', ')}`);
@@ -30,13 +30,6 @@ import {
30
30
  } from './engine.mjs';
31
31
 
32
32
  const WORKER = fileURLToPath(new URL('../bin/hermes-worker.mjs', import.meta.url));
33
- const MAIL_KINDS = {
34
- creator: ['completed', 'blocked', 'gave_up', 'crashed', 'timed_out', 'status', 'archived', 'block_loop_detected', 'review_requested',
35
- 'changes_requested', 'session_started', 'session_resumed', 'session_replaced', 'session_adopted', 'session_lost', 'spawn_failed'],
36
- worker: ['commented', 'assigned', 'machine_set', 'blocked', 'unblocked', 'completed', 'archived', 'changes_requested', 'linked', 'unlinked', 'promoted_manual', 'subtask_added', 'session_adopted'],
37
- // a card the session's own card depends on: it sees it change
38
- dependency: ['completed', 'blocked', 'unblocked', 'archived', 'review_requested', 'changes_requested', 'commented'],
39
- };
40
33
 
41
34
  // ------------------------------------------------------------------ names and the fleet
42
35
 
@@ -107,30 +100,26 @@ function derivedMaxInProgress() {
107
100
  } catch { return null; }
108
101
  }
109
102
 
110
- /** The board's settings: the home's `kanban:` block, Hermes's keys and supercode's own (`machines`, `session_grace_seconds`). */
111
- export function boardSettings(kanban = {}) {
103
+ /**
104
+ * What the platform itself reads from the workflow's params (everything else the dispatcher does is the workflow's
105
+ * transitions and `dispatch`): the claim ttl, the session grace, the tick interval, log rotation, hooks, the failure
106
+ * limit the diagnostics report against, and Hermes's environment override for the crash grace.
107
+ */
108
+ export function boardSettings(params = {}) {
112
109
  const pos = (v) => (Number.isInteger(Number(v)) && Number(v) >= 1 ? Number(v) : null);
110
+ const env = (name, fallback) => {
111
+ const raw = process.env[name];
112
+ return raw !== undefined && raw !== '' && Number.isInteger(Number(raw)) && Number(raw) >= 0 ? Number(raw) : fallback;
113
+ };
113
114
  return {
114
- failureLimit: pos(kanban.failure_limit) ?? DEFAULT_FAILURE_LIMIT,
115
- maxInProgress: pos(kanban.max_in_progress) ?? derivedMaxInProgress(),
116
- 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,
117
- perProfile: pos(kanban.max_in_progress_per_profile),
118
- defaultAssignee: String(kanban.default_assignee ?? '').trim() || null,
119
- reviewDispatch: kanban.review_dispatch !== false,
120
- ttl: claimTtl(kanban.claim_ttl_seconds),
121
- grace: pos(kanban.session_grace_seconds) ?? 120,
122
- machines: Object.fromEntries(Object.entries(kanban.machines ?? {}).map(([name, m]) => [normalMachine(name), { capacity: pos(m?.capacity) }])),
123
- interval: Math.max(Number(kanban.dispatch_interval_seconds) || 60, 1),
124
- staleTimeout: pos(kanban.dispatch_stale_timeout_seconds),
125
- autoDecompose: kanban.auto_decompose !== false,
126
- autoDecomposePerTick: pos(kanban.auto_decompose_per_tick) ?? 3,
127
- goalMaxTurns: pos(kanban.goal_max_turns) ?? 20,
128
- maxSpawn: pos(kanban.max_spawn),
129
- reconcileOrphans: kanban.reconcile_orphans !== false,
130
- logRotateBytes: pos(kanban.worker_log_rotate_bytes) ?? 2 * 1024 * 1024,
131
- logBackups: Number.isInteger(Number(kanban.worker_log_backup_count)) && Number(kanban.worker_log_backup_count) >= 0 ? Number(kanban.worker_log_backup_count) : 1,
132
- hooks: kanban.hooks && typeof kanban.hooks === 'object' ? kanban.hooks : {},
133
- rateLimitCooldown: Number.isInteger(Number(process.env.HERMES_KANBAN_RATE_LIMIT_COOLDOWN_SECONDS)) && process.env.HERMES_KANBAN_RATE_LIMIT_COOLDOWN_SECONDS !== '' ? Number(process.env.HERMES_KANBAN_RATE_LIMIT_COOLDOWN_SECONDS) : 300,
115
+ failureLimit: pos(params.failure_limit) ?? DEFAULT_FAILURE_LIMIT,
116
+ ttl: claimTtl(params.claim_ttl_seconds),
117
+ grace: pos(params.session_grace_seconds) ?? 120,
118
+ interval: Math.max(Number(params.dispatch_interval_seconds) || 60, 1),
119
+ logRotateBytes: pos(params.worker_log_rotate_bytes) ?? 2 * 1024 * 1024,
120
+ logBackups: Number.isInteger(Number(params.worker_log_backup_count)) && Number(params.worker_log_backup_count) >= 0 ? Number(params.worker_log_backup_count) : 1,
121
+ hooks: params.hooks && typeof params.hooks === 'object' ? params.hooks : {},
122
+ crashGrace: env('HERMES_KANBAN_CRASH_GRACE_SECONDS', 30),
134
123
  };
135
124
  }
136
125
 
@@ -171,7 +160,8 @@ function supervise(board, fleet, settings, report, entry, effects) {
171
160
  const limits = limitsOf(db, board, entry, task, session);
172
161
  // Hermes's orphan reconciliation: a running card whose claim bookkeeping is broken, with nothing live behind it
173
162
  const live = session?.surface === 'headless' ? pidAlive(session.pid) : session?.session_id ? (fleet.ok ? sessionAlive(fleet, session) : true) : false;
174
- if (settings.reconcileOrphans && (!task.claim_lock || !task.claim_expires) && !live) {
163
+ // (whether it is reconciled is the workflow's: its `orphaned` transitions and their guard)
164
+ if ((!task.claim_lock || !task.claim_expires) && !live) {
175
165
  observe('orphaned', { reason: 'orphaned_running', claim_lock: task.claim_lock, claim_expires: task.claim_expires, error: 'orphaned running card (broken claim bookkeeping)' }, 'reclaimed');
176
166
  return;
177
167
  }
@@ -737,39 +727,6 @@ export function adoptSession(board, id, who, { workspace = null, ttl, assignee =
737
727
 
738
728
  // ------------------------------------------------------------------ notify
739
729
 
740
- function notice(kind, task, p, role) {
741
- const tid = task?.id ?? '?';
742
- const who = task?.assignee ? `@${task.assignee} ` : '';
743
- const title = task?.title ?? '';
744
- switch (kind) {
745
- case 'session_started': case 'session_resumed': case 'session_replaced': {
746
- const verb = { session_started: 'started', session_resumed: 'resumed', session_replaced: 'could not resume the card\'s last session and started' }[kind];
747
- return `▶ ${who}card ${tid} — ${title}: the board ${verb} session ${p.session_id} on ${p.machine} (pane ${p.pane}; ${p.address}).`;
748
- }
749
- case 'session_adopted': return role === 'worker'
750
- ? `▶ card ${tid} — ${title} is running again, with you: carry it on from where it stands (\`supercode workflow show ${tid}\` reads it).`
751
- : `▶ ${who}card ${tid} — ${title}: the board found session ${p.session_id} still live on ${p.machine} and kept it (${p.address}).`;
752
- case 'session_lost': return `✖ ${who}card ${tid}: session ${p.session_id} is gone from ${p.machine}; the board resumes it, and replaces it from the card only when it cannot.`;
753
- case 'spawn_failed': return `✖ ${who}card ${tid}: its session did not start: ${String(p.error ?? '').slice(0, 300)}`;
754
- case 'completed': return `✔ ${who}card ${tid} done — ${title}${p.summary ? `\n${String(p.summary).slice(0, 300)}` : ''}`;
755
- case 'blocked': return `⏸ ${who}card ${tid} blocked${p.reason ? `: ${String(p.reason).slice(0, 300)}` : ''}`;
756
- case 'unblocked': return `▶ card ${tid} unblocked`;
757
- case 'gave_up': return `✖ ${who}card ${tid} gave up after ${p.failures} failures${p.error ? `\n${String(p.error).slice(0, 300)}` : ''}`;
758
- case 'crashed': return `✖ ${who}card ${tid}: its worker exited without completing it; the board will retry`;
759
- case 'timed_out': return `⏱ ${who}card ${tid} timed out (max_runtime ${p.limit_seconds}s); the board will retry`;
760
- case 'status': return `🔄 ${who}card ${tid} → ${p.status}`;
761
- case 'archived': return role === 'worker' ? `card ${tid} archived` : null;
762
- case 'block_loop_detected': return `🛑 ${who}card ${tid} went to triage: blocked ${p.recurrences}x for the same cause${p.reason ? `: ${String(p.reason).slice(0, 200)}` : ''}`;
763
- case 'review_requested': return `👀 ${who}card ${tid} ready for review — ${title}${p.summary ? `\n${String(p.summary).slice(0, 300)}` : ''}`;
764
- case 'changes_requested': return `🛑 card ${tid}: review asked for changes: ${String(p.reason ?? 'reviewer feedback').slice(0, 300)}`;
765
- case 'assigned': return `card ${tid} is now assigned to ${p.assignee ?? 'nobody'}`;
766
- case 'machine_set': return `card ${tid} now runs on ${p.machine ?? 'no machine'}`;
767
- case 'linked': case 'unlinked': return `card ${tid}: dependency ${kind} (${p.parent} → ${p.child})`;
768
- case 'promoted_manual': return `card ${tid} promoted to ready by ${p.actor}`;
769
- case 'subtask_added': return `card ${tid} has a new subtask ${p.subtask}: ${p.title}`;
770
- default: return null;
771
- }
772
- }
773
730
 
774
731
  // a notice sent within a transaction's caller that cannot await (the launcher): the same door, synchronously
775
732
  function deliverNow(board, to, body, key) {
@@ -796,7 +753,22 @@ export const boardAddress = (label) => `sc:${localMachine()}:board:${label.repla
796
753
  /** Consecutive failed sends after which a subscription is dropped (Hermes's notifier drops a dead chat after 12). */
797
754
  const MAX_SEND_FAILURES = 12;
798
755
 
799
- async function notify(board, fleet, report) {
756
+ /**
757
+ * What an audience is told of an event: the workflow's `notify[audience][kind]` template over the card, the event's
758
+ * payload and (for a comment) the comment; null when the workflow names no such notice.
759
+ */
760
+ export function noticeOf(entry, audience, kind, task, payload, comment = null) {
761
+ const template = entry.workflow.notify?.[audience]?.[kind];
762
+ if (template === undefined || template === null) return null;
763
+ const cut = (text, n) => String(text ?? '').slice(0, Number(n));
764
+ const text = render(template, { card: task, event: payload ?? {}, comment, params: entry.params, cut });
765
+ return text.trim() ? text : null;
766
+ }
767
+
768
+ /** The event kinds an audience hears (the workflow's notify). */
769
+ const heardBy = (entry, audience) => Object.keys(entry.workflow.notify?.[audience] ?? {});
770
+
771
+ async function notify(board, fleet, report, entry) {
800
772
  const from = boardAddress(board.label);
801
773
  // each subscription's notices go in order; different subscribers are told side by side
802
774
  await pool(subscriptions(board.db).filter((sub) => sub.platform === 'supercode'), SEND_CONCURRENCY, async (sub) => {
@@ -806,7 +778,8 @@ async function notify(board, fleet, report) {
806
778
  tx(board, (db) => db.prepare("DELETE FROM kanban_notify_subs WHERE task_id = ? AND platform = 'supercode' AND chat_id = ?").run(sub.task_id, sub.chat_id));
807
779
  return;
808
780
  }
809
- const kinds = MAIL_KINDS[role];
781
+ const kinds = heardBy(entry, role);
782
+ if (!kinds.length) return;
810
783
  const events = board.db.prepare(`SELECT id, kind, payload, created_at FROM task_events WHERE task_id = ? AND id > ? AND kind IN (${kinds.map(() => '?').join(', ')}) ORDER BY id`)
811
784
  .all(sub.task_id, sub.last_event_id, ...kinds);
812
785
  if (!events.length) return;
@@ -819,9 +792,9 @@ async function notify(board, fleet, report) {
819
792
  let text;
820
793
  if (ev.kind === 'commented') {
821
794
  if (p.author === sub.chat_id) { cursor = ev.id; continue; }
822
- const c = board.db.prepare('SELECT body FROM task_comments WHERE task_id = ? AND author = ? AND created_at >= ? ORDER BY id LIMIT 1').get(sub.task_id, p.author, ev.created_at);
823
- text = c ? `💬 card ${sub.task_id}, ${p.author}:\n${c.body}` : null;
824
- } else text = notice(ev.kind, task, p, role);
795
+ const c = board.db.prepare('SELECT author, body FROM task_comments WHERE task_id = ? AND author = ? AND created_at >= ? ORDER BY id LIMIT 1').get(sub.task_id, p.author, ev.created_at);
796
+ text = c ? noticeOf(entry, role, ev.kind, task, p, c) : null;
797
+ } else text = noticeOf(entry, role, ev.kind, task, p);
825
798
  if (text) {
826
799
  // Hermes's delivery modes: `notify` is passive (read at the session's next turn), `wake`/`notify+wake` start its turn
827
800
  await send(from, board.label, sub.chat_id, text, `board-${sub.task_id}-${ev.id}-${sub.chat_id.slice(-12)}`, { queue: sub.delivery_mode === 'notify' });
@@ -846,7 +819,6 @@ async function notify(board, fleet, report) {
846
819
  }
847
820
 
848
821
  // Hermes's terminal kinds for a chat subscriber, in its order of silence: archived and unblocked move the cursor and say nothing
849
- const CHAT_KINDS = ['completed', 'blocked', 'gave_up', 'crashed', 'timed_out', 'status', 'archived', 'unblocked', 'block_loop_detected', 'review_requested', 'changes_requested'];
850
822
 
851
823
  /**
852
824
  * A chat-platform subscription's events, sent through the orchestrator's own channel adapters (the daemon hosts them;
@@ -854,6 +826,10 @@ const CHAT_KINDS = ['completed', 'blocked', 'gave_up', 'crashed', 'timed_out', '
854
826
  */
855
827
  export async function notifyChats(root, adapters, log = () => {}, wake = null) {
856
828
  const sent = [];
829
+ // what a chat is told is the workflow's (notify.chat)
830
+ const entry = await loadWorkflow(root, { fresh: true });
831
+ const kinds = heardBy(entry, 'chat');
832
+ if (!kinds.length) return sent;
857
833
  for (const slug of listBoards(root)) {
858
834
  const board = openBoard(root, slug);
859
835
  board.label = boardLabel(root, slug);
@@ -861,14 +837,14 @@ export async function notifyChats(root, adapters, log = () => {}, wake = null) {
861
837
  for (const sub of subscriptions(board.db)) {
862
838
  const adapter = adapters?.[sub.platform];
863
839
  if (sub.platform === 'supercode' || !adapter) continue;
864
- const events = board.db.prepare(`SELECT id, kind, payload FROM task_events WHERE task_id = ? AND id > ? AND kind IN (${CHAT_KINDS.map(() => '?').join(', ')}) ORDER BY id`).all(sub.task_id, sub.last_event_id, ...CHAT_KINDS);
840
+ const events = board.db.prepare(`SELECT id, kind, payload FROM task_events WHERE task_id = ? AND id > ? AND kind IN (${kinds.map(() => '?').join(', ')}) ORDER BY id`).all(sub.task_id, sub.last_event_id, ...kinds);
865
841
  if (!events.length) continue;
866
842
  const task = tx(board, (db) => getTask(db, sub.task_id));
867
843
  let cursor = Number(sub.last_event_id);
868
844
  let failed = false;
869
845
  for (const ev of events) {
870
846
  const payload = json(ev.payload, {}) ?? {};
871
- const text = ['archived', 'unblocked'].includes(ev.kind) ? null : notice(ev.kind, task, payload, 'creator');
847
+ const text = noticeOf(entry, 'chat', ev.kind, task, payload);
872
848
  if (text) {
873
849
  // a completed card's deliverables go with it as uploads, as Hermes's notifier sends them
874
850
  const files = ev.kind === 'completed' ? artifactsOf(payload, task).map((path) => ({ path })) : [];
@@ -1063,8 +1039,10 @@ const CORRUPT = /malformed|not a database|corrupt|disk image/i;
1063
1039
  * `only` limits starting to one card (a create that answers whether its card started). Each board is ticked under its
1064
1040
  * per-tick lock (Hermes's `.dispatch.lock`), skipped when another dispatcher holds it. Answers one report per board.
1065
1041
  */
1066
- export async function tick({ root, lanes, settings, only = null, slugs = null, dispatch = true }) {
1067
- const entry = await loadWorkflow(root, { fresh: true });
1042
+ export async function tick({ root, lanes, settings, only = null, slugs = null, dispatch = true, params = {} }) {
1043
+ // a verb's flags (dispatch --max, --failure-limit) are params of this tick, read by the workflow as its own
1044
+ const loaded = await loadWorkflow(root, { fresh: true });
1045
+ const entry = { ...loaded, params: { ...loaded.params, ...params } };
1068
1046
  const needFleet = Object.values(lanes).some((l) => l.surface === 'pane');
1069
1047
  const fleetAt = Date.now();
1070
1048
  const fleet = needFleet ? observeFleet() : { ok: false, alive: new Set(), online: new Set(), sessions: [], error: 'no pane lanes' };
@@ -1103,7 +1081,7 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
1103
1081
  // what the moves asked for, once they are committed (messages to actors, closed panes, cleaned workspaces)
1104
1082
  await runEffects(board, effects);
1105
1083
  phase('goals');
1106
- await notify(board, fleet, report); phase('notify');
1084
+ await notify(board, fleet, report, entry); phase('notify');
1107
1085
  fireHooks(board, settings, report);
1108
1086
  if (String(board.db.prepare('PRAGMA journal_mode').get()?.journal_mode).toLowerCase() === 'wal') board.db.prepare('PRAGMA wal_checkpoint(PASSIVE)').get();
1109
1087
  report.ready_waiting = Number(board.db.prepare("SELECT COUNT(*) AS n FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id WHERE t.status = 'ready' AND t.assignee IS NOT NULL AND c.subtask_of IS NULL").get().n);
package/board/engine.mjs CHANGED
@@ -12,7 +12,7 @@ import { existsSync, rmSync } from 'node:fs';
12
12
  import { join, resolve } from 'node:path';
13
13
  import { DatabaseSync } from 'node:sqlite';
14
14
  import { claimer, event, json, newTaskId, now, tx } from './store.mjs';
15
- import { fire, initialStatus, loadWorkflow, setStatus, settle, statusOf, workflowFor } from './workflow.mjs';
15
+ import { fire, loadWorkflow, setStatus, settle, startNew, statusOf, workflowFor } from './workflow.mjs';
16
16
 
17
17
  export const STATUSES = ['triage', 'todo', 'scheduled', 'ready', 'running', 'blocked', 'review', 'done', 'archived'];
18
18
  export const BLOCK_KINDS = ['dependency', 'needs_input', 'capability', 'transient'];
@@ -203,8 +203,9 @@ export function createCard(board, spec) {
203
203
  for (const p of parents) run(db, 'INSERT OR IGNORE INTO task_links (parent_id, child_id) VALUES (?, ?)', p, id);
204
204
  // where it starts is the workflow's `initial` (Hermes: triage, blocked, todo behind open parents, else ready)
205
205
  const entry = workflowFor(board.root);
206
- const status = initialStatus(db, board, entry, getTask(db, id), { triage: Boolean(spec.triage), initial_status: initial });
207
- setStatus(db, entry.workflow, id, status);
206
+ // the create's own words: whether it asked to be a goal card (null: the workflow decides), triage, its arc
207
+ const status = startNew(db, board, entry, getTask(db, id), { triage: Boolean(spec.triage), initial_status: initial,
208
+ goal: spec.goal === undefined || spec.goal === null ? null : spec.goal !== false, subtask_of: arc?.id ?? null });
208
209
  event(db, id, 'created', { assignee: spec.assignee || null, status, parents, tenant: spec.tenant ?? null, ...(machine ? { machine } : {}), ...(arc ? { subtask_of: arc.id } : {}) });
209
210
  if (parents.length) inheritSubscriptions(db, id, parents);
210
211
  if (arc) {
@@ -449,7 +450,7 @@ export const reclaim = (board, id, reason = null, { roles } = {}) => act(board,
449
450
  export const archive = (board, id, { roles } = {}) => act(board, id, 'archive', { roles });
450
451
  export const specify = (board, id, { title = null, body = null, author = 'user', roles } = {}) => act(board, id, 'specify', { roles, payload: { title, body, author } });
451
452
  export const editResult = (board, id, { result, summary = null, metadata = undefined, roles } = {}) => act(board, id, 'edit', { roles, payload: { result, summary, metadata } });
452
- export const heartbeat = (board, id, note = null, { roles } = {}) => act(board, id, 'heartbeat', { roles, payload: { note } });
453
+ export const heartbeat = (board, id, note = null, { roles, until = null } = {}) => act(board, id, 'heartbeat', { roles, payload: { note, until } });
453
454
  export const setModel = (board, id, model = null, provider = null, { roles } = {}) => {
454
455
  if (provider && !model) throw new Error('a provider needs a model');
455
456
  return act(board, id, 'set_model', { roles, payload: { model: model || null, provider: provider || null } });
@@ -539,7 +540,7 @@ export const subscriptions = (db, id = null) => (id ? all(db, 'SELECT * FROM kan
539
540
  // ------------------------------------------------------------------ reading
540
541
 
541
542
  export function listCards(db, { status = null, assignee = null, machine = null, includeArchived = false } = {}) {
542
- const rows = all(db, `SELECT t.*, c.machine, c.subtask_of FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id
543
+ const rows = all(db, `SELECT t.*, c.machine, c.subtask_of, c.stage FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id
543
544
  WHERE (? IS NULL OR t.status = ?) AND (? IS NULL OR t.assignee = ?) AND (? IS NULL OR c.machine = ?) AND (? OR t.status != 'archived')
544
545
  ORDER BY t.priority DESC, t.created_at ASC`, status, status, assignee, assignee, machine, machine, includeArchived ? 1 : 0);
545
546
  return rows.map((r) => ({ ...r, skills: json(r.skills, []) ?? [] }));
@@ -98,10 +98,14 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {} } = {}
98
98
  queue_reason: json(one(db, "SELECT payload FROM task_events WHERE task_id = ? AND kind = 'respawn_guarded' AND created_at >= ? ORDER BY id DESC LIMIT 1",
99
99
  task.id, Number(one(db, 'SELECT since FROM supercode_cards WHERE task_id = ?', task.id)?.since ?? 0))?.payload, {})?.reason ?? null,
100
100
  last_block_event: one(db, "SELECT kind FROM task_events WHERE task_id = ? AND kind IN ('blocked', 'unblocked') ORDER BY id DESC LIMIT 1", task.id)?.kind ?? null,
101
- resume_status: ['resume_status', 'retry_status', 'source_status'].some((k) => interruption[k] === 'review') ? 'review' : 'ready',
101
+ // where the latest interruption says the card resumes (any status the workflow declares); none recorded: null
102
+ resume_status: interruption.resume_status ?? interruption.retry_status ?? interruption.source_status ?? null,
102
103
  last_run: lastRun ?? {},
103
104
  last_completed: lastCompleted ?? null,
104
105
  events_since: (t, kinds = []) => all(db, `SELECT kind FROM task_events WHERE task_id = ? AND created_at >= ?${kinds.length ? ` AND kind IN (${kinds.map(() => '?').join(', ')})` : ''}`, task.id, t, ...kinds),
106
+ // the latest event of each kind on the card: kind → { created_at, payload }
107
+ last_events: Object.fromEntries(all(db, 'SELECT e.kind, e.created_at, e.payload FROM task_events e JOIN (SELECT kind, MAX(id) AS id FROM task_events WHERE task_id = ? GROUP BY kind) l ON l.id = e.id', task.id)
108
+ .map((r) => [r.kind, { created_at: r.created_at, payload: json(r.payload, {}) ?? {} }])),
105
109
  comments_since: (t) => all(db, 'SELECT author, body, created_at FROM task_comments WHERE task_id = ? AND created_at >= ?', task.id, t),
106
110
  };
107
111
  return {
@@ -235,12 +239,20 @@ function dueAfter(db, workflow, task, status, scope) {
235
239
  return null;
236
240
  }
237
241
 
238
- /** Where a new card starts: the first `initial` transition that holds (`event` is the create's own words). */
239
- export function initialStatus(db, board, entry, task, event) {
240
- const scope = scopeOf(db, board, task, { params: entry.params, event });
242
+ /**
243
+ * Where a new card starts: the first `initial` transition that holds (`event` is the create's own words), its status
244
+ * set and its actions run (a goal card by the workflow's default, for one). Answers the status.
245
+ */
246
+ export function startNew(db, board, entry, task, event) {
247
+ const scope = scopeOf(db, board, task, { params: entry.params, event, extra: { lanes: lanesIn(entry) } });
241
248
  const t = entry.workflow.initial.find((x) => holds(x.guard ?? null, scope));
242
249
  if (!t?.target) throw new Error('the workflow says nowhere for a new card to start');
243
- return String(templateValue(t.target, scope));
250
+ const status = String(templateValue(t.target, scope));
251
+ if (!entry.workflow.statuses[status]) throw new Error(`the workflow starts a new card in ${status}, which it does not declare`);
252
+ setStatus(db, entry.workflow, task.id, status);
253
+ const ctx = { db, board, entry, id: task.id, scope: { ...scope, before: scope.card }, from: status, to: status, effects: [], runId: null };
254
+ for (const a of t.actions ?? []) act(ctx, a);
255
+ return status;
244
256
  }
245
257
 
246
258
  // ------------------------------------------------------------------ actions
@@ -270,7 +282,8 @@ const FORMAT = {
270
282
  block_loop_detected: ({ e, after, source, params }) => ({ reason: e.reason ?? null, kind: e.kind ?? null, recurrences: Number(after.block_recurrences || 0), limit: params.block_recurrence_limit, source_status: source }),
271
283
  dependency_wait: ({ e, source }) => ({ reason: e.reason ?? null, kind: e.kind ?? null, source_status: source }),
272
284
  review_requested: ({ e, before, after }) => ({ summary: firstLine(e.summary), implementer: before.assignee, reviewer: after.assignee !== before.assignee ? after.assignee : (e.reviewer ?? null) }),
273
- changes_requested: ({ e, after }) => ({ reason: e.reason ?? null, reviewer: e.reviewer ?? null, implementer: after.assignee }),
285
+ // the reviewer is the lane that reviewed (the card's assignee while in review), unless the verdict names one
286
+ changes_requested: ({ e, before, after }) => ({ reason: e.reason ?? null, reviewer: e.reviewer ?? before.assignee ?? null, implementer: after.assignee }),
274
287
  review_reopened: ({ after, to }) => ({ status: to, implementer: after.assignee }),
275
288
  unblocked: ({ to }) => (to !== 'ready' ? { status: to, resume_status: to } : null),
276
289
  gave_up: ({ e, after, params, outcome }) => ({ error: String(e.error ?? '').slice(0, 500) || null, failures: Number(after.consecutive_failures || 0), limit: after.max_retries ?? params.failure_limit, outcome }),
@@ -283,7 +296,7 @@ const FORMAT = {
283
296
  assigned: ({ after }) => ({ assignee: after.assignee ?? null }),
284
297
  model_override_set: ({ e }) => ({ model: e.model || null, provider: e.provider || null }),
285
298
  goal_set: ({ after }) => ({ goal_mode: Boolean(after.goal_mode), goal_max_turns: after.goal_mode ? after.goal_max_turns ?? null : null }),
286
- heartbeat: ({ e }) => (e.note ? { note: e.note } : null),
299
+ heartbeat: ({ e }) => (e.note || e.until ? { ...(e.note ? { note: e.note } : {}), ...(e.until ? { until: Number(e.until) } : {}) } : null),
287
300
  commented: ({ e }) => ({ author: String(e.author ?? 'user').trim(), len: String(e.body ?? '').length }),
288
301
  archived: () => null,
289
302
  status: ({ e, before, to }) => ({ status: to, reason: e.reason ?? 'reopened', previous_status: before.stored_as }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-orchestrator",
3
- "version": "0.4.32",
3
+ "version": "0.4.33",
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": {