@volter/supercode-orchestrator 0.4.130 → 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;
@@ -780,7 +780,7 @@ export async function sessionNotice(board, e) {
780
780
  let error = null, receipt = null;
781
781
  try {
782
782
  if (!e.address) throw new Error('lifecycle notice awaits the owned launch session identity');
783
- receipt = await send(boardAddress(board.label), board.label, e.address, e.text, `board-run-notice-${e.id}-${e.durableId}`);
783
+ receipt = await send(boardAddress(board.label), board.label, e.address, e.text, e.messageKey ?? `board-run-notice-${e.id}-${e.durableId}`);
784
784
  if (!receipt.delivered) error = receipt.reason ?? 'notice queued; native transcript confirmation pending'; }
785
785
  catch (failure) { error = String(failure.message).slice(-500); }
786
786
  tx(board, (db) => {
@@ -1091,7 +1091,7 @@ function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'wor
1091
1091
  try { deliverNow(board, addressOf(prev), input, `board-run-${task.id}-r${runId}`); }
1092
1092
  catch (error) {
1093
1093
  tx(board, (db) => {
1094
- 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}` };
1095
1095
  const saved = db.prepare('INSERT INTO supercode_effects(effect) VALUES(?)').run(JSON.stringify(effect));
1096
1096
  setRunSession(db, runId, { lifecycle_notice_pending: Number(saved.lastInsertRowid), lifecycle_notice_error: error.message });
1097
1097
  event(db, task.id, 'session_notice_failed', { verb: 'start', error: error.message, retry: true }, runId);
@@ -1303,6 +1303,7 @@ function deliverNow(board, to, body, key) {
1303
1303
  const r = sc(['message', 'remote-deliver'], { input: JSON.stringify(request), timeout: 120_000 });
1304
1304
  const answer = lastJson(r.stdout);
1305
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');
1306
1307
  }
1307
1308
 
1308
1309
  async function send(from, label, to, body, key, { queue = false, ask = false } = {}) {
@@ -1331,8 +1332,9 @@ export async function sendBoardMessages(board, report, taskId = null) {
1331
1332
  if (taskId && row.task_id !== taskId) continue;
1332
1333
  const m = json(row.payload, {}) ?? {};
1333
1334
  try {
1334
- await send(boardAddress(board.label), board.label, m.to, m.text, m.message_id ?? `board-message-${row.id}`, { ask: Boolean(m.message_id) });
1335
- 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 } : {}) }));
1336
1338
  } catch (error) {
1337
1339
  const reason = String(error.message).slice(-200);
1338
1340
  report.undelivered.push([row.task_id, m.to, reason]);
@@ -1404,7 +1406,7 @@ async function notify(board, fleet, report, entry) {
1404
1406
  // the card as the workflow names it (its status the workflow's: triaging, reviewing, stopped; `stored_as` kanban.db's)
1405
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; });
1406
1408
  let cursor = Number(sub.last_event_id);
1407
- let failed = null;
1409
+ let failed = null, pending = false;
1408
1410
  try {
1409
1411
  for (const ev of events) {
1410
1412
  const p = json(ev.payload, {}) ?? {};
@@ -1417,23 +1419,28 @@ async function notify(board, fleet, report, entry) {
1417
1419
  text = c ? noticeOf(entry, role, ev.kind, task, p, c) : null;
1418
1420
  } else text = noticeOf(entry, role, ev.kind, task, p);
1419
1421
  if (text) {
1420
- // Hermes's delivery modes: `notify` is passive (read at the session's next turn), `wake`/`notify+wake` start its turn
1421
- 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' });
1422
- 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 }));
1423
1425
  report.notified.push([sub.task_id, ev.kind, sub.chat_id]);
1424
1426
  }
1425
1427
  cursor = ev.id;
1426
1428
  }
1427
1429
  } catch (error) {
1428
1430
  failed = String(error.message).slice(-200);
1429
- 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
+ });
1430
1437
  report.undelivered.push([sub.task_id, sub.chat_id, failed]);
1431
1438
  }
1432
1439
  // the cursor moves past what was delivered (a failed delivery is retried next tick; the mail id dedupes)
1433
1440
  tx(board, (db) => {
1434
1441
  const where = "WHERE task_id = ? AND platform = 'supercode' AND chat_id = ?";
1435
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);
1436
- const failures = failed ? Number(sub.delivery_metadata?.failures ?? 0) + 1 : 0;
1443
+ const failures = failed && !pending ? Number(sub.delivery_metadata?.failures ?? 0) + 1 : 0;
1437
1444
  if (failures >= MAX_SEND_FAILURES) { db.prepare(`DELETE FROM kanban_notify_subs ${where}`).run(sub.task_id, sub.chat_id); return; }
1438
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);
1439
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);
@@ -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': {
@@ -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,
@@ -369,7 +371,7 @@ export function fire(db, board, entry, id, name, { roles = [], payload = {}, ext
369
371
  none.noTransition = true; throw none;
370
372
  }
371
373
  for (const v of taken.validate ?? []) {
372
- 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; }
373
375
  }
374
376
  const effects = [];
375
377
  const to = take(db, board, entry, task, from, taken, scope, effects);
@@ -515,12 +517,12 @@ const firstLine = (text) => String(text ?? '').trim().split('\n')[0].slice(0, 40
515
517
  const FORMAT = {
516
518
  completed: ({ e }) => {
517
519
  const artifacts = Array.isArray(e.metadata?.artifacts) ? e.metadata.artifacts.filter((a) => typeof a === 'string' && a.trim()) : [];
518
- 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 } : {}) };
519
521
  },
520
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 }),
521
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 }),
522
524
  dependency_wait: ({ e, source }) => ({ reason: e.reason ?? null, on: e.on ?? null, source_status: source }),
523
- 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) }),
524
526
  // the reviewer is the lane that reviewed (the card's assignee while in review), unless the verdict names one
525
527
  changes_requested: ({ e, before, after }) => ({ reason: e.reason ?? null, reviewer: e.reviewer ?? before.assignee ?? null, implementer: after.assignee }),
526
528
  review_reopened: ({ after, to }) => ({ status: to, implementer: after.assignee }),
@@ -567,7 +569,14 @@ const ACTIONS = {
567
569
  }
568
570
  if (remove) {
569
571
  if (!String(reason ?? '').trim()) throw new Error('unblock must say what arrived');
570
- 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);
571
580
  }
572
581
  cardField(ctx, 'blocked_by', JSON.stringify(blocks));
573
582
  // task_links is the Hermes projection of the canonical card relations, including within this transaction.
@@ -891,7 +900,7 @@ export function fireRun(db, board, entry, id, name, { roles = [], payload = {},
891
900
  const candidates = [...(machine.states[from]?.on?.[name] ?? []), ...(machine.on?.[name] ?? [])];
892
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));
893
902
  if (!t) { const error = new Error(`${id}: run event ${name} does not apply`); error.noTransition = true; throw error; }
894
- 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));
895
904
  const to = t.target ? String(templateValue(t.target, scope)) : from;
896
905
  if (!machine.states[to]) throw new Error(`unknown run state ${to}`);
897
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.130",
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": {