@volter/supercode-orchestrator 0.4.124 → 0.4.126

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/board/cli.mjs CHANGED
@@ -13,8 +13,9 @@ import { view, backingPaths, layOut, backingOf, boardExists, boardPath, DEFAULT_
13
13
  import { isLocalWorkspace } from './workspace.mjs';
14
14
  import { openZtrackBoard } from './ztrack.mjs';
15
15
  import * as engine from './engine.mjs';
16
- import { adoptSession, boardAddress, boardLabel, boardSettings, callerSession, reconcileCallerLaunch, closeRequested, lanesOf, localMachine, observeFleet, paused, plan, startCard, tick } from './dispatch.mjs';
17
- import { loadWorkflow, requestSessionClose, scopeOf, statusOf, useWorkflow, workflowEntry, workflowFor } from './workflow.mjs';
16
+ import { adoptSession, boardAddress, boardLabel, boardSettings, callerSession, commandEnv, reconcileCallerLaunch, closeRequested, lanesOf, localMachine, observeFleet, paused, plan, startCard, supercodeBin, tick } from './dispatch.mjs';
17
+ import { watchBoard } from './wake.mjs';
18
+ import { creatorOf, loadWorkflow, requestSessionClose, scopeOf, statusOf, useWorkflow, workflowEntry, workflowFor } from './workflow.mjs';
18
19
  import { evaluate } from './expr.mjs';
19
20
 
20
21
  const HELP = `supercode workflow <verb> [--root <home>] [--board <slug>] [--json]
@@ -34,6 +35,7 @@ Hermes Kanban's verbs, with their flags:
34
35
  complete <id>... [--result] [--summary] [--metadata JSON] edit <id> --result [--summary] [--metadata]
35
36
  block <id> [reason] (--on <card> | --waiting-on <message id>) [--ids ...] schedule <id> [reason] [--ids ...]
36
37
  pause <id> --reason <why> resume <id> cancel <id> set-every <id> <interval|none>
38
+ request-state <id> --state block|pause|done|cancelled --reason <why> [--on <card>|--waiting-on <message>] [--evidence <pointers>]
37
39
  unblock <id>... [--reason] request-review <id> [--summary] [--reviewer] [--force] request-changes <id> <reason> reopen-review <id>... reopen <id>... [--reason]
38
40
  promote <id> [reason] [--ids ...] [--force] [--dry-run] archive <id>... [--rm <id>...]
39
41
  specify <id> [--title --body] [--all] decompose <id> [--child "<title>[@<profile>]"]... [--all]
@@ -55,8 +57,8 @@ supercode's own:
55
57
  goto <id> <status> [reason] [--<field> value]: the event the workflow says takes the card to that status, sent.
56
58
 
57
59
  specify/decompose with no spec start a session on the triage card to write it. A card's own session may comment on,
58
- block (with --kind), heartbeat and request review of its card, specify or decompose it in triage, and file, complete,
59
- block and comment on its subtasks; its arc is marked done by its reviewers, never by itself.
60
+ heartbeat and request review of its card, specify or decompose it in triage, and work its subtasks. The home's
61
+ workflow declares who may block, pause or finish a card; request-state mails its manager without changing state.
60
62
  `;
61
63
 
62
64
  const MULTI = new Set(['parent', 'skill', 'worker', 'child', 'ids', 'rm']);
@@ -238,6 +240,7 @@ export async function runWorkflow(argv) {
238
240
 
239
241
  // a caller that is a card's own session (its harness names it; the board recorded which session it launched)
240
242
  const listing = ['list','ls'].includes(verb);
243
+ await loadWorkflow(root).catch((error) => { if (verb !== 'init' && verb !== 'boards') throw error; });
241
244
  const sid = listing && !args.mine ? null : callerSession();
242
245
  // what this command records is the calling session's act (its address, as its subscriptions name it)
243
246
  if (sid) setActor(`sc:${localMachine()}:${sid === process.env.CODEX_THREAD_ID ? 'codex' : 'claude-code'}:${sid}`);
@@ -246,7 +249,6 @@ export async function runWorkflow(argv) {
246
249
  }
247
250
  // the board's workflow (the IR's): what each verb does, and who may send it, is its
248
251
  // every verb reads the board through its workflow (a list settles cards too), so every verb loads it
249
- if (verb !== 'help' && !listing) await loadWorkflow(root).catch((error) => { if (verb !== 'init' && verb !== 'boards') throw error; });
250
252
  engine.setCaller((db, cardId) => rolesFor(db, cardId, sid, workflowFor(root).params));
251
253
  // a verb with no session behind it (a person at the CLI, the board file's sync) is the manager's, as its roles are
252
254
  if (!sid && verb !== 'help') { try { setActor([].concat(workflowFor(root).params?.managers ?? [])[0] ?? null); } catch { /* no workflow */ } }
@@ -840,10 +842,10 @@ export async function runWorkflow(argv) {
840
842
  if (!declared) throw new Error(`no workflow verb ${verb}; \`supercode workflow help\` lists them`);
841
843
  need(1, 'a card id');
842
844
  const { _: _words, root: _r, board: _b, json: _j, at: _a, ids: _i, ...fields } = args;
843
- const payload = { ...fields, reason: args.reason ?? (rest.slice(1).join(' ') || null) };
845
+ const payload = { ...fields, waiting_on: args['waiting-on'] ?? fields.waiting_on, reason: args.reason ?? (rest.slice(1).join(' ') || null) };
844
846
  const results = [];
845
847
  for (const t of [id, ...(args.ids ?? [])]) results.push([t, (await withBoardAsync((b) => engine.act(b, t, name, { payload }))).to]);
846
- say(args, results.map(([t, s]) => `${t} → ${s}`).join('\n'), { ok: true, results }); return 0;
848
+ 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;
847
849
  }
848
850
  }
849
851
  }
@@ -1060,16 +1062,10 @@ function ownCard(db, sessionId) {
1060
1062
 
1061
1063
  const READS = new Set(['list', 'ls', 'show', 'context', 'runs', 'log', 'tail', 'watch', 'stats', 'assignees', 'diagnostics', 'diag', 'attachments', 'notify-list', 'help', 'init']);
1062
1064
 
1063
- /**
1064
- * What a card's own session may write (the owner's rule): on its card, comment, block with a typed reason, heartbeat,
1065
- * request review, attach a file, specify or decompose it in triage; on its subtasks, file, complete, block, unblock,
1066
- * comment and attach. Never its arc's completion, and never another arc's card: anything across arcs goes through the
1067
- * fleet's manager.
1068
- */
1069
1065
  /**
1070
1066
  * Who a caller is on card `cardId`, in the workflow's roles: a person or the fleet's manager at the CLI (no session)
1071
1067
  * is `manager`; the session of the card's current run has that run's role; the card's working session between runs
1072
- * is its `implementer`; the session working a subtask's arc is `arc`; any other session is nobody on it.
1068
+ * is its `implementer`; the session working a subtask's arc is `arc`; its recorded creator also has the creator role; any other session is nobody on it.
1073
1069
  */
1074
1070
  function rolesFor(db, cardId, sessionId, params = {}) {
1075
1071
  if (!sessionId) return ['manager', 'owner'];
@@ -1079,6 +1075,7 @@ function rolesFor(db, cardId, sessionId, params = {}) {
1079
1075
  const task = engine.getTask(db, cardId);
1080
1076
  if (!task) return [];
1081
1077
  const roles = [];
1078
+ if (creatorOf(db, cardId)?.endsWith(`:${sessionId}`)) roles.push('creator');
1082
1079
  const current = task.current_run_id ? json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(task.current_run_id)?.metadata, {})?.supercode : null;
1083
1080
  if (current?.session_id === sessionId) roles.push(current.role ?? 'implementer');
1084
1081
  else if (engine.lastSession(db, cardId)?.session_id === sessionId) roles.push('implementer');
@@ -1099,6 +1096,7 @@ function authorize(db, verb, rest, args, own) {
1099
1096
  if (args.all) refuse(`does not ${verb} every card; only its own`);
1100
1097
  const card = engine.getTask(db, rest[0]);
1101
1098
  if (!card) return; // the verb itself answers "no card"
1099
+ if (creatorOf(db, card.id)?.endsWith(`:${callerSession()}`)) return; // its declared creator doors still govern the act
1102
1100
  // a card's session writes its own card and its subtasks; what it may do with them is the workflow's (`by`)
1103
1101
  if (card.id !== own.id && card.subtask_of !== own.id) refuse(`writes only its own card and its subtasks, not ${card.id}`);
1104
1102
  for (const other of args.ids ?? []) if (other !== own.id && engine.getTask(db, other)?.subtask_of !== own.id) refuse(`writes only its own card and its subtasks, not ${other}`);
@@ -1139,7 +1137,8 @@ async function serve(root, args = {}) {
1139
1137
  const params = { ...(Number(args.max) > 0 ? { max_spawn: Number(args.max) } : {}), ...(Number(args['failure-limit']) > 0 ? { failure_limit: Number(args['failure-limit']) } : {}) };
1140
1138
  if (args.pidfile) writeFileSync(args.pidfile, `${process.pid}\n`);
1141
1139
  let stopped = false;
1142
- const stop = () => { stopped = true; };
1140
+ let events;
1141
+ const stop = () => { stopped = true; events?.close(); };
1143
1142
  process.on('SIGTERM', stop);
1144
1143
  process.on('SIGINT', stop);
1145
1144
  // what the serve says is kept in the home too (logs/workflow-serve.log), so why it stopped outlives its pane
@@ -1154,9 +1153,12 @@ async function serve(root, args = {}) {
1154
1153
  const installed = () => { try { return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version ?? null; } catch { return null; } };
1155
1154
  const running = installed();
1156
1155
  log(`dispatching the boards of ${root} from ${localMachine()} (orchestrator ${running ?? 'unknown'})`);
1156
+ events = watchBoard({ root, binary: supercodeBin(), env: commandEnv(), log });
1157
+ log('dispatch wakes on board writes, document edits, board mail and session catalogue events; interval is a safety sweep');
1157
1158
  const stuck = {};
1158
1159
  const warned = {};
1159
1160
  let pausedSaid = false;
1161
+ let wakeReason = null;
1160
1162
  try {
1161
1163
  while (!stopped) {
1162
1164
  // An installer owns this operational window, not a card pause. Finish the
@@ -1177,6 +1179,8 @@ async function serve(root, args = {}) {
1177
1179
  pausedSaid = Boolean(paused(root)) && pausedSaid;
1178
1180
  let interval = 60;
1179
1181
  const began = Date.now();
1182
+ if (wakeReason) log(`dispatch wake ${wakeReason.sources.join(', ')}; event age ${began - wakeReason.at}ms`);
1183
+ wakeReason = null;
1180
1184
  try {
1181
1185
  const state = await laneState(root);
1182
1186
  const settings = { ...state.settings, ...override };
@@ -1186,9 +1190,9 @@ async function serve(root, args = {}) {
1186
1190
  if (!said.endsWith('nothing to do') || args.verbose) log(said);
1187
1191
  if (args.verbose && r.ms) log(` took ${Object.entries(r.ms).map(([k, v]) => `${k} ${(v / 1000).toFixed(1)}s`).join(', ')}`);
1188
1192
  if (r.paused && !pausedSaid) { log(`new starts paused by ${r.paused} (\`supercode workflow resume\` lifts it)`); pausedSaid = true; }
1189
- // Hermes's stuck warning: ready work for six ticks running and nothing started
1190
- stuck[r.board] = r.ready_waiting && !r.started.length && !r.paused ? (stuck[r.board] ?? 0) + 1 : 0;
1191
- if (stuck[r.board] >= 6 && Date.now() - (warned[r.board] ?? 0) >= 300_000) { log(`board ${r.board} stuck: ${r.ready_waiting} ready card(s) and nothing started for ${stuck[r.board]} ticks; \`supercode workflow diagnostics\``); warned[r.board] = Date.now(); }
1193
+ // Event bursts do not shorten the six-sweep stuck warning.
1194
+ stuck[r.board] = r.ready_waiting && !r.started.length && !r.paused ? (stuck[r.board] || Date.now()) : 0;
1195
+ 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(); }
1192
1196
  for (const [t, why] of r.failed) log(` ${t} did not start: ${why}`);
1193
1197
  for (const [t, why] of r.unsettled ?? []) log(` ${t} could not be read: ${why}`);
1194
1198
  for (const [t, s, m] of r.started) log(` ${t} started ${s} (${m})`);
@@ -1201,14 +1205,19 @@ async function serve(root, args = {}) {
1201
1205
  const newer = () => { const now = installed(); return running && now && now !== running ? now : null; };
1202
1206
  let moved = newer();
1203
1207
  // the next tick is due an interval after this one began (a long tick is not followed by a full interval's wait)
1204
- const rest = Math.max(1, interval - Math.floor((Date.now() - began) / 1000));
1205
- for (let waited = 0; waited < rest && !stopped && !moved; waited += 1) {
1206
- await new Promise((done) => setTimeout(done, 1000));
1207
- if (waited % 5 === 4) moved = newer();
1208
+ events.refresh();
1209
+ if (!stopped && !moved) {
1210
+ wakeReason = await events.wait(Math.max(0, interval * 1000 - (Date.now() - began)));
1211
+ // A busy event stream is coalesced to at most one tick per second.
1212
+ const remaining = 1000 - (Date.now() - began);
1213
+ if (remaining > 0 && !stopped) await new Promise(done => setTimeout(done, remaining));
1214
+ moved = newer();
1208
1215
  }
1209
1216
  if (moved) { log(`orchestrator ${moved} is installed (this serve runs ${running}); stopping, for its keeper to start the new one`); break; }
1210
1217
  }
1211
1218
  } finally {
1219
+ events?.close();
1220
+ process.off('SIGTERM', stop); process.off('SIGINT', stop);
1212
1221
  if (args.pidfile) { try { unlinkSync(args.pidfile); } catch { /* gone */ } }
1213
1222
  }
1214
1223
  log(stopped ? 'stopped (a signal)' : 'stopped');
@@ -1245,16 +1245,23 @@ async function send(from, label, to, body, key, { queue = false, ask = false } =
1245
1245
  * and marked board_message_sent. A question goes under the id its card waits on; a resend of a sent one is a no-op
1246
1246
  * (the id is the send's idempotency key). One that fails is tried again next tick.
1247
1247
  */
1248
- async function sendBoardMessages(board, report) {
1248
+ export async function sendBoardMessages(board, report, taskId = null) {
1249
1249
  const rows = view(board, (db) => db.prepare(`SELECT id, task_id, payload FROM task_events e WHERE kind = 'board_message' AND NOT EXISTS
1250
1250
  (SELECT 1 FROM task_events s WHERE s.kind = 'board_message_sent' AND s.task_id = e.task_id AND json_extract(s.payload, '$.event') = e.id) ORDER BY id`).all());
1251
1251
  for (const row of rows) {
1252
+ if (taskId && row.task_id !== taskId) continue;
1252
1253
  const m = json(row.payload, {}) ?? {};
1253
1254
  try {
1254
1255
  await send(boardAddress(board.label), board.label, m.to, m.text, m.message_id ?? `board-message-${row.id}`, { ask: Boolean(m.message_id) });
1255
1256
  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 } : {}) }));
1256
1257
  } catch (error) {
1257
- report.undelivered.push([row.task_id, m.to, String(error.message).slice(-200)]);
1258
+ const reason = String(error.message).slice(-200);
1259
+ report.undelivered.push([row.task_id, m.to, reason]);
1260
+ tx(board, db => {
1261
+ const previous = db.prepare("SELECT payload,created_at FROM task_events WHERE task_id=? AND kind='board_message_failed' AND json_extract(payload,'$.event')=? ORDER BY id DESC LIMIT 1").get(row.task_id, row.id);
1262
+ if (json(previous?.payload, {})?.reason !== reason || now() - (previous?.created_at ?? 0) >= 60)
1263
+ event(db, row.task_id, 'board_message_failed', { event: row.id, to: m.to, message_id: m.message_id, reason });
1264
+ });
1258
1265
  }
1259
1266
  }
1260
1267
  }
package/board/engine.mjs CHANGED
@@ -12,7 +12,7 @@ import { existsSync, rmSync } from 'node:fs';
12
12
  import { join, resolve } from 'node:path';
13
13
  import { checkWorkspace, assertWorkspaceOwner, declareWorkspace, isLocalWorkspace, isWorkspacePath } from './workspace.mjs';
14
14
  import { DatabaseSync } from 'node:sqlite';
15
- import { view, claimer, event, json, newTaskId, now, tx } from './store.mjs';
15
+ import { view, actorOf, claimer, event, json, newTaskId, now, tx } from './store.mjs';
16
16
  import { addDependency, retireSupersededRuns, refreshCardInputs, requestSessionClose, eventTo, fire, loadWorkflow, setStatus, settle, startNew, statusOf, runEvent, workflowFor } from './workflow.mjs';
17
17
 
18
18
  export const STATUSES = ['triage', 'todo', 'scheduled', 'ready', 'running', 'blocked', 'review', 'done', 'archived'];
@@ -184,6 +184,10 @@ export function createCard(board, spec) {
184
184
  if (arc?.subtask_of) throw new Error(`${arc.id} is itself a subtask of ${arc.subtask_of}; a subtask belongs to an arc`);
185
185
  let id = newTaskId();
186
186
  while (one(db, 'SELECT 1 FROM tasks WHERE id = ?', id)) id = newTaskId();
187
+ const creationRoles = workflowFor(board.root).params.create_dependencies_by;
188
+ if ((parents.length || initial === 'blocked') && creationRoles && !callerRoles(db, arc?.id ?? id).some(role => creationRoles.includes(role))) {
189
+ throw new Error('Creating a dependency or initially blocked card requires a permitted manager role; file the task without a blocker and use request-state.');
190
+ }
187
191
  assertWorkspaceOwner(db, workspaceTask, checked);
188
192
  // The repository declaration stays separate from the resolved worktree path.
189
193
  if (project) {
@@ -211,7 +215,7 @@ export function createCard(board, spec) {
211
215
  // the create's own words: whether it asked to be a goal card (null: the workflow decides), triage, its arc
212
216
  const status = startNew(db, board, entry, getTask(db, id), { triage: Boolean(spec.triage), initial_status: initial,
213
217
  every: spec.every ?? null, goal: spec.goal === undefined || spec.goal === null ? null : spec.goal !== false, subtask_of: arc?.id ?? null });
214
- event(db, id, 'created', { assignee: spec.assignee || null, status, parents, tenant: spec.tenant ?? null, ...(machine ? { machine } : {}), ...(arc ? { subtask_of: arc.id } : {}) });
218
+ event(db, id, 'created', { creator: actorOf(), assignee: spec.assignee || null, status, parents, tenant: spec.tenant ?? null, ...(machine ? { machine } : {}), ...(arc ? { subtask_of: arc.id } : {}) });
215
219
  if (parents.length) inheritSubscriptions(db, id, parents);
216
220
  if (arc) {
217
221
  // the arc's subscribers hear its subtasks, and the arc's session works them
@@ -382,6 +386,10 @@ export async function runEffects(board, effects = [], { retryNotices = false } =
382
386
  if (!await sessionNotice(board, e)) continue;
383
387
  }
384
388
  else if (e.kind === 'cleanup_workspace') cleanupWorkspace(board, e.id);
389
+ else if (e.kind === 'board_message') {
390
+ const { sendBoardMessages } = await import('./dispatch.mjs');
391
+ await sendBoardMessages(board, { undelivered: [] }, e.id);
392
+ }
385
393
  else if (e.kind === 'invalidate_descendants') tx(board, (db) => invalidateDescendants(db, board, e.id));
386
394
  else if (e.kind === 'stop_run') stopWorker(board, e.runId);
387
395
  else if (e.kind === 'close_session' || e.kind === 'send_to_actor' || e.kind === 'run_ended') {
@@ -685,7 +693,7 @@ export function runs(db, id, { stateType = null, stateName = null } = {}) {
685
693
 
686
694
  /** Delete the thread events of done and archived cards older than `seconds` (Hermes's `gc` of events). */
687
695
  export function gcEvents(board, seconds) {
688
- return tx(board, (db) => Number(run(db, "DELETE FROM task_events WHERE created_at < ? AND task_id IN (SELECT id FROM tasks WHERE status IN ('done', 'archived'))", now() - seconds).changes));
696
+ return tx(board, (db) => Number(run(db, "DELETE FROM task_events WHERE kind != 'created' AND created_at < ? AND task_id IN (SELECT id FROM tasks WHERE status IN ('done', 'archived'))", now() - seconds).changes));
689
697
  }
690
698
 
691
699
  // ------------------------------------------------------------------ diagnostics
package/board/store.mjs CHANGED
@@ -16,6 +16,7 @@ import { dirname, join } from 'node:path';
16
16
  import { DatabaseSync } from 'node:sqlite';
17
17
  import { openZtrackBoard } from './ztrack.mjs';
18
18
  import { isFilesBoard, openFilesBoard } from './files.mjs';
19
+ import { signalBoardWrite } from './wake.mjs';
19
20
 
20
21
  export const DEFAULT_BOARD = 'default';
21
22
 
@@ -163,7 +164,11 @@ export function openBoard(root, slug = DEFAULT_BOARD, { create = false, backing
163
164
  * Run `fn(db)` in one write transaction of the board's backing: answers what `fn` answers; rolls back on a throw.
164
165
  * A read inside the transaction is the state it writes over.
165
166
  */
166
- export const tx = (board, fn) => board.tx(fn);
167
+ export const tx = (board, fn) => {
168
+ const result = board.tx(fn);
169
+ signalBoardWrite(board.root, actor);
170
+ return result;
171
+ };
167
172
  export const view = (board, fn) => fn(board.db);
168
173
 
169
174
  // A deferred native transaction, with the whole operation retried on an optimistic conflict.
package/board/tools.mjs CHANGED
@@ -5,9 +5,9 @@
5
5
  // unblocking tools are the orchestrator's, not a worker's. Answers are JSON text: `{ ok: true, … }` or `{ error }`.
6
6
  import { mkdirSync, statSync, writeFileSync } from 'node:fs';
7
7
  import { basename, join } from 'node:path';
8
- import { DEFAULT_BOARD, event, json, openBoard, tx } from './store.mjs';
8
+ import { DEFAULT_BOARD, actorOf, event, json, openBoard, setActor, tx } from './store.mjs';
9
9
  import * as engine from './engine.mjs';
10
- import { loadWorkflow } from './workflow.mjs';
10
+ import { creatorOf, loadWorkflow } from './workflow.mjs';
11
11
 
12
12
  const ok = (fields = {}) => JSON.stringify({ ok: true, ...fields });
13
13
  const error = (message) => JSON.stringify({ error: message });
@@ -25,7 +25,7 @@ export function redact(value) {
25
25
  return value;
26
26
  }
27
27
 
28
- const LIFECYCLE = new Set(['kanban_complete', 'kanban_block', 'kanban_request_review', 'kanban_request_changes', 'kanban_heartbeat', 'kanban_attach', 'kanban_attach_url', 'kanban_unblock']);
28
+ const LIFECYCLE = new Set(['kanban_complete', 'kanban_block', 'kanban_request_state', 'kanban_request_review', 'kanban_request_changes', 'kanban_heartbeat', 'kanban_attach', 'kanban_attach_url', 'kanban_unblock']);
29
29
  const ORCHESTRATOR_ONLY = new Set(['kanban_list', 'kanban_unblock']);
30
30
 
31
31
  /** One board tool call. `root` is the home; `env` the worker's task environment (`HERMES_KANBAN_*`). */
@@ -33,30 +33,39 @@ export async function boardTool(root, name, args = {}, env = {}) {
33
33
  const own = env.HERMES_KANBAN_TASK || null;
34
34
  const runId = env.HERMES_KANBAN_RUN_ID ? Number(env.HERMES_KANBAN_RUN_ID) : null;
35
35
  const author = env.HERMES_PROFILE || 'worker';
36
- if (own && ORCHESTRATOR_ONLY.has(name)) return error(`${name} is the orchestrator's; a dispatched worker uses kanban_complete, kanban_block, kanban_heartbeat or kanban_comment for its own task`);
36
+ if (own && ORCHESTRATOR_ONLY.has(name)) return error(`${name} is the orchestrator's; read kanban_show for your task's declared doors, or use kanban_request_state, kanban_heartbeat or kanban_comment`);
37
37
  const tid = args.task_id || own;
38
38
  if (!['kanban_list', 'kanban_create', 'kanban_link'].includes(name) && !tid) return error('task_id is required (or HERMES_KANBAN_TASK in the environment)');
39
- if (own && LIFECYCLE.has(name) && tid !== own) return error(`worker is scoped to task ${own}; refusing to change ${tid}. Use kanban_comment to hand off to other tasks, or kanban_create for follow-up work.`);
40
39
  const slug = args.board || env.HERMES_KANBAN_BOARD || DEFAULT_BOARD;
41
40
  // the board's workflow decides what each tool's event does; the worker is its run's role on its own task, and
42
41
  // nobody on another (Hermes's worker scoping)
43
42
  try { await loadWorkflow(root); } catch (e) { return error(e.message); }
44
43
  engine.setCaller((db, cardId) => {
45
- if (!own || cardId !== own || !runId) return own ? [] : ['manager'];
44
+ if (!own || !runId) return own ? [] : ['manager'];
46
45
  const meta = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(runId)?.metadata, {})?.supercode ?? {};
47
- return [meta.role ?? 'implementer'];
46
+ const roles = cardId === own ? [meta.role ?? 'implementer'] : [];
47
+ if (actorOf() && creatorOf(db, cardId) === actorOf()) roles.push('creator');
48
+ return roles;
48
49
  });
49
50
  let board;
50
51
  try { board = openBoard(root, slug); } catch (e) { return error(e.message); }
51
52
  board.label = slug;
53
+ const callingRun = runId ? json(board.db.prepare('SELECT metadata FROM task_runs WHERE id=?').get(runId)?.metadata, {})?.supercode : null;
54
+ setActor(own && runId ? callingRun?.address ?? `hermes-run:${slug}:${runId}` : null);
55
+ if (own && LIFECYCLE.has(name) && tid !== own && creatorOf(board.db, tid) !== actorOf()) {
56
+ board.close(); return error(`worker is scoped to task ${own}; refusing to change ${tid}. Use kanban_comment to hand off to other tasks.`);
57
+ }
52
58
  const current = () => tx(board, (db) => engine.getTask(db, tid));
53
59
  // a worker whose run was superseded (reclaimed, re-dispatched) no longer speaks for the card
54
60
  const ownRun = () => {
55
- if (!own || tid !== own || !runId) return null;
56
- const t = current();
57
- return t && Number(t.current_run_id) !== runId ? `run ${runId} of ${tid} is no longer its current run; nothing was changed` : null;
61
+ if (!own) return null;
62
+ const t = engine.getTask(board.db, own);
63
+ const r = runId && board.db.prepare('SELECT task_id, ended_at FROM task_runs WHERE id=?').get(runId);
64
+ return !r || r.task_id !== own || r.ended_at != null || Number(t?.current_run_id) !== runId
65
+ ? `run ${runId ?? '(missing)'} of ${own} is no longer its current run; nothing was changed` : null;
58
66
  };
59
67
  try {
68
+ if (name !== 'kanban_show' && name !== 'kanban_list') { const stale = ownRun(); if (stale) return error(stale); }
60
69
  switch (name) {
61
70
  case 'kanban_show': {
62
71
  const card = tx(board, (db) => { const c = engine.showCard(db, tid); c.context = engine.cardContext(db, tid); return c; });
@@ -104,6 +113,11 @@ export async function boardTool(root, name, args = {}, env = {}) {
104
113
  const { to: status } = await engine.block(board, tid, { reason: args.reason ? redact(String(args.reason)) : null, on: args.on ?? null, waitingOn: args.waiting_on ?? null });
105
114
  return ok({ task_id: tid, status });
106
115
  }
116
+ case 'kanban_request_state': {
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 } });
119
+ return ok({ task_id: tid, requested: args.state, changed: false });
120
+ }
107
121
  case 'kanban_request_review': {
108
122
  const stale = ownRun(); if (stale) return error(stale);
109
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 });
package/board/wake.mjs ADDED
@@ -0,0 +1,134 @@
1
+ // Wake hints never own board state. A missed hint is repaired by the safety
2
+ // sweep; a writer never waits for a dispatcher or for another writer.
3
+ import { watch, existsSync, readFileSync, readdirSync, writeFileSync, renameSync, unlinkSync, realpathSync } from 'node:fs';
4
+ import { basename, dirname, join, resolve } from 'node:path';
5
+ import { randomUUID } from 'node:crypto';
6
+ import { spawn } from 'node:child_process';
7
+ import { tmpdir } from 'node:os';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ export function signalBoardWrite(root, actor) {
11
+ if (!root || actor?.includes(':board:')) return;
12
+ const path = join(root, '.dispatch-wake.json');
13
+ const temporary = `${path}.${randomUUID()}.tmp`;
14
+ try {
15
+ writeFileSync(temporary, JSON.stringify({ at: Date.now(), actor }) + '\n', { mode: 0o600 });
16
+ renameSync(temporary, path);
17
+ } catch { try { unlinkSync(temporary); } catch { /* hints cannot fail a committed verb */ } }
18
+ }
19
+
20
+ /** One coalescing queue shared by standalone serve and the hosted dispatcher. */
21
+ export function watchBoard({ root, binary, env, log = () => {} }) {
22
+ const watchers = new Map();
23
+ let pending = null, waiter = null, closed = false, child = null, retry = null, lastError = null;
24
+ const wake = (source, at = Date.now()) => {
25
+ if (closed) return;
26
+ if (!pending) pending = { at, sources: new Set() };
27
+ pending.at = Math.min(pending.at, at);
28
+ pending.sources.add(source);
29
+ waiter?.();
30
+ };
31
+ const directory = (path, key, accept) => {
32
+ const id = `${path}\0${key}`;
33
+ if (watchers.has(id) || !existsSync(path)) return;
34
+ try {
35
+ const handle = watch(path, (_event, filename) => {
36
+ if (filename == null || accept(String(filename))) wake(key);
37
+ });
38
+ handle.on('error', error => {
39
+ handle.close(); watchers.delete(id);
40
+ log(`dispatch watch ${key} failed: ${error.message}; safety sweep remains active`);
41
+ });
42
+ watchers.set(id, handle);
43
+ } catch (error) { log(`dispatch watch ${key} unavailable: ${error.message}; safety sweep remains active`); }
44
+ };
45
+ const file = (path, key) => {
46
+ directory(dirname(path), key, name => name === basename(path));
47
+ try {
48
+ const target = realpathSync(path);
49
+ if (target !== path) directory(dirname(target), key, name => name === basename(target));
50
+ } catch { /* the parent watcher sees a newly created file */ }
51
+ };
52
+ const refresh = () => {
53
+ directory(root, 'board-write', name => {
54
+ if (name !== '.dispatch-wake.json') return false;
55
+ try { const receipt = JSON.parse(readFileSync(join(root, name), 'utf8')); wake('board-write', receipt.at); } catch { wake('board-write'); }
56
+ return false;
57
+ });
58
+ for (const name of ['workflow.yaml', 'config.yaml', 'installation-window.json', 'ESTOP']) file(join(root, name), name);
59
+ file(fileURLToPath(new URL('../package.json', import.meta.url)), 'installation');
60
+ const boards = join(root, 'kanban', 'boards');
61
+ directory(join(root, 'kanban'), 'board-registry', name => ['boards', 'ztrack.json', 'current'].includes(name));
62
+ directory(boards, 'board-registry', () => true);
63
+ const dirs = [join(root, 'kanban'), ...(existsSync(boards) ? readdirSync(boards).map(name => join(boards, name)) : [])];
64
+ for (const dir of dirs) {
65
+ file(join(dir, 'ztrack.json'), 'board-config');
66
+ try {
67
+ const config = JSON.parse(readFileSync(join(dir, 'ztrack.json'), 'utf8'));
68
+ const document = resolve(dir, config.document);
69
+ file(document, `document:${document}`);
70
+ file(`${document}.archive.md`, `document:${document}.archive.md`);
71
+ } catch { /* other backing or not yet created */ }
72
+ }
73
+ const config = env.SUPERCODE_HOME || (env.XDG_CONFIG_HOME ? join(env.XDG_CONFIG_HOME, 'supercode') : join(env.HOME ?? '', '.config', 'supercode'));
74
+ const mail = join(config, 'mail');
75
+ directory(mail, 'mailbox-registry', name => name.startsWith('board-'));
76
+ if (existsSync(mail)) for (const name of readdirSync(mail)) {
77
+ if (name.startsWith('board-')) directory(join(mail, name, 'new'), 'board-mail', name => name.endsWith('.json'));
78
+ }
79
+ };
80
+ // The retained Teams catalogue includes local and remote session changes.
81
+ // This is a read-only stream, with no model or new native harness session.
82
+ const follow = () => {
83
+ if (closed) return;
84
+ let buffer = '', error = '';
85
+ const command = env.SUPERCODE_TEAMS_ENTRY ? process.execPath : binary;
86
+ const args = [...(env.SUPERCODE_TEAMS_ENTRY ? [env.SUPERCODE_TEAMS_ENTRY] : []), 'sessions', 'list', '--watch', '--json'];
87
+ child = spawn(command, args, { cwd: tmpdir(), env, stdio: ['ignore', 'pipe', 'pipe'] });
88
+ child.stdout.setEncoding('utf8').on('data', chunk => {
89
+ buffer += chunk;
90
+ if (buffer.length > 8 * 1024 * 1024) { error = 'catalogue frame exceeds 8 MiB'; child.kill(); return; }
91
+ let end;
92
+ while ((end = buffer.indexOf('\n')) >= 0) {
93
+ const line = buffer.slice(0, end); buffer = buffer.slice(end + 1);
94
+ try {
95
+ const event = JSON.parse(line);
96
+ lastError = null;
97
+ if (!['heartbeat', 'ping', 'keepalive'].includes(event.type)) wake('session-catalogue');
98
+ } catch { /* CLI diagnostics are not events */ }
99
+ }
100
+ });
101
+ child.stderr.setEncoding('utf8').on('data', chunk => { error = (error + chunk).slice(-500); });
102
+ child.on('error', failure => { error = failure.message; });
103
+ child.on('close', () => {
104
+ child = null;
105
+ if (!closed) {
106
+ const reason = error.trim() || 'connection closed';
107
+ if (reason !== lastError) log(`dispatch session stream ended: ${reason}; reconnecting, safety sweep remains active`);
108
+ lastError = reason;
109
+ retry = setTimeout(follow, 5000);
110
+ }
111
+ });
112
+ };
113
+ refresh(); follow();
114
+ return {
115
+ refresh,
116
+ wake,
117
+ async wait(milliseconds) {
118
+ if (!pending && !closed) await new Promise(done => {
119
+ const timer = setTimeout(finish, Math.max(0, milliseconds));
120
+ function finish() { clearTimeout(timer); waiter = null; done(); }
121
+ waiter = finish;
122
+ });
123
+ // Bound coalescing: a sustained event stream cannot postpone dispatch.
124
+ if (pending && !closed) await new Promise(done => setTimeout(done, 100));
125
+ const result = pending; pending = null;
126
+ return result ? { at: result.at, sources: [...result.sources] } : null;
127
+ },
128
+ close() {
129
+ closed = true; waiter?.(); clearTimeout(retry);
130
+ for (const handle of watchers.values()) handle.close();
131
+ watchers.clear(); child?.kill('SIGTERM');
132
+ },
133
+ };
134
+ }
@@ -12,12 +12,12 @@ export function taskProtocol(taskId) {
12
12
  `You were started for board task ${taskId} (it is also in HERMES_KANBAN_TASK). The board's tools are yours:`,
13
13
  '- `kanban_show` reads your task: its body, its parents\' handoffs, earlier attempts and its thread. Read it first.',
14
14
  '- `kanban_heartbeat` says you are still working on a long step; `kanban_comment` leaves a note on any task.',
15
- '- `kanban_create` files follow-up work (with `parents` to make it wait on another task); `kanban_link` adds a dependency.',
16
- '- When the work is done, `kanban_complete` with a `summary` a person can read (and `metadata`, `artifacts`, `created_cards` where they apply).',
17
- '- If the work needs review, `kanban_request_review` instead. If you cannot go on, `kanban_block` with a `reason` and the one',
18
- ' thing that ends the wait: `on` (the task it waits on) or `waiting_on` (the id of the message whose answer it waits for).',
19
- 'End every run with exactly one of complete, request_review or block: a run that ends without one is counted as a failure.',
20
- 'You may change only your own task\'s lifecycle; other tasks hear from you through comments.',
15
+ '- `kanban_create` files follow-up work; dependency creation follows the declared permissions.',
16
+ '- Follow the workflow doors below: each home declares who may block, pause or finish a card.',
17
+ '- When the home requires a manager decision, `kanban_request_state` names block, pause, done or cancelled, a reason,',
18
+ ' its dependency (`on` or `waiting_on`) or its evidence. It mails the manager and leaves your card and run active.',
19
+ '- `kanban_request_review` hands completed coding to the independent reviewer when the workflow permits it.',
20
+ 'A role does not grant terminal authority by itself. The recorded creator and the home\'s rules decide it.',
21
21
  ].join('\n');
22
22
  }
23
23
 
@@ -58,6 +58,13 @@ export function statusOf(db, task) {
58
58
  return one(db, 'SELECT stage FROM supercode_cards WHERE task_id = ?', task.id)?.stage ?? task.status;
59
59
  }
60
60
 
61
+ /** Creation history, not an editable display name or notification subscriber. */
62
+ export function creatorOf(db, id) {
63
+ const payload = json(one(db, "SELECT payload FROM task_events WHERE task_id=? AND kind='created' ORDER BY id LIMIT 1", id)?.payload, {}) ?? {};
64
+ const address = payload.creator ?? payload.by;
65
+ return payload.source !== 'document' && /^(?:sc:[^:]+:[^:]+:|hermes-run:)/.test(address ?? '') ? address : null;
66
+ }
67
+
61
68
  /** What kanban.db stores for `status`. */
62
69
  export function storedAs(workflow, status) {
63
70
  return workflow.statuses[status]?.stored_as ?? status;
@@ -214,6 +221,7 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {} } = {}
214
221
  const blocks = json(state.blocked_by, []) ?? [];
215
222
  const card = {
216
223
  ...task,
224
+ creator_address: creatorOf(db, task.id),
217
225
  status,
218
226
  blocked_by: blocks,
219
227
  paused: json(state.paused, null),
@@ -275,6 +283,7 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {} } = {}
275
283
  for (const [key, expr] of Object.entries(loaded.get(board.root)?.workflow?.derived ?? {})) card[key] = evaluate(expr, { card, event, params, now: now() });
276
284
  return {
277
285
  card,
286
+ requester: actorOf(),
278
287
  event,
279
288
  params,
280
289
  now: now(),
@@ -751,6 +760,7 @@ const ACTIONS = {
751
760
  const id = `m-${randomBytes(12).toString('hex')}`;
752
761
  ctx.scope.asked = id;
753
762
  record(ctx.db, ctx.id, 'board_message', { to: String(to), text: `${String(text ?? '')}\n\nAnswer with reply_to ${id}.`, message_id: id }, null);
763
+ ctx.effects.push({ kind: 'board_message', id: ctx.id });
754
764
  },
755
765
  send_to_actor(ctx, text) { ctx.effects.push({ kind: 'send_to_actor', id: ctx.id, text }); },
756
766
  };
@@ -808,7 +818,7 @@ function runSession(ctx, change) {
808
818
  * the board: `--root …` or `--at …`) and its transition's description: what a started session is told it can do.
809
819
  */
810
820
  // the board tool a one-shot worker sends an event with (Hermes's kanban_* tools)
811
- const TOOL_OF = { complete: 'kanban_complete', block: 'kanban_block', request_review: 'kanban_request_review', request_changes: 'kanban_request_changes', heartbeat: 'kanban_heartbeat', comment: 'kanban_comment' };
821
+ const TOOL_OF = { complete: 'kanban_complete', block: 'kanban_block', request_state: 'kanban_request_state', request_review: 'kanban_request_review', request_changes: 'kanban_request_changes', heartbeat: 'kanban_heartbeat', comment: 'kanban_comment' };
812
822
 
813
823
  export function doors(workflow, status, role, id, where, surface = 'pane') {
814
824
  const events = new Map();
package/board/ztrack.mjs CHANGED
@@ -360,6 +360,7 @@ class ZtrackBoard {
360
360
  this.mem.prepare('UPDATE supercode_cards SET blocked_by = ? WHERE task_id = ?').run(JSON.stringify(blocks), row.task_id);
361
361
  for (const b of blocks.filter((b) => b.kind === 'card')) this.insert('task_links', { parent_id: b.id, child_id: row.task_id });
362
362
  }
363
+ this.checkDocumentControls();
363
364
  if (observe) this.documentEvents();
364
365
  }
365
366
  taskCards(id, card) {
@@ -379,6 +380,41 @@ class ZtrackBoard {
379
380
  const rows = this.cardRows(id, card, parent);
380
381
  this.insert('tasks', rows.task); this.insert('supercode_cards', rows.state);
381
382
  }
383
+ checkDocumentControls() {
384
+ // A raw edit cannot authenticate its author. Trusted engine writes already
385
+ // have matching publication hashes; check only external edits, after local
386
+ // subtask references have been resolved to canonical card identities.
387
+ if (this.seed || (this.saved.documentHashes?.open === hash(this.text) && this.saved.documentHashes?.archived === hash(this.archiveText))) return;
388
+ let fields = [];
389
+ try { fields = workflowFor(this.root).params.document_control_fields ?? []; } catch { /* no home policy */ }
390
+ if (!fields.length) return;
391
+ const tasks = new Map(this.mem.prepare('SELECT * FROM tasks').all().map(t => [t.id, t]));
392
+ const states = new Map(this.mem.prepare('SELECT * FROM supercode_cards').all().map(c => [c.task_id, c]));
393
+ const value = (task, state, field) => field === 'status' ? state?.stage ?? task?.status ?? 'todo'
394
+ : field === 'blocked_by' ? json(state?.blocked_by, []).map(b => [b.kind, b.id]).sort()
395
+ : field === 'paused' ? json(state?.paused, null)
396
+ : field === 'archived' ? Boolean(state?.archived) : task?.[field] ?? null;
397
+ for (const id of new Set([...tasks.keys(), ...Object.keys(this.rows.cachedTasks)])) {
398
+ if (this.errors.has(id)) continue;
399
+ const oldTask = this.rows.cachedTasks[id], oldState = this.rows.cachedCards[id];
400
+ const task = tasks.get(id), state = states.get(id);
401
+ const forbidden = !task ? ['card removal'] : fields.filter(field =>
402
+ // A new display author is not an authenticated creator.
403
+ (oldTask || field !== 'created_by') && JSON.stringify(value(task, state, field)) !== JSON.stringify(value(oldTask, oldState, field)));
404
+ if (!forbidden.length) continue;
405
+ this.errors.set(id, `Document edit cannot change ${forbidden.join(', ')}; use this card's role-checked workflow verbs or request-state.`);
406
+ const source = this.documentRecords.get(state?.subtask_of ?? oldState?.subtask_of ?? id);
407
+ if (source) this.preserve.get(source.path)?.add(source.record.id);
408
+ this.mem.prepare('DELETE FROM tasks WHERE id=?').run(id);
409
+ this.mem.prepare('DELETE FROM supercode_cards WHERE task_id=?').run(id);
410
+ this.mem.prepare('DELETE FROM task_links WHERE child_id=?').run(id);
411
+ if (oldTask) this.insert('tasks', oldTask);
412
+ if (oldState) {
413
+ this.insert('supercode_cards', oldState);
414
+ for (const b of json(oldState.blocked_by, []).filter(b => b.kind === 'card')) this.insert('task_links', { parent_id: b.id, child_id: id });
415
+ }
416
+ }
417
+ }
382
418
  cardRows(id, card, parent = null) {
383
419
  const meta = card.metadata ?? {};
384
420
  const stored = this.preset.serializeKanbanCard({ ...card, acceptanceCriteria: [], relations: [], metadata: undefined, paused: undefined, every: undefined, workspace: undefined, branch: undefined, machine: undefined, priority: undefined });
package/board.mjs CHANGED
@@ -2,14 +2,15 @@
2
2
  // docs/plans/hermes-compat.md row 14). The board and its dispatcher are supercode's own (`board/`): Hermes Kanban's
3
3
  // format and lifecycle, each profile's lane decided by its `worker` in the IR. Where a Hermes gateway would run
4
4
  // its dispatcher, the daemon runs this one the same way: when the board's workflow says so (its `dispatch.serve`),
5
- // taking no file locks, one tick every `dispatch.tick` seconds
6
- // of the workflow (at least 1), the first after five seconds. Each tick runs as
5
+ // taking no file locks, waking on events with `dispatch.tick` as a safety sweep.
6
+ // Each tick runs as
7
7
  // `workflow dispatch` in a child process: a tick waits on supercode (the fleet, launches, mail), and the daemon's
8
8
  // own loop never does.
9
9
  import { spawn } from 'node:child_process';
10
10
  import { fileURLToPath } from 'node:url';
11
11
  import { summary } from './board/cli.mjs';
12
- import { notifyChats } from './board/dispatch.mjs';
12
+ import { commandEnv, notifyChats, supercodeBin } from './board/dispatch.mjs';
13
+ import { watchBoard } from './board/wake.mjs';
13
14
  import { workflowEntry } from './board/workflow.mjs';
14
15
  import { evaluate, holds } from './board/expr.mjs';
15
16
 
@@ -52,8 +53,8 @@ export async function startBoard({ root, orchestration = null, log = () => {}, a
52
53
  try { await notifyChats(root, adapters, log, wake); } catch (error) { log('warn', `board: chat notices failed: ${error.message}`); }
53
54
  for (const r of reports) {
54
55
  const line = summary(r);
55
- stuck[r.board] = r.ready_waiting && !r.started?.length && !r.paused ? (stuck[r.board] ?? 0) + 1 : 0;
56
- if (stuck[r.board] >= 6 && Date.now() - (warned[r.board] ?? 0) >= 300_000) { log('warn', `board ${r.board} stuck: ${r.ready_waiting} ready card(s) and nothing started for ${stuck[r.board]} ticks`); warned[r.board] = Date.now(); }
56
+ stuck[r.board] = r.ready_waiting && !r.started?.length && !r.paused ? (stuck[r.board] || Date.now()) : 0;
57
+ if (stuck[r.board] && Date.now() - stuck[r.board] >= seconds * 6000 && Date.now() - (warned[r.board] ?? 0) >= 300_000) { log('warn', `board ${r.board} stuck: ${r.ready_waiting} ready card(s) and nothing started for ${Math.floor((Date.now() - stuck[r.board]) / 1000)}s`); warned[r.board] = Date.now(); }
57
58
  if (r.paused && !said.has(`paused:${r.board}`)) { said.add(`paused:${r.board}`); log('info', `board ${r.board}: new starts paused by ${r.paused}`); }
58
59
  if (!r.paused) said.delete(`paused:${r.board}`);
59
60
  if (!line.endsWith('nothing to do')) log(r.error ? 'warn' : 'info', line);
@@ -67,12 +68,22 @@ export async function startBoard({ root, orchestration = null, log = () => {}, a
67
68
  running = false;
68
69
  }
69
70
  };
70
- const first = setTimeout(() => { void tick(); }, 5_000);
71
- const timer = setInterval(() => { void tick(); }, seconds * 1000);
72
- first.unref?.(); timer.unref?.();
73
- log('info', `board: dispatching every ${seconds}s, each card as its profile's worker says`);
71
+ const events = watchBoard({ root, binary: supercodeBin(), env: commandEnv(), log: message => log('warn', message) });
72
+ void (async () => {
73
+ let wakeReason = null;
74
+ while (!stopped) {
75
+ const began = Date.now();
76
+ if (wakeReason) log('info', `board: dispatch wake ${wakeReason.sources.join(', ')}; event age ${began - wakeReason.at}ms`);
77
+ await tick();
78
+ events.refresh();
79
+ wakeReason = await events.wait(Math.max(0, seconds * 1000 - (Date.now() - began)));
80
+ const remaining = 1000 - (Date.now() - began);
81
+ if (remaining > 0 && !stopped) await new Promise(done => setTimeout(done, remaining));
82
+ }
83
+ })().catch(error => log('warn', `board: dispatch loop failed: ${error.message}`));
84
+ log('info', `board: dispatching on events, safety sweep every ${seconds}s, each card as its profile's worker says`);
74
85
  return {
75
- tick,
76
- stop() { stopped = true; clearTimeout(first); clearInterval(timer); },
86
+ tick() { events.wake('requested'); },
87
+ stop() { stopped = true; events.close(); },
77
88
  };
78
89
  }
@@ -62,7 +62,7 @@
62
62
  },
63
63
  {
64
64
  "name": "kanban_complete",
65
- "description": "Mark your current task done with a structured handoff for downstream workers and humans. 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`` — 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.",
66
66
  "inputSchema": {
67
67
  "type": "object",
68
68
  "properties": {
@@ -106,7 +106,7 @@
106
106
  },
107
107
  {
108
108
  "name": "kanban_block",
109
- "description": "Stop work on this task until one thing ends the wait, and name it: `on`, the task it waits on (it goes to todo and resumes when that task closes), or `waiting_on`, the id of the message you sent whose answer it waits for (it resumes when an answer to that message lands in your mailbox). An outside event is a task on the card of whoever makes it happen or watches for it. `reason` is shown on the board. The same block twice goes to triage.",
109
+ "description": "Add a typed dependency only if the home permits your role to block. Otherwise use kanban_request_state with state block, the reason and on or waiting_on; that request leaves the card active. Stop work on this task until one thing ends the wait, and name it: `on`, the task it waits on (it goes to todo and resumes when that task closes), or `waiting_on`, the id of the message you sent whose answer it waits for (it resumes when an answer to that message lands in your mailbox). An outside event is a task on the card of whoever makes it happen or watches for it. `reason` is shown on the board. The same block twice goes to triage.",
110
110
  "inputSchema": {
111
111
  "type": "object",
112
112
  "properties": {
@@ -171,7 +171,7 @@
171
171
  },
172
172
  {
173
173
  "name": "kanban_request_changes",
174
- "description": "Reviewer verdict: return the current review run to the original implementer with concrete required changes. This closes the review run, reapplies parent dependency gating, and requeues the task without using block-loop accounting. Only use from a task claimed from the review column; use kanban_block only for a genuine external blocker.",
174
+ "description": "Reviewer verdict: return the current review run to the original implementer with concrete required changes. This closes the review run, reapplies parent dependency gating, and requeues the task without using block-loop accounting. Only use from a task claimed from the review column; use kanban_request_state when a manager must decide an external blocker.",
175
175
  "inputSchema": {
176
176
  "type": "object",
177
177
  "properties": {
@@ -471,5 +471,42 @@
471
471
  "child_id"
472
472
  ]
473
473
  }
474
+ },
475
+ {
476
+ "name": "kanban_request_state",
477
+ "description": "Request a manager decision on block, pause, done or cancelled through the home workflow. Includes its rules in manager mail and leaves card/run state unchanged.",
478
+ "inputSchema": {
479
+ "type": "object",
480
+ "properties": {
481
+ "task_id": {
482
+ "type": "string"
483
+ },
484
+ "state": {
485
+ "type": "string",
486
+ "enum": [
487
+ "block",
488
+ "pause",
489
+ "done",
490
+ "cancelled"
491
+ ]
492
+ },
493
+ "reason": {
494
+ "type": "string"
495
+ },
496
+ "on": {
497
+ "type": "string"
498
+ },
499
+ "waiting_on": {
500
+ "type": "string"
501
+ },
502
+ "evidence": {
503
+ "type": "string"
504
+ }
505
+ },
506
+ "required": [
507
+ "state",
508
+ "reason"
509
+ ]
510
+ }
474
511
  }
475
512
  ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-orchestrator",
3
- "version": "0.4.124",
3
+ "version": "0.4.126",
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": {