@volter/supercode-orchestrator 0.4.125 → 0.4.127

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
@@ -15,7 +15,7 @@ import { openZtrackBoard } from './ztrack.mjs';
15
15
  import * as engine from './engine.mjs';
16
16
  import { adoptSession, boardAddress, boardLabel, boardSettings, callerSession, commandEnv, reconcileCallerLaunch, closeRequested, lanesOf, localMachine, observeFleet, paused, plan, startCard, supercodeBin, tick } from './dispatch.mjs';
17
17
  import { watchBoard } from './wake.mjs';
18
- import { loadWorkflow, requestSessionClose, scopeOf, statusOf, useWorkflow, workflowEntry, workflowFor } from './workflow.mjs';
18
+ import { creatorOf, loadWorkflow, requestSessionClose, scopeOf, statusOf, useWorkflow, workflowEntry, workflowFor } from './workflow.mjs';
19
19
  import { evaluate } from './expr.mjs';
20
20
 
21
21
  const HELP = `supercode workflow <verb> [--root <home>] [--board <slug>] [--json]
@@ -35,6 +35,7 @@ Hermes Kanban's verbs, with their flags:
35
35
  complete <id>... [--result] [--summary] [--metadata JSON] edit <id> --result [--summary] [--metadata]
36
36
  block <id> [reason] (--on <card> | --waiting-on <message id>) [--ids ...] schedule <id> [reason] [--ids ...]
37
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>]
38
39
  unblock <id>... [--reason] request-review <id> [--summary] [--reviewer] [--force] request-changes <id> <reason> reopen-review <id>... reopen <id>... [--reason]
39
40
  promote <id> [reason] [--ids ...] [--force] [--dry-run] archive <id>... [--rm <id>...]
40
41
  specify <id> [--title --body] [--all] decompose <id> [--child "<title>[@<profile>]"]... [--all]
@@ -53,11 +54,13 @@ supercode's own:
53
54
  notify-subscribe <id> --address sc:<machine>:<harness>:<id> [--role creator|worker|dependency]
54
55
  Any verb … --at sc:<machine>:board:<home>: sent as mail to a board kept on another machine.
55
56
  <event> <id>... [reason] [--<field> value]: any other event the board's workflow declares, its flags the event's fields.
57
+ start <id> release a --no-start hold and start through the declared workflow
58
+ create ... --adopt <session> hold dispatch until that live session is attached
56
59
  goto <id> <status> [reason] [--<field> value]: the event the workflow says takes the card to that status, sent.
57
60
 
58
61
  specify/decompose with no spec start a session on the triage card to write it. A card's own session may comment on,
59
- block (with --kind), heartbeat and request review of its card, specify or decompose it in triage, and file, complete,
60
- block and comment on its subtasks; its arc is marked done by its reviewers, never by itself.
62
+ heartbeat and request review of its card, specify or decompose it in triage, and work its subtasks. The home's
63
+ workflow declares who may block, pause or finish a card; request-state mails its manager without changing state.
61
64
  `;
62
65
 
63
66
  const MULTI = new Set(['parent', 'skill', 'worker', 'child', 'ids', 'rm']);
@@ -239,6 +242,7 @@ export async function runWorkflow(argv) {
239
242
 
240
243
  // a caller that is a card's own session (its harness names it; the board recorded which session it launched)
241
244
  const listing = ['list','ls'].includes(verb);
245
+ await loadWorkflow(root).catch((error) => { if (verb !== 'init' && verb !== 'boards') throw error; });
242
246
  const sid = listing && !args.mine ? null : callerSession();
243
247
  // what this command records is the calling session's act (its address, as its subscriptions name it)
244
248
  if (sid) setActor(`sc:${localMachine()}:${sid === process.env.CODEX_THREAD_ID ? 'codex' : 'claude-code'}:${sid}`);
@@ -247,7 +251,6 @@ export async function runWorkflow(argv) {
247
251
  }
248
252
  // the board's workflow (the IR's): what each verb does, and who may send it, is its
249
253
  // every verb reads the board through its workflow (a list settles cards too), so every verb loads it
250
- if (verb !== 'help' && !listing) await loadWorkflow(root).catch((error) => { if (verb !== 'init' && verb !== 'boards') throw error; });
251
254
  engine.setCaller((db, cardId) => rolesFor(db, cardId, sid, workflowFor(root).params));
252
255
  // 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
253
256
  if (!sid && verb !== 'help') { try { setActor([].concat(workflowFor(root).params?.managers ?? [])[0] ?? null); } catch { /* no workflow */ } }
@@ -293,11 +296,15 @@ export async function runWorkflow(argv) {
293
296
  workspace, branch: args.branch, parents: args.parent, priority: args.priority, tenant: args.tenant,
294
297
  triage: args.triage, idempotencyKey: args['idempotency-key'], maxRuntime: duration(args['max-runtime']),
295
298
  maxRetries, skills: args.skill, model: args.model, provider: args.provider, createdBy: args['created-by'] ?? callerName(),
299
+ noStart: Boolean(args['no-start']), adopt: args.adopt ?? null,
296
300
  every: args.every ?? null, sessionId: callerSession(), subtaskOf: args['subtask-of'] ?? null, initialStatus: args['initial-status'] ?? null,
297
301
  project: args.project ?? meta.project_id ?? null, goal: args.goal ? { maxTurns: args['goal-max-turns'] ? Number(args['goal-max-turns']) : null } : args['no-goal'] ? false : null,
298
302
  }), true);
299
303
  let answer = { id: created.id, existing: created.existing };
300
- if (!args['no-start'] && !created.existing && !args['subtask-of']) {
304
+ if (args.adopt) {
305
+ const adopted = withBoard(b => adoptSession(b, created.id, args.adopt, { ttl: settings.ttl }));
306
+ answer = { ...answer, started: true, session: adopted.address, mode: adopted.mode };
307
+ } else if (!args['no-start'] && !created.existing && !args['subtask-of']) {
301
308
  const [report] = await tick({ root, lanes, settings, only: created.id, slugs: [slug] });
302
309
  const started = report.started.find(([t]) => t === created.id);
303
310
  const queued = report.queued.find(([t]) => t === created.id);
@@ -812,6 +819,12 @@ export async function runWorkflow(argv) {
812
819
  say(args, lifted.length ? `Resumed (removed ${lifted.join(', ')})` : 'Not paused', { ok: true, lifted });
813
820
  return 0;
814
821
  }
822
+ case 'start': {
823
+ need(1, 'a card id');
824
+ const { lanes, settings } = await laneState(root);
825
+ const session = await withBoardAsync(b => startCard(b, id, { lanes, settings, roles: rolesFor(b.db, id, sid, workflowFor(root).params) }));
826
+ say(args, `${id}: ${session?.address ?? 'started'}`, { ok: true, session }); return 0;
827
+ }
815
828
  case 'adopt': {
816
829
  need(2, 'a card id and a session');
817
830
  const { settings } = await laneState(root);
@@ -841,10 +854,10 @@ export async function runWorkflow(argv) {
841
854
  if (!declared) throw new Error(`no workflow verb ${verb}; \`supercode workflow help\` lists them`);
842
855
  need(1, 'a card id');
843
856
  const { _: _words, root: _r, board: _b, json: _j, at: _a, ids: _i, ...fields } = args;
844
- const payload = { ...fields, reason: args.reason ?? (rest.slice(1).join(' ') || null) };
857
+ const payload = { ...fields, waiting_on: args['waiting-on'] ?? fields.waiting_on, reason: args.reason ?? (rest.slice(1).join(' ') || null) };
845
858
  const results = [];
846
859
  for (const t of [id, ...(args.ids ?? [])]) results.push([t, (await withBoardAsync((b) => engine.act(b, t, name, { payload }))).to]);
847
- say(args, results.map(([t, s]) => `${t} → ${s}`).join('\n'), { ok: true, results }); return 0;
860
+ 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;
848
861
  }
849
862
  }
850
863
  }
@@ -1061,16 +1074,10 @@ function ownCard(db, sessionId) {
1061
1074
 
1062
1075
  const READS = new Set(['list', 'ls', 'show', 'context', 'runs', 'log', 'tail', 'watch', 'stats', 'assignees', 'diagnostics', 'diag', 'attachments', 'notify-list', 'help', 'init']);
1063
1076
 
1064
- /**
1065
- * What a card's own session may write (the owner's rule): on its card, comment, block with a typed reason, heartbeat,
1066
- * request review, attach a file, specify or decompose it in triage; on its subtasks, file, complete, block, unblock,
1067
- * comment and attach. Never its arc's completion, and never another arc's card: anything across arcs goes through the
1068
- * fleet's manager.
1069
- */
1070
1077
  /**
1071
1078
  * Who a caller is on card `cardId`, in the workflow's roles: a person or the fleet's manager at the CLI (no session)
1072
1079
  * is `manager`; the session of the card's current run has that run's role; the card's working session between runs
1073
- * is its `implementer`; the session working a subtask's arc is `arc`; any other session is nobody on it.
1080
+ * 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.
1074
1081
  */
1075
1082
  function rolesFor(db, cardId, sessionId, params = {}) {
1076
1083
  if (!sessionId) return ['manager', 'owner'];
@@ -1080,6 +1087,7 @@ function rolesFor(db, cardId, sessionId, params = {}) {
1080
1087
  const task = engine.getTask(db, cardId);
1081
1088
  if (!task) return [];
1082
1089
  const roles = [];
1090
+ if (creatorOf(db, cardId)?.endsWith(`:${sessionId}`)) roles.push('creator');
1083
1091
  const current = task.current_run_id ? json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(task.current_run_id)?.metadata, {})?.supercode : null;
1084
1092
  if (current?.session_id === sessionId) roles.push(current.role ?? 'implementer');
1085
1093
  else if (engine.lastSession(db, cardId)?.session_id === sessionId) roles.push('implementer');
@@ -1100,6 +1108,7 @@ function authorize(db, verb, rest, args, own) {
1100
1108
  if (args.all) refuse(`does not ${verb} every card; only its own`);
1101
1109
  const card = engine.getTask(db, rest[0]);
1102
1110
  if (!card) return; // the verb itself answers "no card"
1111
+ if (creatorOf(db, card.id)?.endsWith(`:${callerSession()}`)) return; // its declared creator doors still govern the act
1103
1112
  // a card's session writes its own card and its subtasks; what it may do with them is the workflow's (`by`)
1104
1113
  if (card.id !== own.id && card.subtask_of !== own.id) refuse(`writes only its own card and its subtasks, not ${card.id}`);
1105
1114
  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}`);
@@ -136,7 +136,20 @@ const HARNESSES = {
136
136
  const addressOf = (s) => s?.address ?? (s?.session_id && s.machine ? `sc:${s.machine}:${s.harness ?? 'claude-code'}:${s.session_id}` : null);
137
137
  const pause = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
138
138
 
139
- const sessionAlive = (fleet, session) => Boolean(session?.session_id) && [...fleet.alive].some((a) => a.endsWith(`:${session.session_id}`));
139
+ const sessionAlive = (fleet, session) => fleet.native?.has(addressOf(session)) ? fleet.native.get(addressOf(session)).alive : Boolean(session?.session_id) && fleet.alive.has(addressOf(session));
140
+
141
+ /** Native activity comes from the machine's harness discovery, not catalogue age. */
142
+ function nativeSessions(machine, harness) {
143
+ const result = sc(['discover', '--on', machine, '--harness', harness, '--limit', '2000'], { timeout: 120_000 });
144
+ const page = result.code === 0 ? lastJson(result.stdout) : null;
145
+ if (!Array.isArray(page?.sessions)) throw unavailable(`native liveness unavailable on ${machine}: ${(result.stderr || result.stdout).trim().slice(-300)}`);
146
+ return page.sessions;
147
+ }
148
+ function nativeReading(rows, session) {
149
+ const row = rows.find(r => r.locator?.session_id === session.session_id && r.locator?.harness === session.harness);
150
+ const presence = row?.activity?.presence;
151
+ return { row, alive: presence === 'running' ? true : presence === 'persisted' ? false : null };
152
+ }
140
153
 
141
154
  /** Match a manual continuation by the same harness identity and an actually live machine pane. */
142
155
  function continuationPane(fleet, session, panes) {
@@ -211,6 +224,17 @@ function supervise(board, fleet, settings, report, entry, effects) {
211
224
  // The catalog can lag a pane. Read the actual machines once, before any board mutation.
212
225
  const observed = new Map();
213
226
  const exits = new Map();
227
+ const native = new Map(); fleet.native = new Map();
228
+ for (const { id } of rows) {
229
+ const session = currentRun(board.db, id)?.metadata?.supercode;
230
+ if (!session?.session_id || session.pane || session.surface === 'headless') continue;
231
+ const key = `${session.machine}:${session.harness}`;
232
+ if (!native.has(key)) {
233
+ try { native.set(key, nativeSessions(session.machine, session.harness)); }
234
+ catch (error) { native.set(key, null); report.failed.push([id, error.message]); }
235
+ }
236
+ fleet.native.set(addressOf(session), native.get(key) ? nativeReading(native.get(key), session) : { alive: null });
237
+ }
214
238
  for (const { id } of rows) {
215
239
  const s = currentRun(board.db, id)?.metadata?.supercode;
216
240
  if (!s?.pane || observed.has(s.machine)) continue;
@@ -288,7 +312,7 @@ function supervise(board, fleet, settings, report, entry, effects) {
288
312
  // Hermes's orphan reconciliation: a running card whose claim bookkeeping is broken, with nothing live behind it
289
313
  const paneReading = session?.pane ? observed.get(session.machine) : undefined;
290
314
  const paneLive = paneReading instanceof Set ? paneReading.has(session.pane) : null;
291
- const live = session?.surface === 'headless' ? pidAlive(session.pid) : session?.pane ? paneLive !== false : session?.session_id ? (fleet.ok ? sessionAlive(fleet, session) : true) : false;
315
+ const live = session?.surface === 'headless' ? pidAlive(session.pid) : session?.pane ? paneLive !== false : session?.session_id ? (fleet.ok ? sessionAlive(fleet, session) !== false : true) : false;
292
316
  // (whether it is reconciled is the workflow's: its `orphaned` transitions and their guard)
293
317
  if ((!task.claim_lock || !task.claim_expires) && !live) {
294
318
  observe('orphaned', { reason: 'orphaned_running', claim_lock: task.claim_lock, claim_expires: task.claim_expires, error: 'orphaned running card (broken claim bookkeeping)' }, 'reclaimed');
@@ -325,6 +349,7 @@ function supervise(board, fleet, settings, report, entry, effects) {
325
349
  if (session.pane && paneLive === null) return; // no machine reading, no loss verdict
326
350
  if (!session.pane && !fleet.ok) return;
327
351
  const alive = session.pane ? paneLive : sessionAlive(fleet, session);
352
+ if (alive == null) return; // native discovery was inconclusive
328
353
  const machineUp = paneReading instanceof Set || fleet.online.has(session.machine);
329
354
  const young = t - Number(session.launched_at ?? 0) < Number(limits.grace ?? settings.grace);
330
355
  const missing = !alive && machineUp && !young;
@@ -374,6 +399,13 @@ function stopSession(board, session) {
374
399
  if (pidAlive(session.pid)) signal('SIGKILL');
375
400
  return;
376
401
  }
402
+ if (!session.pane && session.surface === 'native') {
403
+ const r = sc(['close', addressOf(session)], { timeout: 120_000, as: boardAddress(board.label) });
404
+ if (r.code !== 0) throw new Error(`closing native session ${addressOf(session)}: ${(r.stderr || r.stdout).trim().slice(-500)}`);
405
+ const reading = nativeReading(nativeSessions(session.machine, session.harness), session);
406
+ if (reading.alive !== false) throw new Error(`native close has no confirmed stopped receipt for ${addressOf(session)}`);
407
+ return;
408
+ }
377
409
  if (session.pane) {
378
410
  const r = sc(['teams', 'panes', 'close', session.pane, ...(session.machine ? ['--machine', session.machine] : [])], { timeout: 60_000, as: boardAddress(board.label) });
379
411
  if (r.code !== 0 || json(r.stdout.trim(), null)?.closed !== session.pane) throw new Error(`closing pane ${session.pane}: ${(r.stderr || r.stdout || `exit ${r.code}`).trim().slice(-500)}`);
@@ -421,6 +453,7 @@ function startCards(board, fleet, lanes, settings, report, entry, effects, { onl
421
453
  for (const task of everyCard) {
422
454
  if (only && task.id !== only) continue;
423
455
  const state = scopeFor(task).card;
456
+ if (state.dispatch_hold) continue;
424
457
  if (workflow.run && (task.subtask_of || state.paused || state.archived || state.blocked_by.length || task.current_run_id || (state.every && state.next_run_at > now()))) continue;
425
458
  if (!startable.has(statusOf(board.db, task))) continue;
426
459
  const plan = planOf(task, scopeFor(task));
@@ -565,6 +598,8 @@ export async function startCard(board, id, { roles = ['manager'], lanes, setting
565
598
  const moved = tx(board, (db) => {
566
599
  assertWorkspaceOwner(db, before, checked);
567
600
  if (checked) db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.mainRoot, id);
601
+ if (!roles.includes('manager')) throw new Error('Only the manager starts a held card; request its decision.');
602
+ event(db, id, 'dispatch_released', { reason: 'explicit start' });
568
603
  return fire(db, board, entry, id, entry.workflow.dispatch.start, { roles, payload: { lock: claimer() } });
569
604
  });
570
605
  const task = getTaskNow(board, id);
@@ -611,7 +646,7 @@ export function closeRequested(board, report, { taskId = null, caller = null } =
611
646
  report.undelivered.push([row.task_id, addressOf(session), error]);
612
647
  }
613
648
  }
614
- if (!session?.pane || session.close_transferred_to || session.close_confirmed_at || (!row.ended_at && !session.close_requested)) continue;
649
+ if ((!session?.pane && session?.surface !== 'native') || session.close_transferred_to || session.close_confirmed_at || (!row.ended_at && !session.close_requested)) continue;
615
650
  if (caller && session.session_id === caller) continue; // let the command that ended its own run return
616
651
  const active = view(board, (db) => db.prepare(`SELECT r.id, r.metadata FROM task_runs r WHERE r.ended_at IS NULL OR EXISTS (SELECT 1 FROM tasks t WHERE t.current_run_id = r.id)`).all());
617
652
  // Persist retries even for historical ends that did not have a close_session action.
@@ -619,6 +654,16 @@ export function closeRequested(board, report, { taskId = null, caller = null } =
619
654
  let error = null;
620
655
  try {
621
656
  const machine = session.machine ?? localMachine();
657
+ if (session.surface === 'native' && !session.pane) {
658
+ const reading = nativeReading(nativeSessions(machine, session.harness), session);
659
+ if (reading.alive == null) throw new Error(`close deferred: native liveness unknown for ${addressOf(session)}`);
660
+ if (reading.alive) {
661
+ const protectedNow = active.some(r => addressOf(json(r.metadata, {})?.supercode) === addressOf(session));
662
+ if (protectedNow) throw new Error('close deferred: native session is owned by an unended run');
663
+ if (session.lifecycle_notice_pending) throw new Error(`close deferred: lifecycle notice ${session.lifecycle_notice_pending} has not been delivered`);
664
+ stopSession(board, session);
665
+ }
666
+ } else {
622
667
  if (!panes.has(machine)) {
623
668
  const r = sc(['teams', 'panes', 'ls', ...onMachine(machine)], { timeout: 60_000, as: boardAddress(board.label) });
624
669
  const list = r.code === 0 ? json(r.stdout.trim(), null) : null;
@@ -634,10 +679,11 @@ export function closeRequested(board, report, { taskId = null, caller = null } =
634
679
  if (protectedNow) throw new Error(`close deferred: pane ${session.pane} is owned by an unended run`);
635
680
  stopSession(board, session); observed.live.delete(session.pane);
636
681
  }
682
+ }
637
683
  } catch (e) { error = String(e.message).slice(-500); }
638
684
  tx(board, (db) => {
639
685
  const current = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(row.id)?.metadata, {})?.supercode;
640
- if (!current || current.pane !== session.pane) return;
686
+ if (!current || current.pane !== session.pane || current.session_id !== session.session_id) return;
641
687
  const at = now();
642
688
  if (error) {
643
689
  setRunSession(db, row.id, { ...current, close_attempted_at: at, close_attempts: Number(current.close_attempts ?? 0) + 1, close_error: error });
@@ -668,8 +714,9 @@ function observeIdle(board, fleet, settings, report, entry, effects) {
668
714
  if (!(entry.workflow.statuses[status]?.on?.session_idle || entry.workflow.on?.session_idle || entry.workflow.run?.on?.session_idle)) return;
669
715
  const runRow = currentRun(db, id);
670
716
  const session = runRow?.metadata?.supercode;
671
- if (!session?.session_id || session.surface !== 'pane' || session.offline_since != null) return;
672
- const live = fleet.sessions.find((x) => x.address?.endsWith(`:${session.session_id}`));
717
+ if (!session?.session_id || !['pane', 'native'].includes(session.surface) || session.offline_since != null) return;
718
+ if (session.surface === 'native' && fleet.native?.get(addressOf(session))?.alive !== true) return;
719
+ const live = fleet.sessions.find((x) => x.address === addressOf(session));
673
720
  const observedAt = now();
674
721
  const lastAt = Math.floor(Number(live?.last_message_at_ms) / 1000);
675
722
  const reading = {
@@ -695,7 +742,7 @@ function observeIdle(board, fleet, settings, report, entry, effects) {
695
742
  try {
696
743
  const where = whereOf(board, task);
697
744
  // the ask names what the session may do, as its run's prompt did
698
- const said = doors(entry.workflow, status, session.role ?? runSpec(entry, status)?.role ?? 'implementer', id, where, session.surface === 'pane' ? 'pane' : 'headless');
745
+ const said = doors(entry.workflow, status, session.role ?? runSpec(entry, status)?.role ?? 'implementer', id, where, session.surface !== 'headless' ? 'pane' : 'headless');
699
746
  const r = fire(db, board, entry, id, 'session_idle', { roles: ['platform'], payload: { ...reading, turns: reading.turns_before, worked }, extra: { where, doors: said } });
700
747
  effects.push(...r.effects);
701
748
  const after = currentRun(db, id);
@@ -948,7 +995,7 @@ function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'wor
948
995
  closeRequested(board, { undelivered: [] }, { taskId: task.id });
949
996
  const pending = view(board, (db) => db.prepare('SELECT id, ended_at, metadata FROM task_runs WHERE task_id = ? AND id != ?').all(task.id, runId))
950
997
  .map((r) => ({ ...r, session: json(r.metadata, {})?.supercode }))
951
- .find((r) => (r.session?.pane || r.session?.launch_key) && !r.session.close_transferred_to && (r.session.slot ?? 'worker') === slot && (r.ended_at || r.session.close_requested) && !r.session.close_confirmed_at);
998
+ .find((r) => (r.session?.pane || r.session?.launch_key || r.session?.surface === 'native') && !r.session.close_transferred_to && (r.session.slot ?? 'worker') === slot && (r.ended_at || r.session.close_requested) && !r.session.close_confirmed_at);
952
999
  if (pending) throw unavailable(`slot ${slot} still owns pane ${pending.session.pane} from run ${pending.id}; closure must be confirmed before another session starts: ${pending.session.close_error ?? 'close pending'}`);
953
1000
  let prev = view(board, (db) => lastSession(db, task.id, slot));
954
1001
  if (prev?.session_id && !prev.forgotten && (prev.harness ?? 'claude-code') === harness && prev.machine === machine) {
@@ -997,6 +1044,12 @@ function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'wor
997
1044
  };
998
1045
  let session = null;
999
1046
  let replaced = null;
1047
+ if (prev?.surface === 'native' && !prev.pane) {
1048
+ const reading = nativeReading(nativeSessions(prev.machine, prev.harness), prev);
1049
+ if (reading.alive == null) throw unavailable(`cannot establish native continuation liveness for ${addressOf(prev)}`);
1050
+ fleet.native ??= new Map(); fleet.native.set(addressOf(prev), reading);
1051
+ if (reading.alive && (prev.closed_at || prev.close_requested || prev.forgotten)) throw unavailable(`native session ${addressOf(prev)} is still live; its close must finish before replacement`);
1052
+ }
1000
1053
  if (prev?.forgotten) {
1001
1054
  if (prev.pane && panePresent(prev.machine, prev.pane)) throw new Error(`forgotten session ${prev.session_id} still has live pane ${prev.pane}; close it before replacement`);
1002
1055
  replaced = { session_id: prev.session_id, why: 'the workflow let it go' };
@@ -1165,14 +1218,15 @@ export function adoptSession(board, id, who, { workspace = null, ttl, assignee =
1165
1218
  if (!found) throw new Error(`the fleet lists no live session ${who}`);
1166
1219
  const [, host, harness, sessionId] = found.address.split(':');
1167
1220
  if (!HARNESSES[harness]) throw new Error(`${found.address} is ${harness}; the board keeps and resumes ${Object.keys(HARNESSES).join(' and ')} sessions`);
1168
- if (!found.pane || !panePresent(host, found.pane)) throw new Error(`${found.address} has no verified live pane`);
1221
+ const native = found.pane ? null : nativeReading(nativeSessions(host, harness), { session_id: sessionId, harness });
1222
+ if (found.pane ? !panePresent(host, found.pane) : native.alive !== true) throw new Error(`${found.address} has no verified live native process or pane`);
1169
1223
  const beforeRun = view(board, (db) => currentRun(db, id));
1170
1224
  const beforeSession = beforeRun?.metadata?.supercode;
1171
1225
  if (beforeRun && beforeSession?.session_id !== sessionId) throw new Error(`${id} has another active run/session; end it before adopting a different identity`);
1172
1226
  if (beforeSession?.pane && beforeSession.pane !== found.pane && panePresent(beforeSession.machine, beforeSession.pane)) throw new Error(`${id} still has live pane ${beforeSession.pane}; close the extra pane before rebinding ${sessionId}`);
1173
1227
 
1174
1228
  // the session's own working directory, as discovery records it (where `--resume` finds it), else its pane's
1175
- let dir = workspace;
1229
+ let dir = workspace ?? native?.row?.cwd;
1176
1230
  if (!dir) {
1177
1231
  const r = sc(['discover', '--on', host, '--harness', harness, '--limit', '2000'], { timeout: 120_000 });
1178
1232
  dir = (json(r.stdout.trim(), {})?.sessions ?? []).find((x) => x.locator?.session_id === sessionId)?.cwd ?? null;
@@ -1203,10 +1257,11 @@ export function adoptSession(board, id, who, { workspace = null, ttl, assignee =
1203
1257
  const implementer = lastSession(db, id, 'worker');
1204
1258
  if (implementer?.session_id === sessionId) throw new Error('a reviewer must be independent of the implementer session');
1205
1259
  }
1260
+ event(db, id, 'dispatch_released', { reason: 'verified adoption', session: found.address });
1206
1261
  if (!beforeRun) fire(db, board, entry, id, entry.workflow.run ? entry.workflow.dispatch.start : 'adopt', { roles: ['manager'], payload: { lock: claimer() } });
1207
1262
  const runId = getTask(db, id).current_run_id;
1208
1263
  if (!runId) throw new Error(`${id} waits on parents that are not done`);
1209
- const session = { surface: 'pane', session_id: sessionId, harness, machine: host, pane: found.pane ?? null, address: found.address, mode: 'adopt', launched_at: now(), seen_at: now() };
1264
+ const session = { surface: found.pane ? 'pane' : 'native', session_id: sessionId, harness, machine: host, pane: found.pane ?? null, address: found.address, mode: 'adopt', launched_at: now(), seen_at: now() };
1210
1265
  setRunSession(db, runId, { ...(currentRun(db, id)?.metadata?.supercode ?? {}), ...session, slot: currentRun(db, id)?.metadata?.supercode?.slot ?? beforeSession?.slot ?? expected?.session ?? 'worker', close_requested: null, closed_at: null, close_confirmed_at: null, close_error: null, missing_since: null });
1211
1266
  event(db, id, 'session_adopted', { session_id: sessionId, machine: host, pane: session.pane, address: found.address, mode: 'adopt', role: currentRun(db, id)?.metadata?.supercode?.role }, runId);
1212
1267
  subscribeIn(db, id, found.address, 'worker');
@@ -1245,16 +1300,23 @@ async function send(from, label, to, body, key, { queue = false, ask = false } =
1245
1300
  * 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
1301
  * (the id is the send's idempotency key). One that fails is tried again next tick.
1247
1302
  */
1248
- async function sendBoardMessages(board, report) {
1303
+ export async function sendBoardMessages(board, report, taskId = null) {
1249
1304
  const rows = view(board, (db) => db.prepare(`SELECT id, task_id, payload FROM task_events e WHERE kind = 'board_message' AND NOT EXISTS
1250
1305
  (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
1306
  for (const row of rows) {
1307
+ if (taskId && row.task_id !== taskId) continue;
1252
1308
  const m = json(row.payload, {}) ?? {};
1253
1309
  try {
1254
1310
  await send(boardAddress(board.label), board.label, m.to, m.text, m.message_id ?? `board-message-${row.id}`, { ask: Boolean(m.message_id) });
1255
1311
  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
1312
  } catch (error) {
1257
- report.undelivered.push([row.task_id, m.to, String(error.message).slice(-200)]);
1313
+ const reason = String(error.message).slice(-200);
1314
+ report.undelivered.push([row.task_id, m.to, reason]);
1315
+ tx(board, db => {
1316
+ 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);
1317
+ if (json(previous?.payload, {})?.reason !== reason || now() - (previous?.created_at ?? 0) >= 60)
1318
+ event(db, row.task_id, 'board_message_failed', { event: row.id, to: m.to, message_id: m.message_id, reason });
1319
+ });
1258
1320
  }
1259
1321
  }
1260
1322
  }
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,8 @@ 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 } : {}) });
219
+ if ((spec.noStart || spec.adopt) && !arc) event(db, id, 'dispatch_held', { session: spec.adopt ?? null, reason: spec.adopt ? 'awaiting adoption' : 'created with --no-start' });
215
220
  if (parents.length) inheritSubscriptions(db, id, parents);
216
221
  if (arc) {
217
222
  // the arc's subscribers hear its subtasks, and the arc's session works them
@@ -382,6 +387,10 @@ export async function runEffects(board, effects = [], { retryNotices = false } =
382
387
  if (!await sessionNotice(board, e)) continue;
383
388
  }
384
389
  else if (e.kind === 'cleanup_workspace') cleanupWorkspace(board, e.id);
390
+ else if (e.kind === 'board_message') {
391
+ const { sendBoardMessages } = await import('./dispatch.mjs');
392
+ await sendBoardMessages(board, { undelivered: [] }, e.id);
393
+ }
385
394
  else if (e.kind === 'invalidate_descendants') tx(board, (db) => invalidateDescendants(db, board, e.id));
386
395
  else if (e.kind === 'stop_run') stopWorker(board, e.runId);
387
396
  else if (e.kind === 'close_session' || e.kind === 'send_to_actor' || e.kind === 'run_ended') {
@@ -685,7 +694,7 @@ export function runs(db, id, { stateType = null, stateName = null } = {}) {
685
694
 
686
695
  /** Delete the thread events of done and archived cards older than `seconds` (Hermes's `gc` of events). */
687
696
  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));
697
+ 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
698
  }
690
699
 
691
700
  // ------------------------------------------------------------------ diagnostics
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 });
@@ -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,8 @@ 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),
225
+ 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',
217
226
  status,
218
227
  blocked_by: blocks,
219
228
  paused: json(state.paused, null),
@@ -275,6 +284,7 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {} } = {}
275
284
  for (const [key, expr] of Object.entries(loaded.get(board.root)?.workflow?.derived ?? {})) card[key] = evaluate(expr, { card, event, params, now: now() });
276
285
  return {
277
286
  card,
287
+ requester: actorOf(),
278
288
  event,
279
289
  params,
280
290
  now: now(),
@@ -751,6 +761,7 @@ const ACTIONS = {
751
761
  const id = `m-${randomBytes(12).toString('hex')}`;
752
762
  ctx.scope.asked = id;
753
763
  record(ctx.db, ctx.id, 'board_message', { to: String(to), text: `${String(text ?? '')}\n\nAnswer with reply_to ${id}.`, message_id: id }, null);
764
+ ctx.effects.push({ kind: 'board_message', id: ctx.id });
754
765
  },
755
766
  send_to_actor(ctx, text) { ctx.effects.push({ kind: 'send_to_actor', id: ctx.id, text }); },
756
767
  };
@@ -808,7 +819,7 @@ function runSession(ctx, change) {
808
819
  * the board: `--root …` or `--at …`) and its transition's description: what a started session is told it can do.
809
820
  */
810
821
  // 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' };
822
+ 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
823
 
813
824
  export function doors(workflow, status, role, id, where, surface = 'pane') {
814
825
  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 });
@@ -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.125",
3
+ "version": "0.4.127",
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": {