@volter/supercode-orchestrator 0.4.130 → 0.4.132
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 +107 -0
- package/board/cli.mjs +6 -4
- package/board/dispatch.mjs +24 -11
- package/board/engine.mjs +7 -4
- package/board/tools.mjs +3 -3
- package/board/workflow.mjs +14 -5
- package/mcp/hermes-kanban-tools.json +63 -35
- package/package.json +1 -1
package/board/ci.mjs
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
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
|
+
import { readFileSync, realpathSync, writeFileSync, renameSync } from 'node:fs';
|
|
5
|
+
import { dirname, join, relative } from 'node:path';
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
|
|
8
|
+
const observations = new Map();
|
|
9
|
+
const key = (board, id) => `${board.root}\0${board.slug}\0${id}`;
|
|
10
|
+
const run = (program, args, cwd) => {
|
|
11
|
+
const result = spawnSync(program, args, { cwd, encoding: 'utf8', timeout: 30_000, maxBuffer: 2 * 1024 * 1024 });
|
|
12
|
+
if (result.status !== 0) throw new Error(`${program}: ${(result.stderr || result.error?.message || 'command failed').trim().slice(-500)}`);
|
|
13
|
+
return result.stdout.trim();
|
|
14
|
+
};
|
|
15
|
+
const codePath = path => path && !/^(?:docs\/|\.volter\/tracker\/)/.test(path) && (!/\.(?:md|txt|png|jpg|svg)$/i.test(path) || /(?:^|\/)prompts\//.test(path));
|
|
16
|
+
|
|
17
|
+
export function completionEvidence(board, id) {
|
|
18
|
+
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.' };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function observeCompletionEvidence(board, id, entry, payload = {}) {
|
|
22
|
+
const state = { required: true, ready: false, reason: null };
|
|
23
|
+
observations.set(key(board, id), state);
|
|
24
|
+
if (!entry.params.completion_ci) { Object.assign(state, { required: false, ready: true }); return state; }
|
|
25
|
+
try {
|
|
26
|
+
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);
|
|
27
|
+
const parent = task?.subtask_of && board.db.prepare('SELECT workspace_path FROM tasks WHERE id=?').get(task.subtask_of);
|
|
28
|
+
const path = task?.workspace_path ?? parent?.workspace_path;
|
|
29
|
+
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)
|
|
30
|
+
.map(row => { try { return JSON.parse(row.payload)?.ci; } catch { return null; } }).find(ci => ci?.commit);
|
|
31
|
+
const supplied = payload.commit ?? payload.metadata?.commit ?? old?.commit;
|
|
32
|
+
if (!path) {
|
|
33
|
+
if (supplied) throw new Error('The named commit needs a resolved repository workspace.');
|
|
34
|
+
Object.assign(state, { required: false, ready: true }); return state;
|
|
35
|
+
}
|
|
36
|
+
const history = run('git', ['log', '--all', '--format=%H%x00', '--name-only', '--fixed-strings', `--grep=${id}`], path);
|
|
37
|
+
const records = [...history.matchAll(/([a-f0-9]{40})\0([^\0]*?)(?=[a-f0-9]{40}\0|$)/gs)];
|
|
38
|
+
const latest = records.find(([, , names]) => names.split('\n').some(codePath))?.[1];
|
|
39
|
+
if (!latest && !supplied) { Object.assign(state, { required: false, ready: true }); return state; }
|
|
40
|
+
const commit = run('git', ['rev-parse', '--verify', `${supplied ?? latest}^{commit}`], path);
|
|
41
|
+
if (latest) run('git', ['merge-base', '--is-ancestor', latest, commit], path);
|
|
42
|
+
const remote = run('git', ['remote', 'get-url', 'origin'], path);
|
|
43
|
+
const repo = /(?:github\.com[:/])([^/]+\/[^/]+?)(?:\.git)?$/.exec(remote)?.[1];
|
|
44
|
+
if (!repo) throw new Error('Deployment evidence requires this workspace\'s GitHub origin.');
|
|
45
|
+
Object.assign(state, { commit, repository: repo });
|
|
46
|
+
if (entry.params.completion_ci.keeper_repositories?.includes(repo)) {
|
|
47
|
+
const receiptPath = join(board.root, 'runtime-deployment.json');
|
|
48
|
+
const receipt = JSON.parse(readFileSync(receiptPath, 'utf8'));
|
|
49
|
+
if (receipt.repository !== repo || receipt.completed_at < Number(run('git', ['show', '-s', '--format=%ct', commit], path)) * 1000) throw new Error('No keeper tick after this runtime commit.');
|
|
50
|
+
run('git', ['merge-base', '--is-ancestor', commit, receipt.head], receipt.checkout);
|
|
51
|
+
const current = workflowRevision(board.root);
|
|
52
|
+
if (!current || current.head !== receipt.head || current.workflow_hash !== receipt.workflow_hash) throw new Error('The retained keeper tick does not describe the live checkout and loaded workflow revision.');
|
|
53
|
+
Object.assign(state, { ready: true, workflow: 'launchd keeper', run_id: `${receipt.pid}@${receipt.completed_at}`, status: 'completed', conclusion: 'success', deployed_sha: receipt.head, url: receiptPath });
|
|
54
|
+
return state;
|
|
55
|
+
}
|
|
56
|
+
const configured = entry.params.completion_ci.workflows?.[repo] ?? entry.params.completion_ci.workflows?.['*'];
|
|
57
|
+
if (!Array.isArray(configured) || !configured.length) throw new Error(`No deployment workflow is declared for ${repo} in completion_ci.workflows.`);
|
|
58
|
+
const matchedRules = (entry.params.completion_ci.path_workflows?.[repo] ?? []).filter(rule =>
|
|
59
|
+
records.some(([, , names]) => names.split('\n').some(path => rule.paths.some(prefix => path.startsWith(prefix)))));
|
|
60
|
+
const deploymentWorkflows = matchedRules.length ? configured.filter(name => matchedRules.every(rule => rule.workflows.includes(name))) : configured;
|
|
61
|
+
const requestedRun = payload.ci_run ?? payload['ci-run'] ?? payload.metadata?.ci_run ?? (old?.commit === commit ? old?.run_id : null);
|
|
62
|
+
let receipt;
|
|
63
|
+
if (requestedRun) {
|
|
64
|
+
receipt = JSON.parse(run('gh', ['run', 'view', String(requestedRun), '--repo', repo, '--json', 'databaseId,workflowName,headSha,status,conclusion,url'], path));
|
|
65
|
+
} else {
|
|
66
|
+
const runs = JSON.parse(run('gh', ['run', 'list', '--repo', repo, '--commit', commit, '--limit', '100', '--json', 'databaseId,workflowName,headSha,status,conclusion,url'], path));
|
|
67
|
+
receipt = runs.find(row => deploymentWorkflows.includes(row.workflowName));
|
|
68
|
+
}
|
|
69
|
+
if (!receipt) throw new Error(`No deployment CI run exists for ${repo}@${commit}.`);
|
|
70
|
+
Object.assign(state, { run_id: receipt.databaseId, workflow: receipt.workflowName, status: receipt.status, conclusion: receipt.conclusion, url: receipt.url });
|
|
71
|
+
if (receipt.headSha !== commit) throw new Error(`CI run ${receipt.databaseId} is for ${receipt.headSha}, not ${commit}.`);
|
|
72
|
+
if (!deploymentWorkflows.includes(receipt.workflowName)) throw new Error(`${receipt.workflowName} is not a declared deployment workflow for ${repo}.`);
|
|
73
|
+
if (receipt.status !== 'completed' || receipt.conclusion !== 'success') throw new Error(`Deployment CI ${receipt.databaseId} is ${receipt.status}/${receipt.conclusion || 'pending'}.`);
|
|
74
|
+
state.deployed_sha = commit;
|
|
75
|
+
state.ready = true;
|
|
76
|
+
state.reason = null;
|
|
77
|
+
} catch (error) { state.reason = error.message; }
|
|
78
|
+
return state;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const digest = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
82
|
+
/** Read the tracked workflow revision the next serve tick will load. */
|
|
83
|
+
export function workflowRevision(root) {
|
|
84
|
+
try {
|
|
85
|
+
const workflow = realpathSync(join(root, 'workflow.yaml'));
|
|
86
|
+
const checkout = run('git', ['rev-parse', '--show-toplevel'], dirname(workflow));
|
|
87
|
+
const head = run('git', ['rev-parse', 'HEAD'], checkout);
|
|
88
|
+
const repository = /(?:github\.com[:/])([^/]+\/[^/]+?)(?:\.git)?$/.exec(run('git', ['remote', 'get-url', 'origin'], checkout))?.[1];
|
|
89
|
+
const name = relative(checkout, workflow).replaceAll('\\', '/');
|
|
90
|
+
const source = readFileSync(workflow, 'utf8').trimEnd();
|
|
91
|
+
if (run('git', ['show', `${head}:${name}`], checkout) !== source) return null;
|
|
92
|
+
// Prompts are loaded with the workflow and must belong to the same revision.
|
|
93
|
+
run('git', ['diff', '--quiet', 'HEAD', '--', name, join(dirname(name), 'prompts')], checkout);
|
|
94
|
+
return { checkout, repository, head, workflow_hash: digest(source), started_at: Date.now(), pid: process.pid };
|
|
95
|
+
} catch { return null; }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Only the retained serve loop calls this after a successful tick. */
|
|
99
|
+
export function retainWorkflowTick(root, before) {
|
|
100
|
+
if (!before) return null;
|
|
101
|
+
const after = workflowRevision(root);
|
|
102
|
+
if (!after || after.head !== before.head || after.workflow_hash !== before.workflow_hash) return null;
|
|
103
|
+
const receipt = { ...before, completed_at: Date.now() };
|
|
104
|
+
const path = join(root, 'runtime-deployment.json'), temporary = `${path}.${process.pid}.tmp`;
|
|
105
|
+
writeFileSync(temporary, JSON.stringify(receipt) + '\n', { mode: 0o600 }); renameSync(temporary, path);
|
|
106
|
+
return receipt;
|
|
107
|
+
}
|
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;
|
|
@@ -1199,14 +1199,16 @@ async function serve(root, args = {}) {
|
|
|
1199
1199
|
const state = await laneState(root);
|
|
1200
1200
|
const settings = { ...state.settings, ...override };
|
|
1201
1201
|
interval = settings.interval;
|
|
1202
|
-
for (const r of await tick({ root, lanes: state.lanes, settings, params })) {
|
|
1202
|
+
for (const r of await tick({ root, lanes: state.lanes, settings, params, deploymentReceipt: true })) {
|
|
1203
1203
|
const said = summary(r);
|
|
1204
|
+
if (r.deployment) log(`runtime deployed ${r.deployment.repository}@${r.deployment.head}; workflow ${r.deployment.workflow_hash}; tick ${r.deployment.completed_at}`);
|
|
1204
1205
|
if (!said.endsWith('nothing to do') || args.verbose) log(said);
|
|
1205
1206
|
if (args.verbose && r.ms) log(` took ${Object.entries(r.ms).map(([k, v]) => `${k} ${(v / 1000).toFixed(1)}s`).join(', ')}`);
|
|
1206
1207
|
if (r.paused && !pausedSaid) { log(`new starts paused by ${r.paused} (\`supercode workflow resume\` lifts it)`); pausedSaid = true; }
|
|
1207
1208
|
// Event bursts do not shorten the six-sweep stuck warning.
|
|
1208
1209
|
stuck[r.board] = r.ready_waiting && !r.started.length && !r.paused ? (stuck[r.board] || Date.now()) : 0;
|
|
1209
1210
|
if (stuck[r.board] && Date.now() - stuck[r.board] >= interval * 6000 && Date.now() - (warned[r.board] ?? 0) >= 300_000) { log(`board ${r.board} stuck: ${r.ready_waiting} ready card(s) and nothing started for ${Math.floor((Date.now() - stuck[r.board]) / 1000)}s; \`supercode workflow diagnostics\``); warned[r.board] = Date.now(); }
|
|
1211
|
+
for (const [t, why] of r.queued) log(` ${t} waits: ${why}`);
|
|
1210
1212
|
for (const [t, why] of r.failed) log(` ${t} did not start: ${why}`);
|
|
1211
1213
|
for (const [t, why] of r.unsettled ?? []) log(` ${t} could not be read: ${why}`);
|
|
1212
1214
|
for (const [t, s, m] of r.started) log(` ${t} started ${s} (${m})`);
|
package/board/dispatch.mjs
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
// Every side effect follows a committed transition: a card is claimed before its session is launched, and a
|
|
16
16
|
// subscriber's cursor moves only after its notice was delivered, so two dispatchers over one board never both
|
|
17
17
|
// launch and a crash between the two steps is answered by the next tick.
|
|
18
|
+
import { workflowRevision, retainWorkflowTick } from './ci.mjs';
|
|
18
19
|
import { createRequire } from 'node:module';
|
|
19
20
|
import { spawn as spawnChild, spawnSync } from 'node:child_process';
|
|
20
21
|
import { existsSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
@@ -780,7 +781,7 @@ export async function sessionNotice(board, e) {
|
|
|
780
781
|
let error = null, receipt = null;
|
|
781
782
|
try {
|
|
782
783
|
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}`);
|
|
784
|
+
receipt = await send(boardAddress(board.label), board.label, e.address, e.text, e.messageKey ?? `board-run-notice-${e.id}-${e.durableId}`);
|
|
784
785
|
if (!receipt.delivered) error = receipt.reason ?? 'notice queued; native transcript confirmation pending'; }
|
|
785
786
|
catch (failure) { error = String(failure.message).slice(-500); }
|
|
786
787
|
tx(board, (db) => {
|
|
@@ -1091,7 +1092,7 @@ function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'wor
|
|
|
1091
1092
|
try { deliverNow(board, addressOf(prev), input, `board-run-${task.id}-r${runId}`); }
|
|
1092
1093
|
catch (error) {
|
|
1093
1094
|
tx(board, (db) => {
|
|
1094
|
-
const effect = { kind: 'session_notice', id: task.id, runId, address: addressOf(prev), text: input, verb: 'start' };
|
|
1095
|
+
const effect = { kind: 'session_notice', id: task.id, runId, address: addressOf(prev), text: input, verb: 'start', messageKey: `board-run-${task.id}-r${runId}` };
|
|
1095
1096
|
const saved = db.prepare('INSERT INTO supercode_effects(effect) VALUES(?)').run(JSON.stringify(effect));
|
|
1096
1097
|
setRunSession(db, runId, { lifecycle_notice_pending: Number(saved.lastInsertRowid), lifecycle_notice_error: error.message });
|
|
1097
1098
|
event(db, task.id, 'session_notice_failed', { verb: 'start', error: error.message, retry: true }, runId);
|
|
@@ -1303,6 +1304,7 @@ function deliverNow(board, to, body, key) {
|
|
|
1303
1304
|
const r = sc(['message', 'remote-deliver'], { input: JSON.stringify(request), timeout: 120_000 });
|
|
1304
1305
|
const answer = lastJson(r.stdout);
|
|
1305
1306
|
if (answer?.code !== 0) throw new Error((answer?.text || r.stderr || `no delivery answer (exit ${r.code})`).trim().slice(-300));
|
|
1307
|
+
if (!answer.receipt?.delivered) throw new Error(answer.receipt?.reason ?? 'native delivery confirmation pending');
|
|
1306
1308
|
}
|
|
1307
1309
|
|
|
1308
1310
|
async function send(from, label, to, body, key, { queue = false, ask = false } = {}) {
|
|
@@ -1331,8 +1333,9 @@ export async function sendBoardMessages(board, report, taskId = null) {
|
|
|
1331
1333
|
if (taskId && row.task_id !== taskId) continue;
|
|
1332
1334
|
const m = json(row.payload, {}) ?? {};
|
|
1333
1335
|
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
|
-
|
|
1336
|
+
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) });
|
|
1337
|
+
if (!receipt.delivered) throw new Error(receipt.reason ?? 'queued; native transcript confirmation pending');
|
|
1338
|
+
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
1339
|
} catch (error) {
|
|
1337
1340
|
const reason = String(error.message).slice(-200);
|
|
1338
1341
|
report.undelivered.push([row.task_id, m.to, reason]);
|
|
@@ -1404,7 +1407,7 @@ async function notify(board, fleet, report, entry) {
|
|
|
1404
1407
|
// the card as the workflow names it (its status the workflow's: triaging, reviewing, stopped; `stored_as` kanban.db's)
|
|
1405
1408
|
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
1409
|
let cursor = Number(sub.last_event_id);
|
|
1407
|
-
let failed = null;
|
|
1410
|
+
let failed = null, pending = false;
|
|
1408
1411
|
try {
|
|
1409
1412
|
for (const ev of events) {
|
|
1410
1413
|
const p = json(ev.payload, {}) ?? {};
|
|
@@ -1417,23 +1420,28 @@ async function notify(board, fleet, report, entry) {
|
|
|
1417
1420
|
text = c ? noticeOf(entry, role, ev.kind, task, p, c) : null;
|
|
1418
1421
|
} else text = noticeOf(entry, role, ev.kind, task, p);
|
|
1419
1422
|
if (text) {
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
tx(board, (db) => event(db, sub.task_id, 'notice_delivered', { source_event: ev.id, kind: ev.kind, address: sub.chat_id }));
|
|
1423
|
+
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' });
|
|
1424
|
+
if (!receipt.delivered) { pending = Boolean(receipt.queued); throw new Error(receipt.reason ?? 'native delivery confirmation pending'); }
|
|
1425
|
+
tx(board, (db) => event(db, sub.task_id, 'notice_delivered', { source_event: ev.id, kind: ev.kind, address: sub.chat_id, receipt }));
|
|
1423
1426
|
report.notified.push([sub.task_id, ev.kind, sub.chat_id]);
|
|
1424
1427
|
}
|
|
1425
1428
|
cursor = ev.id;
|
|
1426
1429
|
}
|
|
1427
1430
|
} catch (error) {
|
|
1428
1431
|
failed = String(error.message).slice(-200);
|
|
1429
|
-
tx(board, (db) =>
|
|
1432
|
+
tx(board, (db) => {
|
|
1433
|
+
const kind = pending ? 'notice_queued' : 'notice_failed';
|
|
1434
|
+
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);
|
|
1435
|
+
const payload = { after_event: cursor, address: sub.chat_id, error: failed, retry: true };
|
|
1436
|
+
if (JSON.stringify(json(previous?.payload, {})) !== JSON.stringify(payload)) event(db, sub.task_id, kind, payload);
|
|
1437
|
+
});
|
|
1430
1438
|
report.undelivered.push([sub.task_id, sub.chat_id, failed]);
|
|
1431
1439
|
}
|
|
1432
1440
|
// the cursor moves past what was delivered (a failed delivery is retried next tick; the mail id dedupes)
|
|
1433
1441
|
tx(board, (db) => {
|
|
1434
1442
|
const where = "WHERE task_id = ? AND platform = 'supercode' AND chat_id = ?";
|
|
1435
1443
|
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;
|
|
1444
|
+
const failures = failed && !pending ? Number(sub.delivery_metadata?.failures ?? 0) + 1 : 0;
|
|
1437
1445
|
if (failures >= MAX_SEND_FAILURES) { db.prepare(`DELETE FROM kanban_notify_subs ${where}`).run(sub.task_id, sub.chat_id); return; }
|
|
1438
1446
|
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
1447
|
if (task?.status === 'archived' && cursor >= events.at(-1).id) db.prepare(`DELETE FROM kanban_notify_subs ${where}`).run(sub.task_id, sub.chat_id);
|
|
@@ -1676,8 +1684,9 @@ export async function plan({ root, lanes, settings, slugs = null, params = {} })
|
|
|
1676
1684
|
* `only` limits starting to one card (a create that answers whether its card started). Each board is ticked under its
|
|
1677
1685
|
* version-checked card claims; no dispatcher file lock. Answers one report per board.
|
|
1678
1686
|
*/
|
|
1679
|
-
export async function tick({ root, lanes, settings, only = null, slugs = null, dispatch = true, params = {} }) {
|
|
1687
|
+
export async function tick({ root, lanes, settings, only = null, slugs = null, dispatch = true, params = {}, deploymentReceipt = false }) {
|
|
1680
1688
|
// a verb's flags (dispatch --max, --failure-limit) are params of this tick, read by the workflow as its own
|
|
1689
|
+
const deployment = deploymentReceipt ? workflowRevision(root) : null;
|
|
1681
1690
|
const loaded = await loadWorkflow(root, { fresh: true });
|
|
1682
1691
|
const entry = { ...loaded, params: { ...loaded.params, ...params } };
|
|
1683
1692
|
const needFleet = Object.values(lanes).some((l) => l.surface === 'pane');
|
|
@@ -1742,5 +1751,9 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
|
|
|
1742
1751
|
}
|
|
1743
1752
|
out.push(report);
|
|
1744
1753
|
}
|
|
1754
|
+
if (deployment && out.every(report => !report.error)) {
|
|
1755
|
+
const receipt = retainWorkflowTick(root, deployment);
|
|
1756
|
+
if (receipt && out[0]) out[0].deployment = receipt;
|
|
1757
|
+
}
|
|
1745
1758
|
return out;
|
|
1746
1759
|
}
|
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': {
|
package/board/workflow.mjs
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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``
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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'
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
3
|
+
"version": "0.4.132",
|
|
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": {
|