@volter/supercode-orchestrator 0.4.129 → 0.4.131

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/ci.mjs ADDED
@@ -0,0 +1,65 @@
1
+ // Deployment evidence is observed before a board mutation. No network or Git
2
+ // process runs inside the optimistic transaction that decides completion.
3
+ import { spawnSync } from 'node:child_process';
4
+
5
+ const observations = new Map();
6
+ const key = (board, id) => `${board.root}\0${board.slug}\0${id}`;
7
+ const run = (program, args, cwd) => {
8
+ const result = spawnSync(program, args, { cwd, encoding: 'utf8', timeout: 30_000, maxBuffer: 2 * 1024 * 1024 });
9
+ if (result.status !== 0) throw new Error(`${program}: ${(result.stderr || result.error?.message || 'command failed').trim().slice(-500)}`);
10
+ return result.stdout.trim();
11
+ };
12
+ const codePath = path => path && !/^(?:docs\/|\.volter\/tracker\/)/.test(path) && (!/\.(?:md|txt|png|jpg|svg)$/i.test(path) || /(?:^|\/)prompts\//.test(path));
13
+
14
+ export function completionEvidence(board, id) {
15
+ return observations.get(key(board, id)) ?? { required: true, ready: false, reason: 'Deployment evidence has not been observed; request review or done through the board CLI with --commit and --ci-run.' };
16
+ }
17
+
18
+ export function observeCompletionEvidence(board, id, entry, payload = {}) {
19
+ const state = { required: true, ready: false, reason: null };
20
+ observations.set(key(board, id), state);
21
+ if (!entry.params.completion_ci) { Object.assign(state, { required: false, ready: true }); return state; }
22
+ try {
23
+ const task = board.db.prepare('SELECT t.*,c.subtask_of,c.workspace_anchor FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.id=?').get(id);
24
+ const parent = task?.subtask_of && board.db.prepare('SELECT workspace_path FROM tasks WHERE id=?').get(task.subtask_of);
25
+ const path = task?.workspace_path ?? parent?.workspace_path;
26
+ const old = board.db.prepare("SELECT payload FROM task_events WHERE task_id=? AND kind IN ('review_requested','state_requested','review_pass') ORDER BY id DESC LIMIT 20").all(id)
27
+ .map(row => { try { return JSON.parse(row.payload)?.ci; } catch { return null; } }).find(ci => ci?.commit);
28
+ const supplied = payload.commit ?? payload.metadata?.commit ?? old?.commit;
29
+ if (!path) {
30
+ if (supplied) throw new Error('The named commit needs a resolved repository workspace.');
31
+ Object.assign(state, { required: false, ready: true }); return state;
32
+ }
33
+ const history = run('git', ['log', '--all', '--format=%H%x00', '--name-only', '--fixed-strings', `--grep=${id}`], path);
34
+ const records = [...history.matchAll(/([a-f0-9]{40})\0([^\0]*?)(?=[a-f0-9]{40}\0|$)/gs)];
35
+ const latest = records.find(([, , names]) => names.split('\n').some(codePath))?.[1];
36
+ if (!latest && !supplied) { Object.assign(state, { required: false, ready: true }); return state; }
37
+ const commit = run('git', ['rev-parse', '--verify', `${supplied ?? latest}^{commit}`], path);
38
+ if (latest) run('git', ['merge-base', '--is-ancestor', latest, commit], path);
39
+ const remote = run('git', ['remote', 'get-url', 'origin'], path);
40
+ const repo = /(?:github\.com[:/])([^/]+\/[^/]+?)(?:\.git)?$/.exec(remote)?.[1];
41
+ if (!repo) throw new Error('Deployment evidence requires this workspace\'s GitHub origin.');
42
+ Object.assign(state, { commit, repository: repo });
43
+ const configured = entry.params.completion_ci.workflows?.[repo] ?? entry.params.completion_ci.workflows?.['*'];
44
+ if (!Array.isArray(configured) || !configured.length) throw new Error(`No deployment workflow is declared for ${repo} in completion_ci.workflows.`);
45
+ const matchedRules = (entry.params.completion_ci.path_workflows?.[repo] ?? []).filter(rule =>
46
+ records.some(([, , names]) => names.split('\n').some(path => rule.paths.some(prefix => path.startsWith(prefix)))));
47
+ const deploymentWorkflows = matchedRules.length ? configured.filter(name => matchedRules.every(rule => rule.workflows.includes(name))) : configured;
48
+ const requestedRun = payload.ci_run ?? payload['ci-run'] ?? payload.metadata?.ci_run ?? (old?.commit === commit ? old?.run_id : null);
49
+ let receipt;
50
+ if (requestedRun) {
51
+ receipt = JSON.parse(run('gh', ['run', 'view', String(requestedRun), '--repo', repo, '--json', 'databaseId,workflowName,headSha,status,conclusion,url'], path));
52
+ } else {
53
+ const runs = JSON.parse(run('gh', ['run', 'list', '--repo', repo, '--commit', commit, '--limit', '100', '--json', 'databaseId,workflowName,headSha,status,conclusion,url'], path));
54
+ receipt = runs.find(row => deploymentWorkflows.includes(row.workflowName));
55
+ }
56
+ if (!receipt) throw new Error(`No deployment CI run exists for ${repo}@${commit}.`);
57
+ Object.assign(state, { run_id: receipt.databaseId, workflow: receipt.workflowName, status: receipt.status, conclusion: receipt.conclusion, url: receipt.url });
58
+ if (receipt.headSha !== commit) throw new Error(`CI run ${receipt.databaseId} is for ${receipt.headSha}, not ${commit}.`);
59
+ if (!deploymentWorkflows.includes(receipt.workflowName)) throw new Error(`${receipt.workflowName} is not a declared deployment workflow for ${repo}.`);
60
+ if (receipt.status !== 'completed' || receipt.conclusion !== 'success') throw new Error(`Deployment CI ${receipt.databaseId} is ${receipt.status}/${receipt.conclusion || 'pending'}.`);
61
+ state.ready = true;
62
+ state.reason = null;
63
+ } catch (error) { state.reason = error.message; }
64
+ return state;
65
+ }
package/board/cli.mjs CHANGED
@@ -537,7 +537,7 @@ export async function runWorkflow(argv) {
537
537
  case 'complete': {
538
538
  need(1, 'a card id');
539
539
  const metadata = metadataOf(args.metadata);
540
- for (const t of rest) await withBoardAsync((b) => engine.complete(b, t, { summary: args.summary ?? null, result: args.result ?? null, metadata: metadata ?? null }));
540
+ for (const t of rest) await withBoardAsync((b) => engine.complete(b, t, { summary: args.summary ?? null, result: args.result ?? null, metadata: metadata ?? null, commit: args.commit ?? null, ci_run: args['ci-run'] ?? null }));
541
541
  say(args, rest.map((t) => `Completed ${t}`).join('\n'), { ok: true, completed: rest }); return 0;
542
542
  }
543
543
  case 'edit': {
@@ -567,7 +567,7 @@ export async function runWorkflow(argv) {
567
567
  if (args.reason) for (const [t] of results) await withBoardAsync((b) => engine.comment(b, t, callerName(), `UNBLOCK: ${args.reason}`));
568
568
  say(args, results.map(([t, s]) => `Unblocked ${t} → ${s}`).join('\n'), { ok: true, results }); return 0;
569
569
  }
570
- 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;
570
+ 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, commit: args.commit ?? null, ci_run: args['ci-run'] ?? null, force: Boolean(args.force), expectedRunId: workerRun(b.db, id, sid) })); say(args, `${id} → review`, { ok: true }); return 0;
571
571
  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;
572
572
  case 'reopen': {
573
573
  // a done card back to work; the cards that built on it are invalidated with it (Hermes's reopen)
@@ -856,7 +856,7 @@ export async function runWorkflow(argv) {
856
856
  if (!declared) throw new Error(`no workflow verb ${verb}; \`supercode workflow help\` lists them`);
857
857
  need(1, 'a card id');
858
858
  const { _: _words, root: _r, board: _b, json: _j, at: _a, ids: _i, ...fields } = args;
859
- const payload = { ...fields, waiting_on: args['waiting-on'] ?? fields.waiting_on, reason: args.reason ?? (rest.slice(1).join(' ') || null) };
859
+ const payload = { ...fields, ci_run: args['ci-run'] ?? fields.ci_run, deployed_sha: args['deployed-sha'] ?? fields.deployed_sha, waiting_on: args['waiting-on'] ?? fields.waiting_on, reason: args.reason ?? (rest.slice(1).join(' ') || null) };
860
860
  const results = [];
861
861
  for (const t of [id, ...(args.ids ?? [])]) results.push([t, (await withBoardAsync((b) => engine.act(b, t, name, { payload }))).to]);
862
862
  say(args, results.map(([t, s]) => name === 'request_state' ? `${t}: ${payload.state} requested; state remains ${s}. Mail delivery is recorded on the card.` : `${t} → ${s}`).join('\n'), { ok: true, results }); return 0;
@@ -450,16 +450,23 @@ function startCards(board, fleet, lanes, settings, report, entry, effects, { onl
450
450
  return { target, runSpec: spec, profile, lane: profile ? lanes[profile] ?? null : null };
451
451
  };
452
452
  const candidates = [];
453
+ const waiting = (task, why) => {
454
+ report.queued.push([task.id, why]);
455
+ if (!dry) tx(board, db => noteGuard(db, task.id, why));
456
+ };
453
457
  for (const task of everyCard) {
454
458
  if (only && task.id !== only) continue;
455
459
  const state = scopeFor(task).card;
456
- if (state.dispatch_hold) continue;
457
- if (workflow.run && (task.subtask_of || state.paused || state.archived || state.blocked_by.length || task.current_run_id || (state.every && state.next_run_at > now()))) continue;
458
- if (!startable.has(statusOf(board.db, task))) continue;
460
+ if (task.subtask_of || state.archived || task.current_run_id || ['done', 'cancelled'].includes(state.status)) continue;
461
+ if (state.dispatch_hold) { waiting(task, 'dispatch held: an explicit start or verified adoption must release it'); continue; }
462
+ if (workflow.run && state.paused) { waiting(task, `paused: ${state.paused.reason ?? 'manager pause'}`); continue; }
463
+ if (workflow.run && state.blocked_by.length) { waiting(task, `blocked by ${state.blocked_by.map(b => b.id).join(', ')}`); continue; }
464
+ if (workflow.run && state.every && state.next_run_at > now()) { waiting(task, `next scheduled run at ${state.next_run_at}`); continue; }
465
+ if (!startable.has(state.status)) { waiting(task, `no ${d.start} transition from ${state.status}`); continue; }
459
466
  const plan = planOf(task, scopeFor(task));
460
- if (!plan) continue;
467
+ if (!plan) { waiting(task, `start guard refused: ${transitionsFor(workflow, state.status, d.start).map(t => t.guard ?? 'sender role').join(' | ')}`); continue; }
461
468
  const scope = scopeFor(task, { lane: plan.lane ?? {}, run: { surface: plan.lane?.surface ?? null, local: plan.lane?.surface !== 'pane' || normalMachine(task.machine ?? '') === localMachine() } });
462
- if (!holds(d.eligible ?? null, scope)) continue;
469
+ if (!holds(d.eligible ?? null, scope)) { waiting(task, `dispatch eligibility refused (memory ${pressure}, local starts ${tickState.local_started}): ${d.eligible}`); continue; }
463
470
  candidates.push({ task, plan, scope });
464
471
  }
465
472
  const keyOf = (c) => (d.order ?? []).map((o) => { const desc = o.startsWith('-'); const v = evaluate(desc ? o.slice(1) : o, c.scope); return { v, desc }; });
@@ -770,16 +777,17 @@ export async function sessionNotice(board, e) {
770
777
  return true;
771
778
  }
772
779
  e = { ...e, address: e.address ?? addressOf(prior) };
773
- let error = null;
780
+ let error = null, receipt = null;
774
781
  try {
775
782
  if (!e.address) throw new Error('lifecycle notice awaits the owned launch session identity');
776
- await send(boardAddress(board.label), board.label, e.address, e.text, `board-run-notice-${e.id}-${e.durableId}`, { queue: true }); }
783
+ receipt = await send(boardAddress(board.label), board.label, e.address, e.text, e.messageKey ?? `board-run-notice-${e.id}-${e.durableId}`);
784
+ if (!receipt.delivered) error = receipt.reason ?? 'notice queued; native transcript confirmation pending'; }
777
785
  catch (failure) { error = String(failure.message).slice(-500); }
778
786
  tx(board, (db) => {
779
787
  const session = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(e.runId)?.metadata, {})?.supercode;
780
788
  if (session) setRunSession(db, e.runId, { ...session,
781
789
  ...(error ? { lifecycle_notice_error: error } : { lifecycle_notice_error: null, ...(session.lifecycle_notice_pending === e.durableId ? { lifecycle_notice_pending: null } : {}) }) });
782
- if (!error || session?.lifecycle_notice_error !== error) event(db, e.id, error ? 'session_notice_failed' : 'session_notice_delivered', { address: e.address, verb: e.verb, source_event: e.sourceEvent, notice: e.durableId, ...(error ? { error, retry: true } : {}) }, e.runId);
790
+ if (!error || session?.lifecycle_notice_error !== error) event(db, e.id, error ? (receipt?.queued ? 'session_notice_queued' : 'session_notice_failed') : 'session_notice_delivered', { address: e.address, verb: e.verb, source_event: e.sourceEvent, notice: e.durableId, receipt, ...(error ? { error, retry: true } : {}) }, e.runId);
783
791
  });
784
792
  return !error;
785
793
  }
@@ -787,12 +795,21 @@ export async function sessionNotice(board, e) {
787
795
  /** A session effect a committed move asked for: a message to the card's current actor, or closing a slot's pane. */
788
796
  export async function sessionEffect(board, e) {
789
797
  if (e.kind === 'send_to_actor') {
790
- const run = view(board, (db) => currentRun(db, e.id));
791
- const session = run?.metadata?.supercode;
798
+ const row = view(board, db => e.runId ? db.prepare('SELECT metadata FROM task_runs WHERE id=?').get(e.runId) : null);
799
+ const session = json(row?.metadata, {})?.supercode;
792
800
  const address = addressOf(session);
793
- if (!address) return;
794
- await send(boardAddress(board.label), board.label, address, e.text, `board-actor-${e.id}-${now()}`);
795
- return;
801
+ let receipt;
802
+ try {
803
+ if (!address) throw new Error('actor session identity unavailable');
804
+ receipt = await send(boardAddress(board.label), board.label, address, e.text, `board-actor-${e.id}-${e.durableId}`);
805
+ } catch (error) { receipt = { delivered: false, reason: error.message }; }
806
+ const kind = receipt.delivered ? 'actor_notice_delivered' : receipt.queued ? 'actor_notice_queued' : 'actor_notice_failed';
807
+ tx(board, db => {
808
+ const previous = db.prepare("SELECT kind,payload FROM task_events WHERE task_id=? AND kind IN ('actor_notice_delivered','actor_notice_queued','actor_notice_failed') ORDER BY id DESC LIMIT 1").get(e.id);
809
+ const payload = { notice: e.durableId, address, receipt };
810
+ if (previous?.kind !== kind || JSON.stringify(json(previous?.payload, {})?.receipt) !== JSON.stringify(receipt)) event(db, e.id, kind, payload, e.runId);
811
+ });
812
+ return receipt.delivered;
796
813
  }
797
814
  if (e.kind === 'close_session' || e.kind === 'run_ended') {
798
815
  tx(board, (db) => requestSessionClose(db, e.id, e.kind === 'run_ended' ? { runId: e.runId } : { slot: e.slot, through: e.runId }));
@@ -1074,7 +1091,7 @@ function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'wor
1074
1091
  try { deliverNow(board, addressOf(prev), input, `board-run-${task.id}-r${runId}`); }
1075
1092
  catch (error) {
1076
1093
  tx(board, (db) => {
1077
- const effect = { kind: 'session_notice', id: task.id, runId, address: addressOf(prev), text: input, verb: 'start' };
1094
+ const effect = { kind: 'session_notice', id: task.id, runId, address: addressOf(prev), text: input, verb: 'start', messageKey: `board-run-${task.id}-r${runId}` };
1078
1095
  const saved = db.prepare('INSERT INTO supercode_effects(effect) VALUES(?)').run(JSON.stringify(effect));
1079
1096
  setRunSession(db, runId, { lifecycle_notice_pending: Number(saved.lastInsertRowid), lifecycle_notice_error: error.message });
1080
1097
  event(db, task.id, 'session_notice_failed', { verb: 'start', error: error.message, retry: true }, runId);
@@ -1286,6 +1303,7 @@ function deliverNow(board, to, body, key) {
1286
1303
  const r = sc(['message', 'remote-deliver'], { input: JSON.stringify(request), timeout: 120_000 });
1287
1304
  const answer = lastJson(r.stdout);
1288
1305
  if (answer?.code !== 0) throw new Error((answer?.text || r.stderr || `no delivery answer (exit ${r.code})`).trim().slice(-300));
1306
+ if (!answer.receipt?.delivered) throw new Error(answer.receipt?.reason ?? 'native delivery confirmation pending');
1289
1307
  }
1290
1308
 
1291
1309
  async function send(from, label, to, body, key, { queue = false, ask = false } = {}) {
@@ -1299,6 +1317,7 @@ async function send(from, label, to, body, key, { queue = false, ask = false } =
1299
1317
  const r = await scAsync(['message', 'remote-deliver'], { input: JSON.stringify(request), timeout: 180_000 });
1300
1318
  const answer = lastJson(r.stdout) ?? {};
1301
1319
  if (answer.code !== 0) throw new Error((answer.text || r.stderr || `no answer (exit ${r.code})`).trim().slice(-300));
1320
+ return answer.receipt ?? { delivered: false, reason: 'mail door returned no delivery receipt' };
1302
1321
  }
1303
1322
 
1304
1323
  /**
@@ -1313,8 +1332,9 @@ export async function sendBoardMessages(board, report, taskId = null) {
1313
1332
  if (taskId && row.task_id !== taskId) continue;
1314
1333
  const m = json(row.payload, {}) ?? {};
1315
1334
  try {
1316
- await send(boardAddress(board.label), board.label, m.to, m.text, m.message_id ?? `board-message-${row.id}`, { ask: Boolean(m.message_id) });
1317
- tx(board, (db) => event(db, row.task_id, 'board_message_sent', { event: row.id, to: m.to, ...(m.message_id ? { message_id: m.message_id } : {}) }));
1335
+ const receipt = await send(boardAddress(board.label), board.label, m.to, m.text, m.message_id ?? `board-message-${row.id}`, { ask: Boolean(m.message_id) });
1336
+ if (!receipt.delivered) throw new Error(receipt.reason ?? 'queued; native transcript confirmation pending');
1337
+ tx(board, (db) => event(db, row.task_id, 'board_message_sent', { event: row.id, to: m.to, receipt, ...(m.message_id ? { message_id: m.message_id } : {}) }));
1318
1338
  } catch (error) {
1319
1339
  const reason = String(error.message).slice(-200);
1320
1340
  report.undelivered.push([row.task_id, m.to, reason]);
@@ -1386,7 +1406,7 @@ async function notify(board, fleet, report, entry) {
1386
1406
  // the card as the workflow names it (its status the workflow's: triaging, reviewing, stopped; `stored_as` kanban.db's)
1387
1407
  const task = view(board, (db) => { const t = getTask(db, sub.task_id); return t ? { ...t, status: statusOf(db, t), stored_as: t.status } : t; });
1388
1408
  let cursor = Number(sub.last_event_id);
1389
- let failed = null;
1409
+ let failed = null, pending = false;
1390
1410
  try {
1391
1411
  for (const ev of events) {
1392
1412
  const p = json(ev.payload, {}) ?? {};
@@ -1399,23 +1419,28 @@ async function notify(board, fleet, report, entry) {
1399
1419
  text = c ? noticeOf(entry, role, ev.kind, task, p, c) : null;
1400
1420
  } else text = noticeOf(entry, role, ev.kind, task, p);
1401
1421
  if (text) {
1402
- // Hermes's delivery modes: `notify` is passive (read at the session's next turn), `wake`/`notify+wake` start its turn
1403
- 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' });
1404
- tx(board, (db) => event(db, sub.task_id, 'notice_delivered', { source_event: ev.id, kind: ev.kind, address: sub.chat_id }));
1422
+ const receipt = await send(from, board.label, sub.chat_id, text, `board-${sub.task_id}-${ev.id}-${sub.chat_id.slice(-12)}`, { queue: !entry.workflow.run && sub.delivery_mode === 'notify' });
1423
+ if (!receipt.delivered) { pending = Boolean(receipt.queued); throw new Error(receipt.reason ?? 'native delivery confirmation pending'); }
1424
+ tx(board, (db) => event(db, sub.task_id, 'notice_delivered', { source_event: ev.id, kind: ev.kind, address: sub.chat_id, receipt }));
1405
1425
  report.notified.push([sub.task_id, ev.kind, sub.chat_id]);
1406
1426
  }
1407
1427
  cursor = ev.id;
1408
1428
  }
1409
1429
  } catch (error) {
1410
1430
  failed = String(error.message).slice(-200);
1411
- tx(board, (db) => event(db, sub.task_id, 'notice_failed', { after_event: cursor, address: sub.chat_id, error: failed, retry: true }));
1431
+ tx(board, (db) => {
1432
+ const kind = pending ? 'notice_queued' : 'notice_failed';
1433
+ const previous = db.prepare('SELECT payload FROM task_events WHERE task_id=? AND kind=? ORDER BY id DESC LIMIT 1').get(sub.task_id, kind);
1434
+ const payload = { after_event: cursor, address: sub.chat_id, error: failed, retry: true };
1435
+ if (JSON.stringify(json(previous?.payload, {})) !== JSON.stringify(payload)) event(db, sub.task_id, kind, payload);
1436
+ });
1412
1437
  report.undelivered.push([sub.task_id, sub.chat_id, failed]);
1413
1438
  }
1414
1439
  // the cursor moves past what was delivered (a failed delivery is retried next tick; the mail id dedupes)
1415
1440
  tx(board, (db) => {
1416
1441
  const where = "WHERE task_id = ? AND platform = 'supercode' AND chat_id = ?";
1417
1442
  db.prepare(`UPDATE kanban_notify_subs SET last_event_id = ? ${where} AND last_event_id < ?`).run(cursor, sub.task_id, sub.chat_id, cursor);
1418
- const failures = failed ? Number(sub.delivery_metadata?.failures ?? 0) + 1 : 0;
1443
+ const failures = failed && !pending ? Number(sub.delivery_metadata?.failures ?? 0) + 1 : 0;
1419
1444
  if (failures >= MAX_SEND_FAILURES) { db.prepare(`DELETE FROM kanban_notify_subs ${where}`).run(sub.task_id, sub.chat_id); return; }
1420
1445
  if (failures !== Number(sub.delivery_metadata?.failures ?? 0)) db.prepare(`UPDATE kanban_notify_subs SET delivery_metadata = ? ${where}`).run(JSON.stringify({ ...sub.delivery_metadata, failures }), sub.task_id, sub.chat_id);
1421
1446
  if (task?.status === 'archived' && cursor >= events.at(-1).id) db.prepare(`DELETE FROM kanban_notify_subs ${where}`).run(sub.task_id, sub.chat_id);
package/board/engine.mjs CHANGED
@@ -13,6 +13,7 @@ import { join, resolve } from 'node:path';
13
13
  import { checkWorkspace, assertWorkspaceOwner, declareWorkspace, isLocalWorkspace, isWorkspacePath } from './workspace.mjs';
14
14
  import { DatabaseSync } from 'node:sqlite';
15
15
  import { view, actorOf, claimer, event, json, newTaskId, now, tx } from './store.mjs';
16
+ import { observeCompletionEvidence } from './ci.mjs';
16
17
  import { addDependency, retireSupersededRuns, refreshCardInputs, requestSessionClose, eventTo, fire, loadWorkflow, setStatus, settle, startNew, statusOf, runEvent, workflowFor } from './workflow.mjs';
17
18
 
18
19
  export const STATUSES = ['triage', 'todo', 'scheduled', 'ready', 'running', 'blocked', 'review', 'done', 'archived'];
@@ -347,6 +348,7 @@ export function setCaller(fn) { callerRoles = fn; }
347
348
  export async function act(board, id, name, { roles = null, payload = {}, extra = {} } = {}) {
348
349
  const entry = await loadWorkflow(board.root);
349
350
  refreshCardInputs(board, id, { commits: ['complete', 'request_review'].includes(name) });
351
+ if (['complete', 'request_review', 'request_state'].includes(name)) payload = { ...payload, ci: observeCompletionEvidence(board, id, entry, payload) };
350
352
  const result = tx(board, (db) => {
351
353
  roles ??= callerRoles(db, id);
352
354
  const r = fire(db, board, entry, id, name, { roles, payload, extra });
@@ -364,6 +366,7 @@ export async function act(board, id, name, { roles = null, payload = {}, extra =
364
366
  export async function goto(board, id, status, { roles = null, payload = {} } = {}) {
365
367
  const entry = await loadWorkflow(board.root);
366
368
  refreshCardInputs(board, id, { commits: true });
369
+ observeCompletionEvidence(board, id, entry, payload);
367
370
  const { name, refusal, from } = (() => {
368
371
  const db=board.db;
369
372
  roles ??= callerRoles(db, id);
@@ -397,7 +400,7 @@ export async function runEffects(board, effects = [], { retryNotices = false } =
397
400
  else if (e.kind === 'close_session' || e.kind === 'send_to_actor' || e.kind === 'run_ended') {
398
401
  if (e.kind === 'run_ended' && e.outcome === 'reclaimed') stopWorker(board, e.runId);
399
402
  const { sessionEffect } = await import('./dispatch.mjs');
400
- await sessionEffect(board, e);
403
+ if (await sessionEffect(board, e) === false) continue;
401
404
  }
402
405
  if (e.durableId) tx(board, (db) => db.prepare('DELETE FROM supercode_effects WHERE id = ?').run(e.durableId));
403
406
  }
@@ -454,14 +457,14 @@ function invalidateDescendants(db, board, id) {
454
457
 
455
458
  // each of Hermes's verbs is an event of the workflow; the words it takes are the event's payload
456
459
 
457
- export const complete = (board, id, { result = null, summary = null, metadata = null, roles, runId = null } = {}) =>
458
- act(board, id, 'complete', { roles, payload: { result, summary, metadata, run_id: runId } });
460
+ export const complete = (board, id, { result = null, summary = null, metadata = null, roles, runId = null, commit = null, ci_run = null } = {}) =>
461
+ act(board, id, 'complete', { roles, payload: { result, summary, metadata, run_id: runId, commit, ci_run } });
459
462
  // a block names the one thing that ends it: a card it waits on (`on`), or the message whose answer it waits for (`waiting_on`)
460
463
  export const block = (board, id, { reason = null, on = null, waitingOn = null, roles, runId = null } = {}) =>
461
464
  act(board, id, 'block', { roles, payload: { reason, on, waiting_on: waitingOn, run_id: runId } });
462
465
  export const unblock = (board, id, { reason = null, on = null, roles } = {}) => act(board, id, 'unblock', { roles, payload: { reason, on } });
463
- export const requestReview = (board, id, { summary = null, metadata = null, reviewer = null, force = false, expectedRunId = null, roles } = {}) =>
464
- act(board, id, 'request_review', { roles, payload: { summary, metadata, reviewer, force, run_id: expectedRunId } });
466
+ export const requestReview = (board, id, { summary = null, metadata = null, reviewer = null, force = false, expectedRunId = null, roles, commit = null, ci_run = null } = {}) =>
467
+ act(board, id, 'request_review', { roles, payload: { summary, metadata, reviewer, force, run_id: expectedRunId, commit, ci_run } });
465
468
  export const requestChanges = (board, id, { reason = null, reviewer = null, roles } = {}) => act(board, id, 'request_changes', { roles, payload: { reason, reviewer } });
466
469
  export const reopen = (board, id, { author = 'user', reason = null, roles } = {}) => act(board, id, 'reopen', { roles, payload: { author, reason } });
467
470
  export const reopenReview = (board, id, { roles } = {}) => act(board, id, 'reopen_review', { roles });
package/board/tools.mjs CHANGED
@@ -105,7 +105,7 @@ export async function boardTool(root, name, args = {}, env = {}) {
105
105
  }
106
106
  }
107
107
  if (env.HERMES_SESSION_ID && own === tid) metadata = { ...(metadata ?? {}), worker_session_id: env.HERMES_SESSION_ID };
108
- await engine.complete(board, tid, { summary, result, metadata });
108
+ await engine.complete(board, tid, { summary, result, metadata, commit: args.commit ?? null, ci_run: args.ci_run ?? null });
109
109
  return ok({ task_id: tid, status: 'done' });
110
110
  }
111
111
  case 'kanban_block': {
@@ -115,12 +115,12 @@ export async function boardTool(root, name, args = {}, env = {}) {
115
115
  }
116
116
  case 'kanban_request_state': {
117
117
  const stale = ownRun(); if (stale) return error(stale);
118
- await engine.act(board, tid, 'request_state', { payload: { state: args.state, reason: args.reason, on: args.on ?? null, waiting_on: args.waiting_on ?? null, evidence: args.evidence ?? null } });
118
+ await engine.act(board, tid, 'request_state', { payload: { state: args.state, reason: args.reason, on: args.on ?? null, waiting_on: args.waiting_on ?? null, evidence: args.evidence ?? null, commit: args.commit ?? null, ci_run: args.ci_run ?? null, deployed_sha: args.deployed_sha ?? null } });
119
119
  return ok({ task_id: tid, requested: args.state, changed: false });
120
120
  }
121
121
  case 'kanban_request_review': {
122
122
  const stale = ownRun(); if (stale) return error(stale);
123
- await engine.requestReview(board, tid, { summary: args.summary ? redact(String(args.summary)) : null, metadata: args.metadata ? redact(args.metadata) : null, reviewer: args.reviewer ?? null, expectedRunId: own === tid && runId ? runId : null });
123
+ await engine.requestReview(board, tid, { summary: args.summary ? redact(String(args.summary)) : null, metadata: args.metadata ? redact(args.metadata) : null, commit: args.commit ?? null, ci_run: args.ci_run ?? null, reviewer: args.reviewer ?? null, expectedRunId: own === tid && runId ? runId : null });
124
124
  return ok({ task_id: tid, status: 'review' });
125
125
  }
126
126
  case 'kanban_request_changes': {
package/board/wake.mjs CHANGED
@@ -20,7 +20,7 @@ export function signalBoardWrite(root, actor) {
20
20
  /** One coalescing queue shared by standalone serve and the hosted dispatcher. */
21
21
  export function watchBoard({ root, binary, env, log = () => {} }) {
22
22
  const watchers = new Map();
23
- let pending = null, waiter = null, closed = false, child = null, retry = null, lastError = null;
23
+ let pending = null, waiter = null, closed = false, child = null, retry = null, lastError = null, catalogue = null;
24
24
  const wake = (source, at = Date.now()) => {
25
25
  if (closed) return;
26
26
  if (!pending) pending = { at, sources: new Set() };
@@ -83,7 +83,7 @@ export function watchBoard({ root, binary, env, log = () => {} }) {
83
83
  if (closed) return;
84
84
  let buffer = '', error = '';
85
85
  const command = env.SUPERCODE_TEAMS_ENTRY ? process.execPath : binary;
86
- const args = [...(env.SUPERCODE_TEAMS_ENTRY ? [env.SUPERCODE_TEAMS_ENTRY] : []), 'sessions', 'list', '--watch', '--json'];
86
+ const args = [...(env.SUPERCODE_TEAMS_ENTRY ? [env.SUPERCODE_TEAMS_ENTRY] : []), 'sessions', 'list', '--watch', '--json', '--limit', '200'];
87
87
  child = spawn(command, args, { cwd: tmpdir(), env, stdio: ['ignore', 'pipe', 'pipe'] });
88
88
  child.stdout.setEncoding('utf8').on('data', chunk => {
89
89
  buffer += chunk;
@@ -94,7 +94,24 @@ export function watchBoard({ root, binary, env, log = () => {} }) {
94
94
  try {
95
95
  const event = JSON.parse(line);
96
96
  lastError = null;
97
- if (!['heartbeat', 'ping', 'keepalive'].includes(event.type)) wake('session-catalogue');
97
+ if (['heartbeat', 'ping', 'keepalive'].includes(event.type)) continue;
98
+ if (event.type === 'catalog_reset' && Array.isArray(event.data?.items)) {
99
+ // Transcript revisions and sort order are not scheduling events.
100
+ // The safety sweep reads quiet timestamps; wake for session identity,
101
+ // availability and available delivery/control doors changing now.
102
+ const next = JSON.stringify(event.data.items.map(item => ({
103
+ id:item.id, harness:item.harness, machines:[...(item.machine_ids ?? [])].sort(),
104
+ bindings:(item.bindings ?? []).map(binding => ({ id:binding.id, machine:binding.machine_id,
105
+ incarnation:binding.incarnation_id, runtime:binding.native_runtime_id, session:binding.native_session_id,
106
+ harness:binding.harness, state:binding.state, capabilities:[...(binding.native_capabilities ?? [])].sort(),
107
+ })).sort((a,b)=>String(a.id).localeCompare(String(b.id))), actions:item.actions,
108
+ presence:item.activity?.presence, turn:item.activity?.turn,
109
+ status:item.live_status, delivery:item.delivery,
110
+ })).sort((a,b)=>String(a.id).localeCompare(String(b.id))));
111
+ if (next === catalogue) continue;
112
+ catalogue = next;
113
+ }
114
+ wake('session-catalogue');
98
115
  } catch { /* CLI diagnostics are not events */ }
99
116
  }
100
117
  });
@@ -11,6 +11,7 @@ import { resolve, relative, isAbsolute } from 'node:path';
11
11
  import { randomBytes } from 'node:crypto';
12
12
  import { actorOf, appendRunObservation, event as record, json, now } from './store.mjs';
13
13
  import { evaluate, holds, render } from './expr.mjs';
14
+ import { completionEvidence } from './ci.mjs';
14
15
 
15
16
  const loaded = new Map();
16
17
 
@@ -222,6 +223,7 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {} } = {}
222
223
  const card = {
223
224
  ...task,
224
225
  creator_address: creatorOf(db, task.id),
226
+ ci: completionEvidence(board, task.id),
225
227
  dispatch_hold: one(db, "SELECT kind FROM task_events WHERE task_id=? AND kind IN ('dispatch_held','dispatch_released','session_adopted') ORDER BY id DESC LIMIT 1", task.id)?.kind === 'dispatch_held',
226
228
  status,
227
229
  blocked_by: blocks,
@@ -359,9 +361,17 @@ export function fire(db, board, entry, id, name, { roles = [], payload = {}, ext
359
361
  const allowed = candidates.filter((t) => !t.by?.length || t.by.some((r) => roles.includes(r)));
360
362
  if (!allowed.length) throw new Error(`${id} is ${from}; ${name.replace(/_/g, '-')} there is for ${[...new Set(candidates.flatMap((t) => t.by ?? []))].join(' or ')}. ${candidates.map((t) => t.description).filter(Boolean).join(' ')}`);
361
363
  const taken = allowed.find((t) => holds(t.guard ?? null, scope));
362
- if (!taken) { const none = new Error(`${id} is ${from}; ${name.replace(/_/g, '-')} does not apply to it now`); none.noTransition = true; throw none; }
364
+ if (!taken) {
365
+ const state = scope.card;
366
+ const reasons = [state.paused && `paused: ${state.paused.reason ?? 'manager pause'}`,
367
+ state.blocked_by.length && `blocked by ${state.blocked_by.map(b => b.id).join(', ')}`,
368
+ state.dispatch_hold && 'dispatch held', state.current_run_id && `current run ${state.current_run_id}`,
369
+ state.archived && 'archived'].filter(Boolean);
370
+ const none = new Error(`${id} is ${from}; ${name.replace(/_/g, '-')} cannot proceed: ${reasons.join('; ') || `guard ${allowed.map(t => t.guard).join(' | ')}`}`);
371
+ none.noTransition = true; throw none;
372
+ }
363
373
  for (const v of taken.validate ?? []) {
364
- if (!holds(v.expr, scope)) { const refusal = new Error(v.message); refusal.refusal = v.message; throw refusal; }
374
+ if (!holds(v.expr, scope)) { const reason = render(v.message, scope); const refusal = new Error(reason); refusal.refusal = reason; throw refusal; }
365
375
  }
366
376
  const effects = [];
367
377
  const to = take(db, board, entry, task, from, taken, scope, effects);
@@ -507,12 +517,12 @@ const firstLine = (text) => String(text ?? '').trim().split('\n')[0].slice(0, 40
507
517
  const FORMAT = {
508
518
  completed: ({ e }) => {
509
519
  const artifacts = Array.isArray(e.metadata?.artifacts) ? e.metadata.artifacts.filter((a) => typeof a === 'string' && a.trim()) : [];
510
- return { result_len: e.result ? String(e.result).length : 0, summary: firstLine(e.summary ?? e.result), ...(artifacts.length ? { artifacts } : {}) };
520
+ return { ci: e.ci ?? null, result_len: e.result ? String(e.result).length : 0, summary: firstLine(e.summary ?? e.result), ...(artifacts.length ? { artifacts } : {}) };
511
521
  },
512
522
  blocked: ({ e, after, source }) => ({ reason: e.reason ?? null, on: e.on ?? null, waiting_on: e.waiting_on ?? null, recurrences: Number(after.block_recurrences || 0), source_status: source }),
513
523
  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 }),
514
524
  dependency_wait: ({ e, source }) => ({ reason: e.reason ?? null, on: e.on ?? null, source_status: source }),
515
- review_requested: ({ e, before, after }) => ({ summary: firstLine(e.summary), implementer: before.assignee, reviewer: after.assignee !== before.assignee ? after.assignee : (e.reviewer ?? null) }),
525
+ review_requested: ({ e, before, after }) => ({ summary: firstLine(e.summary), ci: e.ci ?? null, implementer: before.assignee, reviewer: after.assignee !== before.assignee ? after.assignee : (e.reviewer ?? null) }),
516
526
  // the reviewer is the lane that reviewed (the card's assignee while in review), unless the verdict names one
517
527
  changes_requested: ({ e, before, after }) => ({ reason: e.reason ?? null, reviewer: e.reviewer ?? before.assignee ?? null, implementer: after.assignee }),
518
528
  review_reopened: ({ after, to }) => ({ status: to, implementer: after.assignee }),
@@ -559,7 +569,14 @@ const ACTIONS = {
559
569
  }
560
570
  if (remove) {
561
571
  if (!String(reason ?? '').trim()) throw new Error('unblock must say what arrived');
562
- blocks = blocks.filter((b) => remove === 'messages' ? b.kind !== 'message' : remove !== '*' && b.id !== remove);
572
+ let resolved = remove;
573
+ if (remove !== '*' && remove !== 'messages') {
574
+ const matches = blocks.filter(b => b.id === remove || (b.kind === 'message' && String(remove).startsWith('m-') &&
575
+ (String(remove).startsWith(b.id) || b.id.startsWith(String(remove)))));
576
+ if (matches.length !== 1) throw new Error(matches.length ? `ambiguous dependency ${remove}; name one of ${matches.map(b => b.id).join(', ')}` : `no dependency ${remove}; current blockers: ${blocks.map(b => b.id).join(', ') || 'none'}`);
577
+ resolved = matches[0].id;
578
+ }
579
+ blocks = blocks.filter((b) => resolved === 'messages' ? b.kind !== 'message' : resolved !== '*' && b.id !== resolved);
563
580
  }
564
581
  cardField(ctx, 'blocked_by', JSON.stringify(blocks));
565
582
  // task_links is the Hermes projection of the canonical card relations, including within this transaction.
@@ -763,7 +780,11 @@ const ACTIONS = {
763
780
  record(ctx.db, ctx.id, 'board_message', { to: String(to), text: `${String(text ?? '')}\n\nAnswer with reply_to ${id}.`, message_id: id }, null);
764
781
  ctx.effects.push({ kind: 'board_message', id: ctx.id });
765
782
  },
766
- send_to_actor(ctx, text) { ctx.effects.push({ kind: 'send_to_actor', id: ctx.id, text }); },
783
+ send_to_actor(ctx, text) {
784
+ const effect = { kind: 'send_to_actor', id: ctx.id, runId: ctx.runId, text };
785
+ const inserted = run(ctx.db, 'INSERT INTO supercode_effects (effect) VALUES (?)', JSON.stringify(effect));
786
+ ctx.effects.push({ ...effect, durableId: Number(inserted.lastInsertRowid) });
787
+ },
767
788
  };
768
789
 
769
790
  /** Binding a newer run transfers ownership; stale run references cannot veto its later stop. */
@@ -879,7 +900,7 @@ export function fireRun(db, board, entry, id, name, { roles = [], payload = {},
879
900
  const candidates = [...(machine.states[from]?.on?.[name] ?? []), ...(machine.on?.[name] ?? [])];
880
901
  const t = candidates.find((t) => (!t.from?.length || t.from.includes(from)) && (!t.by?.length || t.by.some((r) => roles.includes(r))) && holds(t.guard ?? null, scope));
881
902
  if (!t) { const error = new Error(`${id}: run event ${name} does not apply`); error.noTransition = true; throw error; }
882
- for (const v of t.validate ?? []) if (!holds(v.expr, scope)) throw new Error(v.message);
903
+ for (const v of t.validate ?? []) if (!holds(v.expr, scope)) throw new Error(render(v.message, scope));
883
904
  const to = t.target ? String(templateValue(t.target, scope)) : from;
884
905
  if (!machine.states[to]) throw new Error(`unknown run state ${to}`);
885
906
  const effects = [];
@@ -1,7 +1,7 @@
1
1
  [
2
2
  {
3
3
  "name": "kanban_show",
4
- "description": "Read a task's full state — title, body, assignee, parent task handoffs, your prior attempts on this task if any, comments, and recent events. Use this to (re)orient yourself before starting work, especially on retries. The response includes a pre-formatted ``worker_context`` string suitable for inclusion verbatim in your reasoning.",
4
+ "description": "Read a task's full state \u2014 title, body, assignee, parent task handoffs, your prior attempts on this task if any, comments, and recent events. Use this to (re)orient yourself before starting work, especially on retries. The response includes a pre-formatted ``worker_context`` string suitable for inclusion verbatim in your reasoning.",
5
5
  "inputSchema": {
6
6
  "type": "object",
7
7
  "properties": {
@@ -11,7 +11,7 @@
11
11
  },
12
12
  "board": {
13
13
  "type": "string",
14
- "description": "Kanban board slug to target. When omitted, the call resolves the active board the usual way: HERMES_KANBAN_DB env → HERMES_KANBAN_BOARD env → the 'current' symlink under the kanban home → 'default'. Pass an explicit slug only when the caller (e.g. a Telegram routing layer) needs to override the env-pinned active board for this one call."
14
+ "description": "Kanban board slug to target. When omitted, the call resolves the active board the usual way: HERMES_KANBAN_DB env \u2192 HERMES_KANBAN_BOARD env \u2192 the 'current' symlink under the kanban home \u2192 'default'. Pass an explicit slug only when the caller (e.g. a Telegram routing layer) needs to override the env-pinned active board for this one call."
15
15
  }
16
16
  },
17
17
  "required": []
@@ -19,7 +19,7 @@
19
19
  },
20
20
  {
21
21
  "name": "kanban_list",
22
- "description": "List Kanban task summaries so an orchestrator profile can discover work to route. Supports the same core filters as the CLI: assignee, status, tenant, include_archived, and limit. Returns compact rows with ids, title, status, assignee, priority, parent/child ids, and counts. Bounded to 50 rows by default, 200 max, with truncation metadata. Also recomputes ready tasks before listing, matching the CLI. Orchestrator-only — dispatcher-spawned task workers never see this tool.",
22
+ "description": "List Kanban task summaries so an orchestrator profile can discover work to route. Supports the same core filters as the CLI: assignee, status, tenant, include_archived, and limit. Returns compact rows with ids, title, status, assignee, priority, parent/child ids, and counts. Bounded to 50 rows by default, 200 max, with truncation metadata. Also recomputes ready tasks before listing, matching the CLI. Orchestrator-only \u2014 dispatcher-spawned task workers never see this tool.",
23
23
  "inputSchema": {
24
24
  "type": "object",
25
25
  "properties": {
@@ -54,7 +54,7 @@
54
54
  },
55
55
  "board": {
56
56
  "type": "string",
57
- "description": "Kanban board slug to target. When omitted, the call resolves the active board the usual way: HERMES_KANBAN_DB env → HERMES_KANBAN_BOARD env → the 'current' symlink under the kanban home → 'default'. Pass an explicit slug only when the caller (e.g. a Telegram routing layer) needs to override the env-pinned active board for this one call."
57
+ "description": "Kanban board slug to target. When omitted, the call resolves the active board the usual way: HERMES_KANBAN_DB env \u2192 HERMES_KANBAN_BOARD env \u2192 the 'current' symlink under the kanban home \u2192 'default'. Pass an explicit slug only when the caller (e.g. a Telegram routing layer) needs to override the env-pinned active board for this one call."
58
58
  }
59
59
  },
60
60
  "required": []
@@ -62,7 +62,7 @@
62
62
  },
63
63
  {
64
64
  "name": "kanban_complete",
65
- "description": "Complete through the home's declared workflow permissions with a structured handoff. Homes may reserve terminal closure to the recorded creator or manager; use kanban_request_state when you need their decision. Prefer ``summary`` for a human-readable 1-3 sentence description of what you did; put machine-readable facts in ``metadata`` (changed_files, tests_run, decisions, findings, etc). At least one of ``summary`` or ``result`` is required. If you created new tasks via ``kanban_create`` during this run, list their ids in ``created_cards`` — the kernel verifies them so phantom references are caught before they leak into downstream automation. If you produced deliverable files (charts, PDFs, spreadsheets, generated images), list their absolute paths in ``artifacts`` — the gateway notifier will upload them as native attachments to the human who subscribed to the task, so the deliverable lands in their chat alongside the summary instead of being a path they have to fetch by hand.",
65
+ "description": "Complete through the home's declared workflow permissions with a structured handoff. Homes may reserve terminal closure to the recorded creator or manager; use kanban_request_state when you need their decision. Prefer ``summary`` for a human-readable 1-3 sentence description of what you did; put machine-readable facts in ``metadata`` (changed_files, tests_run, decisions, findings, etc). At least one of ``summary`` or ``result`` is required. If you created new tasks via ``kanban_create`` during this run, list their ids in ``created_cards`` \u2014 the kernel verifies them so phantom references are caught before they leak into downstream automation. If you produced deliverable files (charts, PDFs, spreadsheets, generated images), list their absolute paths in ``artifacts`` \u2014 the gateway notifier will upload them as native attachments to the human who subscribed to the task, so the deliverable lands in their chat alongside the summary instead of being a path they have to fetch by hand.",
66
66
  "inputSchema": {
67
67
  "type": "object",
68
68
  "properties": {
@@ -76,7 +76,7 @@
76
76
  },
77
77
  "metadata": {
78
78
  "type": "object",
79
- "description": "Free-form dict of structured facts about this attempt — {\"changed_files\": [...], \"tests_run\": 12, \"findings\": [...]}. Surfaced to downstream workers alongside ``summary``."
79
+ "description": "Free-form dict of structured facts about this attempt \u2014 {\"changed_files\": [...], \"tests_run\": 12, \"findings\": [...]}. Surfaced to downstream workers alongside ``summary``."
80
80
  },
81
81
  "result": {
82
82
  "type": "string",
@@ -87,18 +87,26 @@
87
87
  "items": {
88
88
  "type": "string"
89
89
  },
90
- "description": "Optional structured manifest of task ids you created via ``kanban_create`` during this run. The kernel verifies each id exists and was created by this worker's profile; any phantom id blocks the completion with an error listing what went wrong (auditable in the task's events). Only list ids you got back from a successful ``kanban_create`` call — do not invent or remember ids from prose. Omit the field if you did not create any cards."
90
+ "description": "Optional structured manifest of task ids you created via ``kanban_create`` during this run. The kernel verifies each id exists and was created by this worker's profile; any phantom id blocks the completion with an error listing what went wrong (auditable in the task's events). Only list ids you got back from a successful ``kanban_create`` call \u2014 do not invent or remember ids from prose. Omit the field if you did not create any cards."
91
91
  },
92
92
  "artifacts": {
93
93
  "type": "array",
94
94
  "items": {
95
95
  "type": "string"
96
96
  },
97
- "description": "Optional list of absolute paths to deliverable files you produced during this run — generated charts, PDFs, spreadsheets, images, archives. Examples: [\"/tmp/q3-revenue.png\", \"/tmp/report.pdf\"]. The gateway notifier uploads each path as a native attachment to the subscribed chat (images embed inline, everything else uploads as a file) so the deliverable lands with the completion notification. Skip intermediate scratch files and references that are not the deliverable. The path must exist on disk at completion. Files inside a managed scratch workspace are copied to durable task attachments before cleanup; a missing declared scratch artifact keeps the task in-flight so you can fix the path and retry."
97
+ "description": "Optional list of absolute paths to deliverable files you produced during this run \u2014 generated charts, PDFs, spreadsheets, images, archives. Examples: [\"/tmp/q3-revenue.png\", \"/tmp/report.pdf\"]. The gateway notifier uploads each path as a native attachment to the subscribed chat (images embed inline, everything else uploads as a file) so the deliverable lands with the completion notification. Skip intermediate scratch files and references that are not the deliverable. The path must exist on disk at completion. Files inside a managed scratch workspace are copied to durable task attachments before cleanup; a missing declared scratch artifact keeps the task in-flight so you can fix the path and retry."
98
98
  },
99
99
  "board": {
100
100
  "type": "string",
101
- "description": "Kanban board slug to target. When omitted, the call resolves the active board the usual way: HERMES_KANBAN_DB env → HERMES_KANBAN_BOARD env → the 'current' symlink under the kanban home → 'default'. Pass an explicit slug only when the caller (e.g. a Telegram routing layer) needs to override the env-pinned active board for this one call."
101
+ "description": "Kanban board slug to target. When omitted, the call resolves the active board the usual way: HERMES_KANBAN_DB env \u2192 HERMES_KANBAN_BOARD env \u2192 the 'current' symlink under the kanban home \u2192 'default'. Pass an explicit slug only when the caller (e.g. a Telegram routing layer) needs to override the env-pinned active board for this one call."
102
+ },
103
+ "commit": {
104
+ "type": "string",
105
+ "description": "Exact commit deployed by the CI run."
106
+ },
107
+ "ci_run": {
108
+ "type": "integer",
109
+ "description": "GitHub Actions deployment run id for that exact commit."
102
110
  }
103
111
  },
104
112
  "required": []
@@ -124,11 +132,11 @@
124
132
  },
125
133
  "waiting_on": {
126
134
  "type": "string",
127
- "description": "The id of the message (m-… or a-…) whose answer this waits for."
135
+ "description": "The id of the message (m-\u2026 or a-\u2026) whose answer this waits for."
128
136
  },
129
137
  "board": {
130
138
  "type": "string",
131
- "description": "Kanban board slug to target. When omitted, the call resolves the active board the usual way: HERMES_KANBAN_DB env → HERMES_KANBAN_BOARD env → the 'current' symlink under the kanban home → 'default'. Pass an explicit slug only when the caller (e.g. a Telegram routing layer) needs to override the env-pinned active board for this one call."
139
+ "description": "Kanban board slug to target. When omitted, the call resolves the active board the usual way: HERMES_KANBAN_DB env \u2192 HERMES_KANBAN_BOARD env \u2192 the 'current' symlink under the kanban home \u2192 'default'. Pass an explicit slug only when the caller (e.g. a Telegram routing layer) needs to override the env-pinned active board for this one call."
132
140
  }
133
141
  },
134
142
  "required": [
@@ -138,7 +146,7 @@
138
146
  },
139
147
  {
140
148
  "name": "kanban_request_review",
141
- "description": "Hand the task off for review: implementation, self-review, and verification are complete and you want a human (or reviewer) to look before it is marked done. Moves the task to the 'review' column and notifies the subscriber. Unlike ``kanban_block`` this is NOT a blocker — it never counts toward unblock-loop detection, so a task can cycle through review across follow-ups without ever being falsely escalated to triage. Use this instead of blocking with a free-form 'review-required:' reason.",
149
+ "description": "Hand the task off for review: implementation, self-review, and verification are complete and you want a human (or reviewer) to look before it is marked done. Moves the task to the 'review' column and notifies the subscriber. Unlike ``kanban_block`` this is NOT a blocker \u2014 it never counts toward unblock-loop detection, so a task can cycle through review across follow-ups without ever being falsely escalated to triage. Use this instead of blocking with a free-form 'review-required:' reason.",
142
150
  "inputSchema": {
143
151
  "type": "object",
144
152
  "properties": {
@@ -148,7 +156,7 @@
148
156
  },
149
157
  "summary": {
150
158
  "type": "string",
151
- "description": "What was implemented and how it was verified, in one or two sentences — shown to the reviewer. Don't paste the whole diff; the reviewer has the board and the PR."
159
+ "description": "What was implemented and how it was verified, in one or two sentences \u2014 shown to the reviewer. Don't paste the whole diff; the reviewer has the board and the PR."
152
160
  },
153
161
  "reviewer": {
154
162
  "type": "string",
@@ -161,7 +169,15 @@
161
169
  },
162
170
  "board": {
163
171
  "type": "string",
164
- "description": "Kanban board slug to target. When omitted, the call resolves the active board the usual way: HERMES_KANBAN_DB env → HERMES_KANBAN_BOARD env → the 'current' symlink under the kanban home → 'default'. Pass an explicit slug only when the caller (e.g. a Telegram routing layer) needs to override the env-pinned active board for this one call."
172
+ "description": "Kanban board slug to target. When omitted, the call resolves the active board the usual way: HERMES_KANBAN_DB env \u2192 HERMES_KANBAN_BOARD env \u2192 the 'current' symlink under the kanban home \u2192 'default'. Pass an explicit slug only when the caller (e.g. a Telegram routing layer) needs to override the env-pinned active board for this one call."
173
+ },
174
+ "commit": {
175
+ "type": "string",
176
+ "description": "Exact commit deployed by the CI run."
177
+ },
178
+ "ci_run": {
179
+ "type": "integer",
180
+ "description": "GitHub Actions deployment run id for that exact commit."
165
181
  }
166
182
  },
167
183
  "required": [
@@ -185,7 +201,7 @@
185
201
  },
186
202
  "board": {
187
203
  "type": "string",
188
- "description": "Kanban board slug to target. When omitted, the call resolves the active board the usual way: HERMES_KANBAN_DB env → HERMES_KANBAN_BOARD env → the 'current' symlink under the kanban home → 'default'. Pass an explicit slug only when the caller (e.g. a Telegram routing layer) needs to override the env-pinned active board for this one call."
204
+ "description": "Kanban board slug to target. When omitted, the call resolves the active board the usual way: HERMES_KANBAN_DB env \u2192 HERMES_KANBAN_BOARD env \u2192 the 'current' symlink under the kanban home \u2192 'default'. Pass an explicit slug only when the caller (e.g. a Telegram routing layer) needs to override the env-pinned active board for this one call."
189
205
  }
190
206
  },
191
207
  "required": [
@@ -195,7 +211,7 @@
195
211
  },
196
212
  {
197
213
  "name": "kanban_heartbeat",
198
- "description": "Signal that you're still alive during a long operation (training, encoding, large crawls). Call every few minutes so humans see liveness separately from PID checks. Pure side effect — no work changes.",
214
+ "description": "Signal that you're still alive during a long operation (training, encoding, large crawls). Call every few minutes so humans see liveness separately from PID checks. Pure side effect \u2014 no work changes.",
199
215
  "inputSchema": {
200
216
  "type": "object",
201
217
  "properties": {
@@ -209,7 +225,7 @@
209
225
  },
210
226
  "board": {
211
227
  "type": "string",
212
- "description": "Kanban board slug to target. When omitted, the call resolves the active board the usual way: HERMES_KANBAN_DB env → HERMES_KANBAN_BOARD env → the 'current' symlink under the kanban home → 'default'. Pass an explicit slug only when the caller (e.g. a Telegram routing layer) needs to override the env-pinned active board for this one call."
228
+ "description": "Kanban board slug to target. When omitted, the call resolves the active board the usual way: HERMES_KANBAN_DB env \u2192 HERMES_KANBAN_BOARD env \u2192 the 'current' symlink under the kanban home \u2192 'default'. Pass an explicit slug only when the caller (e.g. a Telegram routing layer) needs to override the env-pinned active board for this one call."
213
229
  }
214
230
  },
215
231
  "required": []
@@ -217,13 +233,13 @@
217
233
  },
218
234
  {
219
235
  "name": "kanban_comment",
220
- "description": "Append a comment to a task's thread. Use for durable notes that should outlive this run (questions for the next worker, partial findings, rationale). Ephemeral reasoning doesn't belong here — use your normal response instead.",
236
+ "description": "Append a comment to a task's thread. Use for durable notes that should outlive this run (questions for the next worker, partial findings, rationale). Ephemeral reasoning doesn't belong here \u2014 use your normal response instead.",
221
237
  "inputSchema": {
222
238
  "type": "object",
223
239
  "properties": {
224
240
  "task_id": {
225
241
  "type": "string",
226
- "description": "Task id. Required (may be your own task or another's — comment threads are per-task)."
242
+ "description": "Task id. Required (may be your own task or another's \u2014 comment threads are per-task)."
227
243
  },
228
244
  "body": {
229
245
  "type": "string",
@@ -231,7 +247,7 @@
231
247
  },
232
248
  "board": {
233
249
  "type": "string",
234
- "description": "Kanban board slug to target. When omitted, the call resolves the active board the usual way: HERMES_KANBAN_DB env → HERMES_KANBAN_BOARD env → the 'current' symlink under the kanban home → 'default'. Pass an explicit slug only when the caller (e.g. a Telegram routing layer) needs to override the env-pinned active board for this one call."
250
+ "description": "Kanban board slug to target. When omitted, the call resolves the active board the usual way: HERMES_KANBAN_DB env \u2192 HERMES_KANBAN_BOARD env \u2192 the 'current' symlink under the kanban home \u2192 'default'. Pass an explicit slug only when the caller (e.g. a Telegram routing layer) needs to override the env-pinned active board for this one call."
235
251
  }
236
252
  },
237
253
  "required": [
@@ -242,7 +258,7 @@
242
258
  },
243
259
  {
244
260
  "name": "kanban_attach",
245
- "description": "Attach a file to a task by passing its bytes inline (base64). Use for genuine file artifacts the next worker or a human should be able to download — generated reports, images, exports. The file is stored as a real attachment (not a comment link) under the task's attachments dir, capped at 25 MB. Prefer kanban_attach_url when you only have a URL.",
261
+ "description": "Attach a file to a task by passing its bytes inline (base64). Use for genuine file artifacts the next worker or a human should be able to download \u2014 generated reports, images, exports. The file is stored as a real attachment (not a comment link) under the task's attachments dir, capped at 25 MB. Prefer kanban_attach_url when you only have a URL.",
246
262
  "inputSchema": {
247
263
  "type": "object",
248
264
  "properties": {
@@ -264,7 +280,7 @@
264
280
  },
265
281
  "board": {
266
282
  "type": "string",
267
- "description": "Kanban board slug to target. When omitted, the call resolves the active board the usual way: HERMES_KANBAN_DB env → HERMES_KANBAN_BOARD env → the 'current' symlink under the kanban home → 'default'. Pass an explicit slug only when the caller (e.g. a Telegram routing layer) needs to override the env-pinned active board for this one call."
283
+ "description": "Kanban board slug to target. When omitted, the call resolves the active board the usual way: HERMES_KANBAN_DB env \u2192 HERMES_KANBAN_BOARD env \u2192 the 'current' symlink under the kanban home \u2192 'default'. Pass an explicit slug only when the caller (e.g. a Telegram routing layer) needs to override the env-pinned active board for this one call."
268
284
  }
269
285
  },
270
286
  "required": [
@@ -275,7 +291,7 @@
275
291
  },
276
292
  {
277
293
  "name": "kanban_attach_url",
278
- "description": "Attach a file to a task by URL — Hermes downloads it server-side and stores it as a real attachment (capped at 25 MB). Use when you have a link rather than the bytes. Only http/https URLs are accepted.",
294
+ "description": "Attach a file to a task by URL \u2014 Hermes downloads it server-side and stores it as a real attachment (capped at 25 MB). Use when you have a link rather than the bytes. Only http/https URLs are accepted.",
279
295
  "inputSchema": {
280
296
  "type": "object",
281
297
  "properties": {
@@ -297,7 +313,7 @@
297
313
  },
298
314
  "board": {
299
315
  "type": "string",
300
- "description": "Kanban board slug to target. When omitted, the call resolves the active board the usual way: HERMES_KANBAN_DB env → HERMES_KANBAN_BOARD env → the 'current' symlink under the kanban home → 'default'. Pass an explicit slug only when the caller (e.g. a Telegram routing layer) needs to override the env-pinned active board for this one call."
316
+ "description": "Kanban board slug to target. When omitted, the call resolves the active board the usual way: HERMES_KANBAN_DB env \u2192 HERMES_KANBAN_BOARD env \u2192 the 'current' symlink under the kanban home \u2192 'default'. Pass an explicit slug only when the caller (e.g. a Telegram routing layer) needs to override the env-pinned active board for this one call."
301
317
  }
302
318
  },
303
319
  "required": [
@@ -317,7 +333,7 @@
317
333
  },
318
334
  "board": {
319
335
  "type": "string",
320
- "description": "Kanban board slug to target. When omitted, the call resolves the active board the usual way: HERMES_KANBAN_DB env → HERMES_KANBAN_BOARD env → the 'current' symlink under the kanban home → 'default'. Pass an explicit slug only when the caller (e.g. a Telegram routing layer) needs to override the env-pinned active board for this one call."
336
+ "description": "Kanban board slug to target. When omitted, the call resolves the active board the usual way: HERMES_KANBAN_DB env \u2192 HERMES_KANBAN_BOARD env \u2192 the 'current' symlink under the kanban home \u2192 'default'. Pass an explicit slug only when the caller (e.g. a Telegram routing layer) needs to override the env-pinned active board for this one call."
321
337
  }
322
338
  },
323
339
  "required": []
@@ -325,7 +341,7 @@
325
341
  },
326
342
  {
327
343
  "name": "kanban_create",
328
- "description": "Create a new kanban task, optionally as a child of the current one (pass the current task id in ``parents``). Used by orchestrator workers to fan out — decompose work into child tasks with specific assignees, link them into a pipeline, then complete your own task. The dispatcher picks up the new tasks on its next tick and spawns the assigned profiles.",
344
+ "description": "Create a new kanban task, optionally as a child of the current one (pass the current task id in ``parents``). Used by orchestrator workers to fan out \u2014 decompose work into child tasks with specific assignees, link them into a pipeline, then complete your own task. The dispatcher picks up the new tasks on its next tick and spawns the assigned profiles.",
329
345
  "inputSchema": {
330
346
  "type": "object",
331
347
  "properties": {
@@ -335,7 +351,7 @@
335
351
  },
336
352
  "assignee": {
337
353
  "type": "string",
338
- "description": "Profile name that should execute this task (e.g. 'researcher-a', 'reviewer', 'writer'). Required — tasks without an assignee are never dispatched."
354
+ "description": "Profile name that should execute this task (e.g. 'researcher-a', 'reviewer', 'writer'). Required \u2014 tasks without an assignee are never dispatched."
339
355
  },
340
356
  "body": {
341
357
  "type": "string",
@@ -375,7 +391,7 @@
375
391
  },
376
392
  "triage": {
377
393
  "type": "boolean",
378
- "description": "If true, task lands in 'triage' instead of 'todo' — a specifier profile is expected to flesh out the body before work starts."
394
+ "description": "If true, task lands in 'triage' instead of 'todo' \u2014 a specifier profile is expected to flesh out the body before work starts."
379
395
  },
380
396
  "idempotency_key": {
381
397
  "type": "string",
@@ -398,7 +414,7 @@
398
414
  "items": {
399
415
  "type": "string"
400
416
  },
401
- "description": "Skill names to force-load into the dispatched worker. The kanban lifecycle is already injected automatically; use this to pin a task to a specialist context — e.g. ['translation'] for a translation task, ['github-code-review'] for a reviewer task. The names must match skills installed on the assignee's profile."
417
+ "description": "Skill names to force-load into the dispatched worker. The kanban lifecycle is already injected automatically; use this to pin a task to a specialist context \u2014 e.g. ['translation'] for a translation task, ['github-code-review'] for a reviewer task. The names must match skills installed on the assignee's profile."
402
418
  },
403
419
  "goal_mode": {
404
420
  "type": "boolean",
@@ -414,11 +430,11 @@
414
430
  },
415
431
  "provider": {
416
432
  "type": "string",
417
- "description": "Provider the 'model' belongs to (e.g. 'openrouter', 'anthropic', 'nous'). Set this whenever the model is not from the assignee profile's configured provider — a model name alone is resolved against the profile's provider and will fail if it belongs to a different one. Requires 'model'."
433
+ "description": "Provider the 'model' belongs to (e.g. 'openrouter', 'anthropic', 'nous'). Set this whenever the model is not from the assignee profile's configured provider \u2014 a model name alone is resolved against the profile's provider and will fail if it belongs to a different one. Requires 'model'."
418
434
  },
419
435
  "board": {
420
436
  "type": "string",
421
- "description": "Kanban board slug to target. When omitted, the call resolves the active board the usual way: HERMES_KANBAN_DB env → HERMES_KANBAN_BOARD env → the 'current' symlink under the kanban home → 'default'. Pass an explicit slug only when the caller (e.g. a Telegram routing layer) needs to override the env-pinned active board for this one call."
437
+ "description": "Kanban board slug to target. When omitted, the call resolves the active board the usual way: HERMES_KANBAN_DB env \u2192 HERMES_KANBAN_BOARD env \u2192 the 'current' symlink under the kanban home \u2192 'default'. Pass an explicit slug only when the caller (e.g. a Telegram routing layer) needs to override the env-pinned active board for this one call."
422
438
  }
423
439
  },
424
440
  "required": [
@@ -429,7 +445,7 @@
429
445
  },
430
446
  {
431
447
  "name": "kanban_unblock",
432
- "description": "Unblock a Kanban task. It moves to ready when all parents are done, or todo while any parent remains open. Orchestrator-only — only profiles with the kanban toolset can unblock routed work; dispatcher-spawned task workers never see this tool.",
448
+ "description": "Unblock a Kanban task. It moves to ready when all parents are done, or todo while any parent remains open. Orchestrator-only \u2014 only profiles with the kanban toolset can unblock routed work; dispatcher-spawned task workers never see this tool.",
433
449
  "inputSchema": {
434
450
  "type": "object",
435
451
  "properties": {
@@ -439,7 +455,7 @@
439
455
  },
440
456
  "board": {
441
457
  "type": "string",
442
- "description": "Kanban board slug to target. When omitted, the call resolves the active board the usual way: HERMES_KANBAN_DB env → HERMES_KANBAN_BOARD env → the 'current' symlink under the kanban home → 'default'. Pass an explicit slug only when the caller (e.g. a Telegram routing layer) needs to override the env-pinned active board for this one call."
458
+ "description": "Kanban board slug to target. When omitted, the call resolves the active board the usual way: HERMES_KANBAN_DB env \u2192 HERMES_KANBAN_BOARD env \u2192 the 'current' symlink under the kanban home \u2192 'default'. Pass an explicit slug only when the caller (e.g. a Telegram routing layer) needs to override the env-pinned active board for this one call."
443
459
  }
444
460
  },
445
461
  "required": [
@@ -449,7 +465,7 @@
449
465
  },
450
466
  {
451
467
  "name": "kanban_link",
452
- "description": "Add a parent→child dependency edge after both tasks already exist. The child won't promote to 'ready' until all parents are 'done'. Cycles and self-links are rejected.",
468
+ "description": "Add a parent\u2192child dependency edge after both tasks already exist. The child won't promote to 'ready' until all parents are 'done'. Cycles and self-links are rejected.",
453
469
  "inputSchema": {
454
470
  "type": "object",
455
471
  "properties": {
@@ -463,7 +479,7 @@
463
479
  },
464
480
  "board": {
465
481
  "type": "string",
466
- "description": "Kanban board slug to target. When omitted, the call resolves the active board the usual way: HERMES_KANBAN_DB env → HERMES_KANBAN_BOARD env → the 'current' symlink under the kanban home → 'default'. Pass an explicit slug only when the caller (e.g. a Telegram routing layer) needs to override the env-pinned active board for this one call."
482
+ "description": "Kanban board slug to target. When omitted, the call resolves the active board the usual way: HERMES_KANBAN_DB env \u2192 HERMES_KANBAN_BOARD env \u2192 the 'current' symlink under the kanban home \u2192 'default'. Pass an explicit slug only when the caller (e.g. a Telegram routing layer) needs to override the env-pinned active board for this one call."
467
483
  }
468
484
  },
469
485
  "required": [
@@ -501,6 +517,18 @@
501
517
  },
502
518
  "evidence": {
503
519
  "type": "string"
520
+ },
521
+ "commit": {
522
+ "type": "string",
523
+ "description": "Exact commit deployed by the CI run."
524
+ },
525
+ "ci_run": {
526
+ "type": "integer",
527
+ "description": "GitHub Actions deployment run id for that exact commit."
528
+ },
529
+ "deployed_sha": {
530
+ "type": "string",
531
+ "description": "The deployed commit the independent reviewer actually inspected."
504
532
  }
505
533
  },
506
534
  "required": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-orchestrator",
3
- "version": "0.4.129",
3
+ "version": "0.4.131",
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": {