@volter/supercode-orchestrator 0.5.211 → 0.5.212

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.
@@ -53,6 +53,11 @@ if (process.argv[2] === 'workflow') {
53
53
  const {disconnectWorkplace}=await import('../connection.mjs');
54
54
  try{const {values}=parseArgs({args:process.argv.slice(3),options:{root:{type:'string'},connection:{type:'string'}}});process.stdout.write(JSON.stringify(await disconnectWorkplace(values),null,2)+'\n');}
55
55
  catch(error){process.stderr.write(`disconnect: ${error.message}\n`);process.exitCode=1;}
56
+ } else if (process.argv[2] === 'mcp-server') {
57
+ // a session's orchestrator tools on stdio, bound by its environment (mcp/mount.mjs mounts this through the stable
58
+ // supercode)
59
+ const {serveStdio,bindingFromEnv}=await import('../mcp/server.mjs');
60
+ serveStdio(bindingFromEnv());
56
61
  } else if (process.argv[2] === 'agent-channel') {
57
62
  await import('./agent-channel.mjs');
58
63
  } else {
@@ -0,0 +1,68 @@
1
+ // Upload staging belongs to the board, beneath the authorized card, and to one caller.
2
+ // No attachment record exists until all bytes and their digest have been verified.
3
+ import { closeSync, fstatSync, linkSync, mkdirSync, openSync, readFileSync, readSync, rmSync, unlinkSync, writeFileSync, writeSync } from 'node:fs';
4
+ import { createHash } from 'node:crypto';
5
+ import { basename, join } from 'node:path';
6
+
7
+ const CHUNK = 1024 * 1024;
8
+ const safeName = name => basename(String(name)).replace(/[^A-Za-z0-9._ -]/g, '_').replace(/^\.+$/, '') || 'attachment';
9
+
10
+ export function receiveAttachment({ directory, upload, caller, input, add }) {
11
+ if (!/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(upload) || !caller)
12
+ throw new Error('attachment upload needs its identifier and an admitted caller');
13
+ let body;
14
+ try { body = JSON.parse(input); } catch { throw new Error('attachment upload needs JSON input'); }
15
+ const staging = join(directory, '.uploads', upload), metadata = join(staging, 'upload.json'), path = join(staging, 'bytes');
16
+ if (body?.action === 'begin') {
17
+ if (!Number.isSafeInteger(body.size) || body.size < 0 || typeof body.name !== 'string') throw new Error('invalid attachment extent or name');
18
+ mkdirSync(join(directory, '.uploads'), { recursive: true });
19
+ mkdirSync(staging, { mode: 0o700 });
20
+ try {
21
+ writeFileSync(path, '', { flag: 'wx', mode: 0o600 });
22
+ writeFileSync(metadata, JSON.stringify({ caller, size: body.size, name: safeName(body.name) }), { flag: 'wx', mode: 0o600 });
23
+ } catch (error) { rmSync(staging, { recursive: true, force: true }); throw error; }
24
+ return { ok: true, upload, received: 0 };
25
+ }
26
+ const held = JSON.parse(readFileSync(metadata, 'utf8'));
27
+ if (held.caller !== caller) throw new Error('attachment upload belongs to another caller');
28
+ if (body?.action === 'abort') { rmSync(staging, { recursive: true }); return { ok: true, upload, aborted: true }; }
29
+ if (body?.action === 'append') {
30
+ const bytes = typeof body.bytes === 'string' && body.bytes.length <= Math.ceil(CHUNK / 3) * 4 ? Buffer.from(body.bytes, 'base64') : null;
31
+ if (!bytes?.length || bytes.length > CHUNK || bytes.toString('base64') !== body.bytes
32
+ || !Number.isSafeInteger(body.offset) || body.offset < 0 || body.offset + bytes.length > held.size)
33
+ throw new Error('invalid attachment chunk');
34
+ const file = openSync(path, 'r+');
35
+ try {
36
+ if (fstatSync(file).size !== body.offset) throw new Error('attachment chunk is not at its next offset');
37
+ for (let written = 0; written < bytes.length;) {
38
+ const count = writeSync(file, bytes, written, bytes.length - written, body.offset + written);
39
+ if (count <= 0) throw new Error('attachment chunk write stopped');
40
+ written += count;
41
+ }
42
+ } finally { closeSync(file); }
43
+ return { ok: true, upload, received: body.offset + bytes.length };
44
+ }
45
+ if (body?.action !== 'commit' || !/^[a-f0-9]{64}$/.test(body.sha256)) throw new Error('invalid attachment upload action or digest');
46
+ const file = openSync(path, 'r'), hash = createHash('sha256'), buffer = Buffer.alloc(CHUNK);
47
+ try {
48
+ if (fstatSync(file).size !== held.size) throw new Error('attachment upload is incomplete');
49
+ for (let offset = 0; offset < held.size;) {
50
+ const count = readSync(file, buffer, 0, Math.min(CHUNK, held.size - offset), offset);
51
+ if (count <= 0) throw new Error('attachment upload ended before its extent');
52
+ hash.update(buffer.subarray(0, count)); offset += count;
53
+ }
54
+ if (hash.digest('hex') !== body.sha256) throw new Error('attachment upload digest does not match the source');
55
+ } finally { closeSync(file); }
56
+ let target;
57
+ for (let n = 0;; n++) {
58
+ target = join(directory, n ? `${n}-${held.name}` : held.name);
59
+ try { linkSync(path, target); break; } catch (error) { if (error.code !== 'EEXIST') throw error; }
60
+ }
61
+ let id;
62
+ try { id = add({ path: target, size: held.size }, held.name); }
63
+ catch (error) { unlinkSync(target); throw error; }
64
+ // The committed record is authoritative; failed staging cleanup must not turn success into an ambiguous retry.
65
+ let cleanup = null;
66
+ try { rmSync(staging, { recursive: true }); } catch { cleanup = staging; }
67
+ return { ok: true, id, path: target, size: held.size, sha256: body.sha256, name: held.name, ...(cleanup ? { cleanup } : {}) };
68
+ }
package/board/cli.mjs CHANGED
@@ -15,6 +15,7 @@ import { detachSession } from './custody.mjs';
15
15
  import { taskStatusOf } from './published.mjs';
16
16
  import { executionOf } from './session-work.mjs';
17
17
  import { attachmentRecord, readAttachment } from './attachment-read.mjs';
18
+ import { receiveAttachment } from './attachment-write.mjs';
18
19
  import { listCardsInTurns, READ_BY_OWNER, writeCardList } from './listing.mjs';
19
20
  import { redactGuardText as redactText, withCredentialGuard } from './credential-guard.mjs';
20
21
  import { openOwnerDoor } from './owner-door.mjs';
@@ -112,13 +113,14 @@ The board's verbs, with their flags:
112
113
  comment <id> (<text> | --file PATH|-) [--author] [--max-len] [(--pr URL --pr-head SHA)...] [--source owner/repo:branch]... attach <id> <path> [--name] [--content-type] attachments <id> attach-rm <n>
113
114
  attachment-read <id> <attachment-id> [--offset N --length N --version V] bounded base64/metadata JSON, including through --at
114
115
  complete <id>... [--result] [--summary] [--metadata JSON] [--commit SHA] edit <id> --result [--summary] [--metadata]
116
+ finish <id> --result TEXT (when declared: end your completed attempt; the board selects review)
115
117
  block <id> [reason] (--on <card> | --waiting-on <message id>) [--ids ...] schedule <id> [reason] [--ids ...]
116
118
  pause <id> --reason <why> resume <id> cancel <id> --reason <why>
117
119
  request-state <id> --state block|pause|done|cancelled --reason <why> [--on <card>|--waiting-on <message>] [--evidence <pointers>]
118
120
  unblock <id>... --on <blocker> --reason <what arrived> request-review <id> (--pr URL --pr-head SHA)... [--summary] [--reviewer] [--force] [--commit SHA] request-changes <id> <reason> [--finding <finding>]... reopen-review <id>... reopen <id>... [--reason]
119
121
  promote <id> [reason] [--ids ...] [--force] [--dry-run] archive <id>... --reason <why> [--rm <id>...]
120
122
  specify <id> [--readable-id ID] [--title --body | --body-file PATH|-] [--workspace scratch|worktree:<repository>|dir:<repository>] [--branch NAME] [--machine M|none] [--all]
121
- decompose <id> [--child "<title>[@<profile>]"]... [--all]
123
+ decompose <id> [--child "<title>[@<profile>]"]... [--all] file the card's subtasks atomically, if its workflow allows
122
124
  diagnostics [--task] [--severity] stats assignees log <id> [--tail N] tail <id> watch [--assignee] [--kinds]
123
125
  github-usage [--since 1h] local REST attempts by caller/endpoint; no GitHub read
124
126
  notify-subscribe <id> --platform P --chat-id C [--thread-id] notify-list [<id>] notify-unsubscribe <id> --platform P --chat-id C
@@ -833,6 +835,15 @@ export async function runVerb(argv) {
833
835
  }
834
836
  case 'attach': {
835
837
  need(2, 'a card id and a file');
838
+ if (args.upload !== undefined) {
839
+ if (rest[1] !== '-') throw new Error('attachment upload reads its carried stdin');
840
+ withBoard(b => { if (!engine.getTask(b.db, id)) throw new Error(`no card ${id}`); });
841
+ const directory = join(slug === DEFAULT_BOARD ? join(root, 'kanban', 'attachments') : join(boardDir(root, slug), 'attachments'), id);
842
+ const result = receiveAttachment({ directory, upload: args.upload, caller: callerAddress() ?? actorOf(), input: current().stdin,
843
+ add: (file, name) => withBoard(b => engine.addAttachment(b, id, { file, name, contentType: args['content-type'] ?? null, author: args.author ?? callerName() })) });
844
+ say(args, result.id ? `Attached ${result.name} to ${id} (#${result.id}, sha256 ${result.sha256})` : `Upload ${args.upload}: ${result.received ?? 'aborted'}`, result);
845
+ return 0;
846
+ }
836
847
  const source = callerPath(rest[1]);
837
848
  if (!existsSync(source) || !statSync(source).isFile()) throw new Error(`no file ${rest[1]}`);
838
849
  const name = basename(args.name ?? source).replace(/[^A-Za-z0-9._ -]/g, '_') || 'attachment';
@@ -27,7 +27,7 @@ import { readMachineFleet } from './machine-fleet.mjs';
27
27
  import { homeFleetMachines } from './fleet-targets.mjs';
28
28
  import { delegatedWork, idleInterval } from './session-work.mjs';
29
29
  import { localSessionDelegations } from '../agents.mjs';
30
- import { creatorOf, sessionProgress } from './workflow.mjs';
30
+ import { cardSectionHeading, creatorOf, sessionProgress } from './workflow.mjs';
31
31
  import { boardLabel, boardName } from '@volter/teams/board-name';
32
32
  let fleetRoot = null;
33
33
  import { inspectCompletionEvidence, noCodeChange, openReviewPullRequests, reviewDue, withCompletionEvidence, withFreshReviewReads, reviewCandidate, workflowRevision, retainWorkflowTick } from './ci.mjs';
@@ -51,7 +51,7 @@ import { homedir, hostname, tmpdir, totalmem } from 'node:os';
51
51
  import { basename, dirname, join } from 'node:path';
52
52
  import { createHash, randomUUID } from 'node:crypto';
53
53
  import { fileURLToPath } from 'node:url';
54
- import { view, boardCopyOf, boardKey, boardPath, cardHandleOf, readableIdOf, briefPath, claimer, claimHolds, copyRefusal, event, json, listBoards, logsDir, newClaim, now, NOTICE_RUN_IDS, openBoard, pendingLaunches, sessionBound, setActor, storageFault, tx } from './store.mjs';
54
+ import { view, boardCopyOf, boardKey, boardPath, cardHandleOf, readableIdOf, briefPath, claimer, claimHolds, copyRefusal, event, json, listBoards, logsDir, newClaim, now, NOTICE_RUN_IDS, openBoard, openBoardSnapshot, pendingLaunches, sessionBound, setActor, storageFault, tx } from './store.mjs';
55
55
  import { doors, fire, loadWorkflow, resumeOf, runSpec, runWorkingSeconds, requestSessionClose, retireSupersededRuns, refreshCardInputs, refreshRelationAnswers, transitionsFor, scopeOf, statusOf, projectStatus, storedStatus, templateValue, workflowFor } from './workflow.mjs';
56
56
  import { checkWorkspace, assertWorkspaceOwner, notePresence, resolveWorkspace } from './workspace.mjs';
57
57
  import { capacity, dispatched, executionHost, harnessDefaults, holdings } from './capacity.mjs';
@@ -830,7 +830,14 @@ async function homeHoldings(root, slugs, lanes, fleet, entry) {
830
830
  const out = [];
831
831
  for (const slug of slugs) {
832
832
  try {
833
- const b = openBoard(root, slug);
833
+ // Capacity spans awaits. It needs its own committed snapshot, not the shared writer's total_changes counter:
834
+ // unrelated owner calls and delivery writes used to invalidate all three attempts at this read.
835
+ let b;
836
+ try { b = openBoardSnapshot(root, slug); }
837
+ catch (error) {
838
+ if (error.code !== 'board_read_by_owner') throw error;
839
+ b = openBoard(root, slug); // files/non-WAL backings retain their guarded optimistic read
840
+ }
834
841
  try {
835
842
  b.slug ??= slug; b.label ??= boardLabel(root, slug);
836
843
  // each run's session read before it is counted live or not (unknown stays unknown)
@@ -840,7 +847,7 @@ async function homeHoldings(root, slugs, lanes, fleet, entry) {
840
847
  runLaneOf: row => runLane(b.db, b, entry, getTask(b.db, row.id), lanes) }));
841
848
  }
842
849
  finally { b.close(); }
843
- } catch (error) { if (error.capacityUnread) throw error; /* its own tick reports other board errors */ }
850
+ } catch (error) { error.capacityUnread = true; throw error; }
844
851
  }
845
852
  out.defaults = harnessDefaults(out);
846
853
  return out;
@@ -927,6 +934,9 @@ async function startCards(board, fleet, lanes, settings, report, entry, effects,
927
934
  if (task.subtask_of || task.current_run_id) continue;
928
935
  const state = scopeFor(task).card;
929
936
  if (state.archived || ['done', 'cancelled'].includes(state.status)) continue;
937
+ if (state.status === 'running' && completedWork(board.db, task.id)) {
938
+ waiting(task, 'work is complete; the board must select its independent review before another implementer run'); continue;
939
+ }
930
940
  if (state.detached) { waiting(task, `session belongs to ${state.detached.person}; verified adoption is required`); continue; }
931
941
  if (state.dispatch_hold) { waiting(task, 'dispatch held: an explicit start or verified adoption must release it'); continue; }
932
942
  if (workflow.run && state.paused) { waiting(task, `paused: ${state.paused.reason ?? 'manager pause'}`); continue; }
@@ -991,7 +1001,7 @@ async function startCards(board, fleet, lanes, settings, report, entry, effects,
991
1001
  const refused = caps.admit(waitingEntries[i], scope, waitingEntries);
992
1002
  if (refused) throw Object.assign(new Error(refused.why), { claimedElsewhere: true });
993
1003
  assertWorkspaceOwner(db, task, checked);
994
- if (checked) db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.mainRoot, task.id);
1004
+ if (checked) db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.workspaceRoot, task.id);
995
1005
  if (!task.assignee && plan.profile) {
996
1006
  db.prepare('UPDATE tasks SET assignee = ? WHERE id = ?').run(plan.profile, task.id);
997
1007
  event(db, task.id, 'assigned', { assignee: plan.profile, source: 'workflow role' });
@@ -1076,7 +1086,7 @@ async function launchAttempt(board, entry, id, lane, fleet, settings, runId) {
1076
1086
  const checked = await checkWorkspace(board, task);
1077
1087
  if (checked) runTx(board, id, runId, (db) => {
1078
1088
  assertWorkspaceOwner(db, task, checked);
1079
- db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.mainRoot, id);
1089
+ db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.workspaceRoot, id);
1080
1090
  });
1081
1091
  const status = view(board, (db) => statusOf(db, task));
1082
1092
  const spec = runSpec(entry, status, task);
@@ -1209,7 +1219,7 @@ export async function startCard(board, id, { roles = ['manager'], lanes, setting
1209
1219
  const moved = tx(board, (db) => {
1210
1220
  if (detachedCard(db, id)) throw new Error(`${id} is detached to a person; request and verify adoption before starting it`);
1211
1221
  assertWorkspaceOwner(db, before, checked);
1212
- if (checked) db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.mainRoot, id);
1222
+ if (checked) db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.workspaceRoot, id);
1213
1223
  if (!roles.some(role => ['manager', 'creator'].includes(role))) throw new Error('Only the manager or creator starts a held card; request its decision.');
1214
1224
  event(db, id, 'dispatch_released', { reason: 'explicit start' });
1215
1225
  requestSessionClose(db,id);
@@ -1450,7 +1460,7 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
1450
1460
  continue;
1451
1461
  }
1452
1462
  }
1453
- if (session?.monitor_held && !session.close_requested) continue; // only an explicit close action ends this process
1463
+ if ((session?.monitor_held || session?.retain_after_run) && !session.close_requested) continue; // only an explicit close action ends this process
1454
1464
  if ((!session?.pane && session?.surface !== 'native') || session.close_transferred_to || session.close_confirmed_at || (!row.ended_at && !session.close_requested)) continue;
1455
1465
  if (caller && session.session_id === caller) continue; // let the command that ended its own run return
1456
1466
  // The board closes only what it launched. An adopted session (a person's own thread, a hook session the card was
@@ -1617,7 +1627,18 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
1617
1627
  return Number(rows?.find((row) => row.locator?.session_id === session.session_id)?.updated_at_ms) || null;
1618
1628
  };
1619
1629
  const managers = [].concat(entry.params.managers ?? []).filter(Boolean);
1620
- const delegations = localSessionDelegations(board.root, localMachine());
1630
+ const running = board.db.prepare(entry.workflow.run ? "SELECT id FROM tasks WHERE current_run_id IS NOT NULL" : "SELECT id FROM tasks WHERE status = 'running'").all();
1631
+ const edges = localSessionDelegations(board.root, localMachine());
1632
+ const reached = new Set(running.map(({ id }) => addressOf(currentRun(board.db, id)?.metadata?.supercode)).filter(Boolean));
1633
+ const delegations = [];
1634
+ const pending = [...reached];
1635
+ const byStarter = new Map();
1636
+ for (const edge of edges) { const children = byStarter.get(edge.starter) ?? []; children.push(edge); byStarter.set(edge.starter, children); }
1637
+ while (pending.length) for (const edge of byStarter.get(pending.pop()) ?? []) {
1638
+ delegations.push(edge);
1639
+ if (!reached.has(edge.session)) { reached.add(edge.session); pending.push(edge.session); }
1640
+ }
1641
+ report.idle_delegations = { recorded: edges.length, reachable: delegations.length };
1621
1642
  const delegatedReadings = [...fleet.sessions];
1622
1643
  // Forks and concurrent sessions need not have a pane. Read only the exact
1623
1644
  // native identities this home's starter receipts name, before any write.
@@ -1633,7 +1654,6 @@ async function observeIdle(board, fleet, settings, report, entry, effects) {
1633
1654
  if (row?.activity) delegatedReadings.push({ address, state: row.activity.presence, turn: row.activity.turn });
1634
1655
  } catch { /* an unread child is never called working */ }
1635
1656
  }
1636
- const running = board.db.prepare(entry.workflow.run ? "SELECT id FROM tasks WHERE current_run_id IS NOT NULL" : "SELECT id FROM tasks WHERE status = 'running'").all();
1637
1657
  // every running card a session is on: a session waiting on its user is told once, naming them all
1638
1658
  const cardsOf = new Map();
1639
1659
  for (const { id } of running) {
@@ -2284,6 +2304,10 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
2284
2304
  const pending = view(board, (db) => db.prepare('SELECT id, ended_at, metadata FROM task_runs WHERE task_id = ? AND id != ?').all(task.id, runId))
2285
2305
  .map((r) => ({ ...r, session: json(r.metadata, {})?.supercode }))
2286
2306
  .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
2307
+ // Deliberate retention owes no close. Only this slot's latest identity may reach the liveness/continuation
2308
+ // checks below; an explicit close or another unresolved identity still holds the slot.
2309
+ && !(r.session.retain_after_run && !r.session.close_requested && !r.session.forgotten && last?.session_id
2310
+ && r.session.session_id === last.session_id && r.session.pane === last.pane && r.session.machine === machine && (r.session.harness ?? 'claude-code') === harness)
2287
2311
  && !(closeRefused(r.session) && last?.session_id && r.session.session_id === last.session_id));
2288
2312
  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'}`);
2289
2313
  let prev = view(board, (db) => lastSession(db, task.id, slot));
@@ -2431,13 +2455,14 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
2431
2455
  if (next === 'refuse') throw new Error(`session ${prev.session_id} still has live pane ${prev.pane}; cannot resume or replace it`);
2432
2456
  if (next === 'keep') {
2433
2457
  // the session is kept; the heartbeat's count belongs to the run that ended, so the new run starts it again
2434
- const { missing_since: _m, goal_turns: _t, goal_nudged_at: _n, idle_since: _is, idle_notice: _in, unknown_since: _us, unknown_notice: _un, work_progress: _wp, idle_observation: _io, idle_below_threshold: _ib, idle_eligible: _ie, close_requested: _q, closed_at: _c, close_confirmed_at: _cc, close_error: _ce, close_attempts: _ca, close_attempted_at: _ct, close_failed_at: _cf, close_unread: _cu, close_claim: _cl, monitor_held: _mh, released: _rl, review_held: _rh, ...kept } = prev;
2458
+ const { missing_since: _m, goal_turns: _t, goal_nudged_at: _n, idle_since: _is, idle_notice: _in, unknown_since: _us, unknown_notice: _un, work_progress: _wp, idle_observation: _io, idle_below_threshold: _ib, idle_eligible: _ie, close_requested: _q, closed_at: _c, close_confirmed_at: _cc, close_error: _ce, close_attempts: _ca, close_attempted_at: _ct, close_failed_at: _cf, close_unread: _cu, close_claim: _cl, monitor_held: _mh, released: _rl, review_held: _rh, retain_after_run: _rr, ...kept } = prev;
2435
2459
  session = { ...kept, mode: 'adopt', launched_at: now(), seen_at: now() };
2436
2460
  // a kept session is told what this run is for (its card came back to it, with what happened since), only while
2437
2461
  // this run is still the card's current one (its pane and native reads were awaited)
2438
2462
  tx(board, (db) => {
2439
2463
  if (!runCurrent(db, task.id, runId)) throw Object.assign(new Error(`run ${runId} of ${task.id} ended or was replaced while its session was read; nothing was told to it`), { launchSuperseded: true });
2440
2464
  setRunSession(db, runId, { ...session, ...meta });
2465
+ transferRetainedSession(db, task.id, runId, session);
2441
2466
  // an earlier run's close of this pane is this run's now: the pane is the kept session's, not a close to retry
2442
2467
  if (prev.pane) for (const r of db.prepare('SELECT id, metadata FROM task_runs WHERE task_id = ? AND id != ?').all(task.id, runId)) {
2443
2468
  const s = json(r.metadata, {})?.supercode;
@@ -2506,6 +2531,7 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
2506
2531
  const saved = json(row?.metadata, {})?.supercode;
2507
2532
  if (saved?.address === session.address && !saved.launch_pending) return true;
2508
2533
  setRunSession(db, runId, session);
2534
+ transferRetainedSession(db, task.id, runId, session);
2509
2535
  event(db, task.id, kind, { session_id: session.session_id, machine, pane: session.pane, address: session.address, mode: session.mode, ...(replaced ? { replaced } : {}) }, runId);
2510
2536
  // the working session hears updates to its card; the slot's previous session no longer does (a reviewer starting
2511
2537
  // leaves the implementer's, and a subtask's arc session's, subscription alone)
@@ -2520,6 +2546,19 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
2520
2546
  return session;
2521
2547
  }
2522
2548
 
2549
+ /** A continued role identity now belongs to its new attempt. Preserve the completed receipt, and keep an old
2550
+ * retained reference from closing the same native process/pane later. Transfer only after a successful bind. */
2551
+ function transferRetainedSession(db, id, runId, session) {
2552
+ if (!session?.session_id) return;
2553
+ for (const row of db.prepare('SELECT id,metadata FROM task_runs WHERE task_id=? AND id<? AND ended_at IS NOT NULL').all(id, runId)) {
2554
+ const old = json(row.metadata, {})?.supercode;
2555
+ if (!old?.retain_after_run || old.close_requested || old.close_confirmed_at || old.close_transferred_to || old.forgotten
2556
+ || old.session_id !== session.session_id || old.machine !== session.machine || (old.slot ?? 'worker') !== (session.slot ?? 'worker')) continue;
2557
+ setRunSession(db, row.id, { close_transferred_to: runId });
2558
+ event(db, id, 'session_transferred', { successor: runId, session_id: old.session_id, pane: old.pane ?? null }, row.id);
2559
+ }
2560
+ }
2561
+
2523
2562
  /** The agent a slot's last session belongs to, when it is not the agent this run's `meta` names: the agent store's
2524
2563
  * owner of the session where it records one in this run's org, else the agent its own run ran as. Null when it is
2525
2564
  * this run's agent, or no agent is known for it (a session from before card agents). */
@@ -2816,8 +2855,10 @@ async function adoptSession(board, id, who, { request = null, open = request, pr
2816
2855
  // agent store another, and every round after would trip on it.
2817
2856
  const owner = agent ? agentRunForSession(board.root, [`${harness}:${sessionId}`]) : null;
2818
2857
  if (owner && owner.agent_id !== agent.identity.id) throw new Error(`${found.address} already works for agent ${owner.agent_id} (run ${owner.run_id}); it cannot be adopted as ${agent.identity.id}`);
2819
- // the card's previous session is read before the bind decides whether it is still live
2820
- await readBefore(fleet, view(board, (db) => lastSession(db, id)));
2858
+ // Each role owns its own slot. A retained worker does not prevent binding the independent reviewer; only this
2859
+ // adoption's slot can conflict with the incoming identity. Independence still reads worker history below.
2860
+ const adoptionSlot = beforeSession?.slot ?? runSpec(entry0, view(board, db => statusOf(db, card0)), card0)?.session ?? 'worker';
2861
+ await readBefore(fleet, view(board, (db) => lastSession(db, id, adoptionSlot)));
2821
2862
  const adopted = tx(board, (db) => {
2822
2863
  const task = getTask(db, id);
2823
2864
  if (!task) throw new Error(`no card ${id}`);
@@ -2826,7 +2867,7 @@ async function adoptSession(board, id, who, { request = null, open = request, pr
2826
2867
  if (changed) throw changed;
2827
2868
  if (beforeRun && assignee && assignee !== task.assignee) throw new Error('rebinding a session does not reassign its active run');
2828
2869
  if (task.machine && normalMachine(task.machine) !== host) throw new Error(`${id} runs on ${task.machine}, and ${found.address} is on ${host}; move the card first`);
2829
- const was = lastSession(db, id);
2870
+ const was = lastSession(db, id, adoptionSlot);
2830
2871
  // a session whose close the board confirmed is not live, whatever a fleet reading still lists (a released one was
2831
2872
  // left running, and is read)
2832
2873
  if (was && was.session_id !== sessionId && !(was.close_confirmed_at && !was.released) && sessionAlive(fleet, was)) throw new Error(`${id} already has a live session, ${was.address}`);
@@ -3526,14 +3567,37 @@ export async function plan({ root, lanes, settings, slugs = null, params = {} })
3526
3567
  return out;
3527
3568
  }
3528
3569
 
3570
+ /** An explicit completion of the latest attempt, after any previous review or reopening. A reclaimed or failed
3571
+ * attempt is not a completed disposition, and a new attempt or returned verdict invalidates the old receipt. */
3572
+ function completedWork(db, id) {
3573
+ const last = db.prepare(`SELECT id,kind,run_id FROM task_events WHERE task_id=?
3574
+ AND kind IN ('work_completed','review_requested','changes_requested','review_pass','review_voided','reopened')
3575
+ ORDER BY id DESC LIMIT 1`).get(id);
3576
+ if (last?.kind !== 'work_completed') return null;
3577
+ const attempt = db.prepare('SELECT id,outcome,ended_at FROM task_runs WHERE task_id=? ORDER BY id DESC LIMIT 1').get(id);
3578
+ return attempt?.id === last.run_id && attempt.outcome === 'completed' && attempt.ended_at != null ? last : null;
3579
+ }
3580
+
3581
+ /** Finish ends the attempt, not its caller's native turn. Keep observing that retained attempt until the board
3582
+ * requests review; the receipt's lifecycle/latest-attempt fence prevents an older session holding later work. */
3583
+ function reviewTurnRun(db, id) {
3584
+ const active = currentRun(db, id);
3585
+ if (active) return active;
3586
+ const completed = completedWork(db, id);
3587
+ if (!completed || detachedCard(db, id)) return null;
3588
+ const row = db.prepare('SELECT * FROM task_runs WHERE id=?').get(completed.run_id);
3589
+ const metadata = json(row?.metadata, {}) ?? {}, session = metadata.supercode;
3590
+ return row && session?.retain_after_run && !session.close_transferred_to && !detachedSession(db, session) ? { ...row, metadata } : null;
3591
+ }
3592
+
3529
3593
  /** Whether an arc with no open PR may be one that changed no code and is due its review: it has done work, by filing
3530
- * tasks or by a run that ended with an Acceptance outcome ticked (a fresh arc with neither has not started), and no
3594
+ * tasks, an explicit completed-work receipt, or a completed run with an Acceptance outcome ticked, and no
3531
3595
  * review of it is pending or passed (after changes are requested, closing the tasks that own the findings makes it
3532
3596
  * due again). */
3533
3597
  function noCodeReviewDue(board, id) {
3534
3598
  return view(board, (db) => {
3535
3599
  const filed = db.prepare('SELECT 1 FROM supercode_cards WHERE subtask_of = ? LIMIT 1').get(id);
3536
- if (!filed && !(db.prepare('SELECT 1 FROM task_runs WHERE task_id = ? AND ended_at IS NOT NULL LIMIT 1').get(id) &&
3600
+ if (!filed && !completedWork(db, id) && !(db.prepare("SELECT 1 FROM task_runs WHERE task_id = ? AND outcome = 'completed' AND ended_at IS NOT NULL LIMIT 1").get(id) &&
3537
3601
  outcomeTicked(db.prepare('SELECT body FROM tasks WHERE id = ?').get(id)?.body))) return false;
3538
3602
  const last = db.prepare("SELECT kind FROM task_events WHERE task_id = ? AND kind IN ('review_requested', 'changes_requested', 'review_pass','review_voided') ORDER BY id DESC LIMIT 1").get(id);
3539
3603
  return !last || ['changes_requested','review_voided'].includes(last.kind);
@@ -3549,8 +3613,8 @@ function passedReview(board, id) {
3549
3613
  function outcomeTicked(body) {
3550
3614
  let acceptance = false;
3551
3615
  return String(body ?? '').split('\n').some((line) => {
3552
- const heading = /^#{1,6}\s+(.+?)\s*$/.exec(line);
3553
- if (heading) acceptance = heading[1].toLowerCase() === 'acceptance';
3616
+ const heading = cardSectionHeading(line);
3617
+ if (heading !== null) acceptance = heading === 'acceptance';
3554
3618
  return acceptance && /^\s*[-*] \[[xX]\] \S/.test(line);
3555
3619
  });
3556
3620
  }
@@ -3571,7 +3635,7 @@ function outcomeTicked(body) {
3571
3635
  * running a proof, still holds the review. */
3572
3636
  async function turnInFlight(board, id, fleet, heldSince = null) {
3573
3637
  if (!fleet?.ok) return null;
3574
- const session = currentRun(board.db, id)?.metadata?.supercode;
3638
+ const session = reviewTurnRun(board.db, id)?.metadata?.supercode;
3575
3639
  if (!session?.session_id || session.surface === 'headless' || session.close_requested) return null;
3576
3640
  let live = fleet.sessions?.find((x) => x.address === addressOf(session)) ?? null;
3577
3641
  if (live?.turn == null && session.surface === 'native') try {
@@ -3643,11 +3707,11 @@ async function activatePullRequestReviews(board, entry, report, effects, { only
3643
3707
  }
3644
3708
  // A native turn holds the advisory proposal before authoritative source reads. Retain the proposal on the run
3645
3709
  // (`review_held`) so a goal turn can be told to have started after that work was first observed.
3646
- const heldRun = currentRun(board.db, id), dueKey = reviewDueKey(board, id, payload);
3710
+ const heldRun = reviewTurnRun(board.db, id), dueKey = reviewDueKey(board, id, payload);
3647
3711
  const held = heldRun?.metadata?.supercode?.review_held?.key === dueKey ? heldRun.metadata.supercode.review_held : null;
3648
3712
  const inTurn = await turnInFlight(board, id, fleet, held ? Number(held.since) : null);
3649
3713
  if (inTurn) {
3650
- if (!held && !dry && heldRun) tx(board, (db) => { if (currentRun(db, id)?.id === heldRun.id) setRunSession(db, heldRun.id, { review_held: { key: dueKey, since: now() } }); });
3714
+ if (!held && !dry && heldRun) tx(board, (db) => { if (reviewTurnRun(db, id)?.id === heldRun.id) setRunSession(db, heldRun.id, { review_held: { key: dueKey, since: now() } }); });
3651
3715
  report.queued.push([id, `review waits: ${inTurn.why}`]);
3652
3716
  continue;
3653
3717
  }
@@ -3902,7 +3966,7 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
3902
3966
  if (dispatch && !only && !report.paused) await observeIdle(board, fleet, settings, report, entry, effects);
3903
3967
  // what the moves asked for is recorded with them; the delivery pass (`deliver`) carries it out, outside the round
3904
3968
  queueEffects(board, effects);
3905
- phase('goals');
3969
+ phase('idle');
3906
3970
  ensureSubscriptions(board, entry);
3907
3971
  fireHooks(board, settings, report);
3908
3972
  if (String(board.db.prepare('PRAGMA journal_mode').get()?.journal_mode).toLowerCase() === 'wal') board.db.prepare('PRAGMA wal_checkpoint(PASSIVE)').get();
package/board/engine.mjs CHANGED
@@ -292,7 +292,7 @@ export function createCard(board, spec) {
292
292
  const handle = cardHandle(boardKey(board.root, board.slug), numberOf(db, id));
293
293
  if (namedBranch) run(db, 'UPDATE tasks SET branch_name = ? WHERE id = ?', handle ? namedBranch(handle) : `wt/${id}`, id);
294
294
  const machine = spec.machine ?? arc?.machine ?? null;
295
- run(db, 'INSERT OR REPLACE INTO supercode_cards (task_id,machine,subtask_of,workspace_anchor,workspace_root,tags,requested_by_unverified) VALUES (?,?,?,?,?,?,?)', id, machine, arc?.id ?? null, declared.repository, checked?.mainRoot ?? null,
295
+ run(db, 'INSERT OR REPLACE INTO supercode_cards (task_id,machine,subtask_of,workspace_anchor,workspace_root,tags,requested_by_unverified) VALUES (?,?,?,?,?,?,?)', id, machine, arc?.id ?? null, declared.repository, checked?.workspaceRoot ?? null,
296
296
  tags.length ? JSON.stringify(tags) : null, spec.requestedByUnverified ? JSON.stringify(spec.requestedByUnverified) : null);
297
297
  for (const p of parents) addDependency(db, entry, id, p);
298
298
  // where it starts is the workflow's `initial` (Hermes: triage, blocked, todo behind open parents, else ready)
@@ -740,17 +740,26 @@ export const editResult = (board, id, { result, summary = null, metadata = undef
740
740
  export const heartbeat = (board, id, note = null, { roles, until = null } = {}) => act(board, id, 'heartbeat', { roles, payload: { note, until } });
741
741
  export const setGoal = (board, id, { on = true, maxTurns = null, roles } = {}) => act(board, id, 'set_goal', { roles, payload: { goal: Boolean(on), max_turns: on ? maxTurns : null } });
742
742
 
743
- /** A triage card fans out into children it waits on (Hermes's decomposer); answers their ids. */
743
+ /** File the named card's tasks only if its workflow accepts decompose; refusal leaves no cards behind. */
744
744
  export async function decompose(board, id, children, { author = 'user', roles } = {}) {
745
745
  if (!children.length) throw new Error('decompose names at least one child (--child "<title>[@<assignee>]")');
746
- const task = view(board, (db) => must(db, id));
747
- const ids = [];
748
- for (const c of children) ids.push(createCard(board, await prepareCard(board, {
749
- title: c.title, body: c.body ?? null, assignee: c.assignee ?? task.assignee, machine: task.machine,
750
- workspace: task.workspace_anchor ? `${task.workspace_kind}:${task.workspace_anchor}` : task.workspace_path ? `${task.workspace_kind}:${task.workspace_path}` : task.workspace_kind, tenant: task.tenant, createdBy: author,
751
- })).id);
752
- await act(board, id, 'decompose', { roles, payload: { children: ids, author } });
753
- return ids;
746
+ const entry = await loadWorkflow(board.root);
747
+ await refreshCardInputs(board, id);
748
+ const result = tx(board, db => {
749
+ const task = must(db, id), sender = roles ?? callerRoles(db, id);
750
+ if (task.subtask_of) throw new Error(`${id} is itself a subtask of ${task.subtask_of}; decompose its arc instead`);
751
+ // Creation and authorization share this transaction. Guards see the actual child ids, and any refusal
752
+ // (including a later child's invalid spec) rolls back every child, event, subscription and queued effect.
753
+ const ids = children.map(c => createCard(board, {
754
+ title: c.title, body: c.body ?? null, assignee: c.assignee ?? task.assignee, machine: task.machine,
755
+ subtaskOf: id, workspace: 'scratch', tenant: task.tenant, createdBy: author,
756
+ }).id);
757
+ const move = fire(db, board, entry, id, 'decompose', { roles: sender, payload: { children: ids, author } });
758
+ for (const child of ids) settle(db, board, entry, child, move.effects);
759
+ return { ids, effects: move.effects };
760
+ });
761
+ await runEffects(board, result.effects);
762
+ return result.ids;
754
763
  }
755
764
 
756
765
  /** `child` waits for `parent`. */
package/board/store.mjs CHANGED
@@ -516,7 +516,7 @@ export function heldConfigWatched(slug, source, watched) {
516
516
 
517
517
  export function openBoard(root, slug = DEFAULT_BOARD, options = {}) {
518
518
  // a listing on the owner's read door reads on a connection of its own, never the held board's (openReadOnly)
519
- if (current().readOnly) return openReadOnly(root, slug);
519
+ if (current().readOnly) return openBoardSnapshot(root, slug);
520
520
  if (holding !== null && !options.create && realRoot(root) === holding && !untrustedConfig.get(slug)?.size) {
521
521
  let entry = heldBoards.get(slug);
522
522
  if (!entry) {
@@ -553,7 +553,7 @@ function heldHandle(entry, slug) {
553
553
  * reader can take; a store not in WAL or not laid out; one it cannot begin reading, `unreadable`) is left to the owner
554
554
  * (listing.mjs byOwner).
555
555
  */
556
- function openReadOnly(root, slug) {
556
+ export function openBoardSnapshot(root, slug) {
557
557
  const use = backingOf(root, slug);
558
558
  if (!use) throw new Error(`no board ${slug} in ${root} (${boardPath(root, slug)})`);
559
559
  const path = backingPaths(root, slug)[use];
@@ -382,6 +382,14 @@ function stateRequestOf(db, id) {
382
382
 
383
383
  const OPEN_OUTCOME = /^(\s*[-*] )\[ \] (.+)$/;
384
384
  const TICKED_OUTCOME = /^\s*[-*] \[[xX]\] .+$/;
385
+ /** Card bodies use Markdown headings and standalone labels such as `Acceptance:` and `Tasks:`. Every label ends
386
+ * the preceding section, so a task or another section's checkbox never acquires Acceptance permissions. */
387
+ export function cardSectionHeading(line) {
388
+ const heading = /^#{1,6}\s+(.+?)\s*$/.exec(line);
389
+ if (heading) return heading[1].replace(/:$/, '').toLowerCase();
390
+ const label = /^\s*([A-Za-z][A-Za-z0-9 _-]*):\s*$/.exec(line);
391
+ return label ? label[1].trim().toLowerCase() : null;
392
+ }
385
393
  /** Whether `body` is `was` with only outcomes ticked: each changed line is under the Acceptance heading, was `- [ ] X`
386
394
  * and is now `- [x] X` or `- [x] X: <pointer>`, or was `- [x] X` and is now `- [x] X: <pointer>` (the pointer a tick
387
395
  * needs, added after it); no other line added, removed or changed. */
@@ -393,8 +401,8 @@ function onlyTicks(was, body) {
393
401
  // an outcome is a line of the card's Acceptance section (its heading at any level), not a task or any other checkbox
394
402
  let section = null;
395
403
  return before.every((line, i) => {
396
- const heading = /^#{1,6}\s+(.+?)\s*$/.exec(line);
397
- if (heading) section = heading[1].toLowerCase();
404
+ const heading = cardSectionHeading(line);
405
+ if (heading !== null) section = heading;
398
406
  if (line === after[i]) return true;
399
407
  if (section !== 'acceptance') return false;
400
408
  const pointed = (ticked) => after[i].startsWith(`${ticked}: `) && after[i].trim().length > ticked.trim().length + 1;
@@ -487,8 +495,8 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {}, lazy
487
495
  const lines = String(task.body ?? '').split('\n');
488
496
  let acceptance = false;
489
497
  return lines.every((line) => {
490
- const heading = /^#{1,6}\s+(.+?)\s*$/.exec(line);
491
- if (heading) acceptance = heading[1].toLowerCase() === 'acceptance';
498
+ const heading = cardSectionHeading(line);
499
+ if (heading !== null) acceptance = heading === 'acceptance';
492
500
  if (!acceptance || !/^\s*[-*] \[[ xX]\]/.test(line)) return true;
493
501
  return /^\s*[-*] \[[xX]\] .+: \S.+$/.test(line);
494
502
  });
@@ -1055,7 +1063,7 @@ const ACTIONS = {
1055
1063
  else runSession(ctx, (s) => ({ ...s, [counter]: 0 }));
1056
1064
  }
1057
1065
  },
1058
- end_run(ctx, { outcome, summary = null, error = null } = {}) {
1066
+ end_run(ctx, { outcome, summary = null, error = null, close_session = true } = {}) {
1059
1067
  const row = one(ctx.db, 'SELECT current_run_id FROM tasks WHERE id = ?', ctx.id);
1060
1068
  const t = now();
1061
1069
  if (row?.current_run_id) {
@@ -1063,10 +1071,15 @@ const ACTIONS = {
1063
1071
  retireSupersededRuns(ctx.db, ctx.id, runId, json(one(ctx.db, 'SELECT metadata FROM task_runs WHERE id = ?', runId)?.metadata, {})?.supercode);
1064
1072
  run(ctx.db, `UPDATE task_runs SET status = ?, outcome = ?, summary = COALESCE(?, summary), error = COALESCE(?, error), ended_at = ?, claim_lock = NULL, claim_expires = NULL, worker_pid = NULL
1065
1073
  WHERE id = ? AND ended_at IS NULL`, outcome, outcome, summary, error ?? ctx.scope.event?.error ?? null, t, runId);
1066
- requestSessionClose(ctx.db, ctx.id, { runId });
1067
1074
  ctx.runId = runId;
1068
1075
  ctx.lastOutcome = outcome;
1069
- ctx.effects.push({ kind: 'run_ended', id: ctx.id, runId, outcome });
1076
+ runSession(ctx, session => ({ ...session, retain_after_run: !close_session }));
1077
+ // A declared completion may end the attempt while retaining its role session. In particular, its own caller
1078
+ // must be able to finish and return before the board starts the independent review.
1079
+ if (close_session) {
1080
+ requestSessionClose(ctx.db, ctx.id, { runId });
1081
+ ctx.effects.push({ kind: 'run_ended', id: ctx.id, runId, outcome });
1082
+ }
1070
1083
  } else if (summary || ctx.scope.event?.metadata) {
1071
1084
  // an outcome reached with no run open (a person completing a card nobody claimed)
1072
1085
  const task = one(ctx.db, 'SELECT assignee FROM tasks WHERE id = ?', ctx.id);
@@ -1153,8 +1166,12 @@ const ACTIONS = {
1153
1166
  if (said?.body !== undefined && said?.body !== null) run(ctx.db, 'UPDATE tasks SET body = ? WHERE id = ?', said.body, ctx.id);
1154
1167
  },
1155
1168
  decompose(ctx, said) {
1156
- // the children are filed by the caller before the event (they are cards of their own); the card waits on them
1157
- for (const child of said?.children ?? []) run(ctx.db, 'INSERT OR IGNORE INTO task_links (parent_id, child_id) VALUES (?, ?)', child, ctx.id);
1169
+ // Native arc tasks are worked by the arc's session, never dispatched as prerequisite cards of their own.
1170
+ // Retain the older workflow's dependency action only for separately declared cards.
1171
+ for (const child of said?.children ?? []) {
1172
+ if (one(ctx.db, 'SELECT subtask_of FROM supercode_cards WHERE task_id=?', child)?.subtask_of === ctx.id) continue;
1173
+ run(ctx.db, 'INSERT OR IGNORE INTO task_links (parent_id, child_id) VALUES (?, ?)', child, ctx.id);
1174
+ }
1158
1175
  },
1159
1176
  // card ids a handoff names that no card has (Hermes flags them the same way)
1160
1177
  check_references(ctx, said) {
@@ -1,5 +1,5 @@
1
1
  // Filesystem/RPC work happens before a board transaction; the ownership check is pure.
2
- import { existsSync } from 'node:fs';
2
+ import { existsSync, realpathSync } from 'node:fs';
3
3
  import { basename, dirname } from 'node:path';
4
4
  import { runtimeDoor } from './runtime-door.mjs';
5
5
  import { inspectCheckout } from '@volter/teams/checkout';
@@ -130,23 +130,34 @@ export async function checkWorkspace(board, task) {
130
130
  if (task.workspace_kind === 'scratch') return null;
131
131
  task = { ...task, handle: task.handle ?? cardHandleOf(board, task.id) };
132
132
  const facts = await resolveWorkspace(task), peers = [];
133
- if (task.workspace_kind === 'dir' && !facts.linked) {
134
- for (const row of board.db.prepare(`SELECT t.id,t.workspace_path,t.branch_name,c.workspace_anchor,c.machine FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.current_run_id IS NOT NULL AND t.workspace_kind='dir'`).all()) {
133
+ const workspaceRoot = task.workspace_kind === 'worktree' ? facts.path : facts.root;
134
+ // New claims retain their exact checkout receipt before launch. Legacy claims without a receipt are resolved
135
+ // once for this check, including subdirectories and remote aliases that their retained path alone cannot identify.
136
+ {
137
+ for (const row of board.db.prepare(`SELECT t.id,t.workspace_kind,t.workspace_path,t.branch_name,c.workspace_anchor,c.machine FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.current_run_id IS NOT NULL AND t.workspace_kind!='scratch' AND c.workspace_root IS NULL`).all()) {
135
138
  if (row.id === task.id || normalMachine(row.machine ?? localMachine()) !== normalMachine(task.machine ?? localMachine())) continue;
136
139
  let other;
137
- try { other = await resolveWorkspace({ ...row, workspace_kind: 'dir' }); } catch { continue; } // That card's own launch reports its invalid workspace.
138
- if (!other.linked && other.root === facts.root) peers.push(row.id);
140
+ try { other = await resolveWorkspace({ ...row, handle: cardHandleOf(board, row.id) }); } catch { continue; } // Its retained path/reservation is still checked below.
141
+ if ((row.workspace_kind === 'worktree' ? other.path : other.root) === workspaceRoot) peers.push(row.id);
139
142
  }
140
143
  }
141
- const checked = { ...facts, mainRoot: task.workspace_kind === 'dir' && !facts.linked ? facts.root : null, peers };
144
+ const checked = { ...facts, workspaceRoot, peers };
142
145
  assertWorkspaceOwner(board.db, task, checked);
143
146
  return checked;
144
147
  }
148
+ const workspacePath = (path, local) => {
149
+ if (!path) return null;
150
+ if (local) { try { return realpathSync(path); } catch { /* a pre-launch reservation may not exist yet */ } }
151
+ return path.replace(/\/+$/, '');
152
+ };
145
153
  export function assertWorkspaceOwner(db, task, checked) {
146
- if (!checked?.mainRoot) return;
147
- const conflict = db.prepare(`SELECT t.id,c.machine,c.workspace_root FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.current_run_id IS NOT NULL AND t.workspace_kind='dir'`).all()
148
- .find(row => row.id !== task.id && normalMachine(row.machine ?? localMachine()) === normalMachine(task.machine ?? localMachine()) && (row.workspace_root === checked.mainRoot || checked.peers?.includes(row.id)));
149
- if (conflict) throw new Error(`main checkout is already used by running card ${conflict.id}; declare worktree:${checked.repository} for a solo worktree`);
154
+ if (!checked?.workspaceRoot) return;
155
+ const machine = normalMachine(task.machine ?? localMachine()), local = machine === localMachine();
156
+ const path = workspacePath(checked.workspaceRoot, local);
157
+ const conflict = db.prepare(`SELECT t.id,t.workspace_path,c.machine,c.workspace_root FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.current_run_id IS NOT NULL AND t.workspace_kind!='scratch'`).all()
158
+ .find(row => row.id !== task.id && normalMachine(row.machine ?? localMachine()) === machine &&
159
+ (workspacePath(row.workspace_root, local) === path || workspacePath(row.workspace_path, local) === path || checked.peers?.includes(row.id)));
160
+ if (conflict) throw new Error(`workspace ${checked.path} is already used by running card ${conflict.id}; declare worktree:${checked.repository} for a solo worktree`);
150
161
  }
151
162
 
152
163
  export const isLocalWorkspace = task => !task.machine || normalMachine(task.machine) === localMachine();
package/board-input.d.ts CHANGED
@@ -1,2 +1,4 @@
1
1
  /** Read a remote card write's selected text file (or stdin) on the caller's machine. */
2
2
  export function prepareBoardText(argv: string[]): { argv: string[]; stdin: string | null };
3
+ /** Read a caller-local attachment in bounded requests, ending with its digest. */
4
+ export function prepareBoardInputs(argv: string[]): Generator<{ argv: string[]; stdin: string | null }>;
package/board-input.mjs CHANGED
@@ -1,6 +1,8 @@
1
1
  // A remote card write reads its text on the caller's machine, before the board door is called.
2
2
  // The board's own argument parser decides which file is used; its last value wins.
3
- import { readFileSync } from 'node:fs';
3
+ import { closeSync, fstatSync, openSync, readFileSync, readSync } from 'node:fs';
4
+ import { createHash, randomUUID } from 'node:crypto';
5
+ import { basename } from 'node:path';
4
6
  import { parse } from './board/args.mjs';
5
7
 
6
8
  /** Carry a comment/create/specify text input as the owner's existing `stdin` field. */
@@ -14,3 +16,33 @@ export function prepareBoardText(argv) {
14
16
  // reads the carried text at `-`, never an unrelated file at the caller's original path on another machine.
15
17
  return { argv: [...argv, `--${key}`, '-'], stdin: readFileSync(file === '-' ? 0 : file, 'utf8') };
16
18
  }
19
+
20
+ /** Each yielded request fits one board frame. Only the last answer is the attachment receipt. */
21
+ export function* prepareBoardInputs(argv) {
22
+ const args = parse(argv);
23
+ if (args._[0] !== 'attach' || args.help) { yield prepareBoardText(argv); return; }
24
+ if (args._.length !== 3 || args.upload !== undefined) throw new Error('attach needs a card id and a local file');
25
+ const source = openSync(args._[2], 'r');
26
+ try {
27
+ const before = fstatSync(source, { bigint: true });
28
+ if (!before.isFile() || before.size > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error('attach needs a regular file of supported size');
29
+ const upload = randomUUID(), size = Number(before.size), hash = createHash('sha256');
30
+ const command = ['attach', args._[1], '-', '--upload', upload];
31
+ if (args.json) command.push('--json');
32
+ for (const key of ['name', 'content-type', 'author']) if (args[key] !== undefined) command.push(`--${key}`, args[key]);
33
+ const input = body => ({ argv: command, stdin: JSON.stringify(body) });
34
+ yield input({ action: 'begin', size, name: args.name ?? basename(args._[2]) });
35
+ const buffer = Buffer.alloc(1024 * 1024);
36
+ for (let offset = 0; offset < size;) {
37
+ const count = readSync(source, buffer, 0, Math.min(buffer.length, size - offset), offset);
38
+ if (count <= 0) throw new Error('attachment ended during upload; no attachment was committed');
39
+ const bytes = buffer.subarray(0, count); hash.update(bytes);
40
+ yield input({ action: 'append', offset, bytes: bytes.toString('base64') });
41
+ offset += count;
42
+ }
43
+ const after = fstatSync(source, { bigint: true });
44
+ if (before.size !== after.size || before.mtimeNs !== after.mtimeNs || before.ctimeNs !== after.ctimeNs)
45
+ throw new Error('attachment changed during upload; no attachment was committed');
46
+ yield input({ action: 'commit', sha256: hash.digest('hex') });
47
+ } finally { closeSync(source); }
48
+ }
package/hermes-cli.mjs CHANGED
@@ -1,13 +1,14 @@
1
1
  // Hermes's own tools beside a worker (docs/plans/hermes-compat.md row 7). A home is a Hermes home, and the
2
2
  // skills it carries call Hermes's CLI (`hermes kanban …`, `hermes cron notepad …`, `hermes cron doctor`)
3
3
  // against it, so a worker of any harness runs with `HERMES_HOME` set to its profile's folder and the
4
- // pinned `hermes` on its `PATH`; and what Hermes's scheduler records about a failed run (its incidents,
5
- // in `cron/executions.db`) the orchestrator records through Hermes's own function.
4
+ // pinned `hermes` on its `PATH`. Native cron incidents use the owning interchange codec, in the same executions store.
6
5
  import { spawn, spawnSync } from 'node:child_process';
7
6
  import { accessSync, constants, existsSync, readFileSync, statSync } from 'node:fs';
8
7
  import { delimiter, dirname, join } from 'node:path';
9
8
  import { parseDotenv } from './orchestration.mjs';
10
9
  import { locateHermes } from './apply/doors.mjs';
10
+ import { recordCronIncident } from '@volter/supercode-interchange/cron-incidents';
11
+ import { redactText } from '@volter/teams/redact';
11
12
 
12
13
  /** Hermes's interpreter over `code`, `payload` on stdin, without blocking the daemon: `{ status, stdout, stderr }`. */
13
14
  function python(install, code, payload, env, timeout) {
@@ -48,23 +49,10 @@ export function hermesToolEnv(profileDir) {
48
49
  return env;
49
50
  }
50
51
 
51
- /**
52
- * Record a failed run as Hermes's scheduler does: `cron.incidents.upsert_incident`, one incident per job
53
- * and error signature, in the profile's `cron/executions.db`. Answers the incident id, or null (and why)
54
- * when Hermes is not installed or refused.
55
- */
56
- export function recordIncident(profileDir, { job_id, job_name = null, error }) {
57
- const install = hermesInstall();
58
- if (!install?.python) return { id: null, why: 'no Hermes install on PATH' };
59
- const code = 'import json,sys\nfrom cron.incidents import upsert_incident\na=json.loads(sys.stdin.read())\nprint(json.dumps(upsert_incident(a["job_id"], a["error"], job_name=a.get("job_name"))))';
60
- const run = spawnSync(install.python, ['-c', code], {
61
- input: JSON.stringify({ job_id, job_name, error: String(error ?? 'failed') }),
62
- encoding: 'utf8', timeout: 30_000, cwd: install.source,
63
- env: { PATH: process.env.PATH ?? '', HOME: process.env.HOME ?? '', HERMES_HOME: profileDir, PYTHONPATH: install.source },
64
- });
65
- if (run.status !== 0) return { id: null, why: (run.stderr || run.error?.message || '').trim().slice(-300) };
66
- const [id] = JSON.parse(run.stdout.trim().split('\n').pop());
67
- return { id };
52
+ /** Record native cron failure grouping without requiring a separate Hermes Python installation. */
53
+ export function recordIncident(profileDir, { job_id, error }) {
54
+ try { return recordCronIncident(profileDir, { job_id, error: String(error ?? 'failed'), redacted_error: redactText(String(error ?? 'failed')) }); }
55
+ catch (error) { return { id: null, why: error.message }; }
68
56
  }
69
57
 
70
58
  // Access through Hermes's own pairing store and writers (docs/plans/hermes-compat.md row 8): a code is
package/index.d.ts CHANGED
@@ -443,7 +443,7 @@ export interface McpServerLaunch { name: string; command: string; arguments: str
443
443
  export function mountOrchestratorMcp(o: {
444
444
  root: Path; profile: Profile; harness: string; surfaceKey?: string | null;
445
445
  launch?: { program: string; arguments?: string[]; env?: Record<string, string> };
446
- socketPath?: Path; serverPath?: Path; program?: string;
446
+ socketPath?: Path; program?: string;
447
447
  /** The Teams id of the agent the session is; the rh2-agent record installed for that id (`--agent`) is mounted beside
448
448
  * the orchestrator server as `rh2-agent`. */
449
449
  agentId?: string | null;
package/loop.mjs CHANGED
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { mkdirSync, writeFileSync, existsSync, readdirSync, readFileSync, renameSync, unlinkSync, realpathSync } from 'node:fs';
8
8
  import { basename, join, dirname } from 'node:path';
9
+ import { parseSessionWire, stringifySessionWire } from '@volter/supercode-teams/session-read-client';
9
10
  import { step, leaseId } from './reducer.mjs';
10
11
  import * as events from './events.mjs';
11
12
  import { read, save, retainWorld, releaseWorld, refreshVault } from './orchestration.mjs';
@@ -278,9 +279,18 @@ export class Orchestrator {
278
279
  try {event={...event,identity:await this.identityRecord.observe({observation,location:JSON.stringify(event.key),at:Date.parse(event.at),name:event.sender_name,evidence:{adapter:event.adapter,message_id:event.message_id}})};}
279
280
  catch(error){event={...event,identity:{observation,principal:null,classification:'unresolved',label:'unresolved'}};this.log('warn',`identity observation unavailable: ${error.message}`);}
280
281
  }
282
+ const before = parseSessionWire(stringifySessionWire(this.state));
281
283
  const effects = step(this.state, event);
282
- for (const effect of effects) {
284
+ const firstSave = effects.findIndex(effect => effect.type === 'save');
285
+ if (firstSave >= 0) {
286
+ // Persist the reducer's claims before any external action. A refused save must leave no in-memory gate or
287
+ // hosted-fire marker behind: otherwise the next tick thinks an execution that never started is still alive.
288
+ try { await this.save(); }
289
+ catch (error) { this.state = before; throw error; }
290
+ }
291
+ for (const [index, effect] of effects.entries()) {
283
292
  this.effectLog.push(effect);
293
+ if (index === firstSave) continue;
284
294
  await this.perform(effect);
285
295
  }
286
296
  return effects;
package/mcp/mount.mjs CHANGED
@@ -24,13 +24,19 @@ import { SOCKET_ENV, socketPathFor } from './server.mjs';
24
24
  import { mcpServers } from '../hermes-home.mjs';
25
25
  import { KANBAN_ENV } from '../hermes-cli.mjs';
26
26
  import { installedAgentRecord } from '../installed-apps.mjs';
27
+ import { supercodeHome } from '@volter/teams/home';
27
28
 
28
29
  export { MCP_DOORS, doorFor } from '@volter/supercode-harness-sdk/mcp-doors';
29
30
 
30
- /** The stdio server this module mounts. */
31
+ /** The stdio server `supercode orchestrator mcp-server` runs (the mount names that door, never this path). */
31
32
  export const SERVER_PATH = resolve(dirname(fileURLToPath(import.meta.url)), 'server.mjs');
32
- /** The orchestrator's CLI, whose `apps mcp` runs `rh2 mcp` as an installed agent. */
33
- const ORCHESTRATOR_BIN = resolve(dirname(fileURLToPath(import.meta.url)), '../bin/orchestrator.mjs');
33
+ /** The machine's stable `supercode` (the source pin's shim in the supercode home, else the one on PATH), which runs
34
+ * every program a session mounts (`orchestrator mcp-server`, `orchestrator apps mcp`): a config that names it names no
35
+ * release, so the pin that retires a tree leaves it working. */
36
+ function stableSupercode() {
37
+ const shim = join(supercodeHome(), 'bin', process.platform === 'win32' ? 'supercode.cmd' : 'supercode');
38
+ return existsSync(shim) ? shim : 'supercode';
39
+ }
34
40
  /** The MCP server an agent with an installed rh2-agent gets: `rh2 mcp` as that agent (its own name, so the user's own
35
41
  * `rh2` server, whatever it holds, is neither replaced nor relied on). */
36
42
  export const RH2_AGENT_SERVER = 'rh2-agent';
@@ -40,7 +46,7 @@ export const RH2_AGENT_SERVER = 'rh2-agent';
40
46
  * Teams id): a record is found by that id alone, so an agent sharing another's name is never mounted with its record. */
41
47
  function rh2AgentServer(root, agentId, program) {
42
48
  if (!installedAgentRecord(root, agentId)) return null;
43
- return { command: program, args: [ORCHESTRATOR_BIN, 'apps', 'mcp', 'rh2-agent', '--root', root, '--agent', agentId] };
49
+ return { command: stableSupercode(), args: ['orchestrator', 'apps', 'mcp', 'rh2-agent', '--root', root, '--agent', agentId, '--node', program] };
44
50
  }
45
51
 
46
52
  const slug = (surfaceKey) => (surfaceKey ? String(surfaceKey).replace(/[^A-Za-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'session' : 'session');
@@ -55,13 +61,12 @@ const slug = (surfaceKey) => (surfaceKey ? String(surfaceKey).replace(/[^A-Za-z0
55
61
  * @param {string|null} o.surfaceKey the surface this session is bound to
56
62
  * @param {object|undefined} o.launch the launch the loop resolved, if any
57
63
  * @param {string} [o.socketPath] defaults to `<root>/orchestrator.sock`
58
- * @param {string} [o.serverPath] defaults to this package's `mcp/server.mjs`
59
- * @param {string} [o.program] the interpreter, defaults to this node
64
+ * @param {string} [o.program] the node the server runs with, defaults to this node
60
65
  * @param {string|null} [o.agentId] the Teams id of the agent whose own session this is, whose installed rh2-agent is
61
66
  * mounted beside; null for a worker acting for an agent
62
67
  * @returns {{door: string, ran: string, launch: object|undefined, mcp_servers: object[]|null, path: string|null}}
63
68
  */
64
- export function mountOrchestratorMcp({ root, profile, harness, surfaceKey = null, launch, socketPath, serverPath = SERVER_PATH, program = process.execPath, vault = new Map(), task = null, agentId = null }) {
69
+ export function mountOrchestratorMcp({ root, profile, harness, surfaceKey = null, launch, socketPath, program = process.execPath, vault = new Map(), task = null, agentId = null }) {
65
70
  const door = doorFor(harness);
66
71
  // the profile's own servers (`.mcp.json`, the workers' form; docs/plans/hermes-compat.md row 4), mounted
67
72
  // beside this one through the same door, `${VAR}` resolved from the profile's vault
@@ -81,6 +86,9 @@ export function mountOrchestratorMcp({ root, profile, harness, surfaceKey = null
81
86
  // board's tools for it (docs/plans/hermes-compat.md row 14)
82
87
  const sessionEnv = { ...env, ...(surfaceKey ? { [ORCH_ENV.surface]: surfaceKey } : {}), ...(task ?? {}) };
83
88
 
89
+ // the server through the stable supercode, never this release's server.mjs: a pin that retires this tree leaves the
90
+ // session's next server start (a resume, a reconnect) working
91
+ const server = { command: stableSupercode(), args: ['orchestrator', 'mcp-server', '--node', program] };
84
92
  switch (door) {
85
93
  case 'flag': {
86
94
  if (!launch) {
@@ -88,14 +96,14 @@ export function mountOrchestratorMcp({ root, profile, harness, surfaceKey = null
88
96
  }
89
97
  const file = join(home, `mcp-orchestrator-${slug(surfaceKey)}.json`);
90
98
  mkdirSync(home, { recursive: true });
91
- writeMcpConfigFile(file, { ...own, [SERVER_NAME]: { command: program, args: [serverPath], env: sessionEnv } });
99
+ writeMcpConfigFile(file, { ...own, [SERVER_NAME]: { ...server, env: sessionEnv } });
92
100
  return { door, ran: `wrote ${file} and passed --mcp-config`, launch: withMcpConfigFlag(launch, file), mcp_servers: null, path: file };
93
101
  }
94
102
 
95
103
  case 'toml': {
96
104
  const file = join(home, 'config.toml');
97
105
  mkdirSync(home, { recursive: true });
98
- const block = tomlBlock(program, serverPath, env);
106
+ const block = tomlBlock(server, env);
99
107
  let text = upsertTomlBlock(existsSync(file) ? readFileSync(file, 'utf8') : '', block, SERVER_NAME);
100
108
  const written = ownNames(home);
101
109
  for (const name of written) if (!(name in own)) text = removeTomlBlock(text, name);
@@ -115,7 +123,7 @@ export function mountOrchestratorMcp({ root, profile, harness, surfaceKey = null
115
123
  for (const name of ownNames(home)) if (!(name in own)) delete config.mcp[name];
116
124
  for (const [name, server] of Object.entries(own)) config.mcp[name] = opencodeEntry(server);
117
125
  recordOwnNames(home, Object.keys(own));
118
- config.mcp[SERVER_NAME] = opencodeEntry({ command: program, args: [serverPath], env });
126
+ config.mcp[SERVER_NAME] = opencodeEntry({ ...server, env });
119
127
  writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`);
120
128
  return { door, ran: `wrote mcp.${SERVER_NAME} into ${file}`, launch, mcp_servers: null, path: file };
121
129
  }
@@ -131,7 +139,7 @@ export function mountOrchestratorMcp({ root, profile, harness, surfaceKey = null
131
139
  launch,
132
140
  mcp_servers: [
133
141
  ...Object.entries(own).filter(([, server]) => server.command).map(([name, server]) => acpEntry(name, server)),
134
- acpEntry(SERVER_NAME, { command: program, args: [serverPath], env: sessionEnv }),
142
+ acpEntry(SERVER_NAME, { ...server, env: sessionEnv }),
135
143
  ],
136
144
  path: null,
137
145
  };
@@ -139,11 +147,11 @@ export function mountOrchestratorMcp({ root, profile, harness, surfaceKey = null
139
147
  }
140
148
 
141
149
  /** The orchestrator's own `[mcp_servers.orchestrator]` block. */
142
- function tomlBlock(program, serverPath, env) {
150
+ function tomlBlock({ command, args }, env) {
143
151
  const lines = [
144
152
  `[mcp_servers.${SERVER_NAME}]`,
145
- `command = ${tomlString(program)}`,
146
- `args = [${tomlString(serverPath)}]`,
153
+ `command = ${tomlString(command)}`,
154
+ `args = [${args.map(tomlString).join(', ')}]`,
147
155
  ];
148
156
  for (const [k, v] of Object.entries(env)) lines.push(`env.${k} = ${tomlString(v)}`);
149
157
  // The per-session half rides codex's own env allowlist: the server is a child
package/orchestration.mjs CHANGED
@@ -28,6 +28,7 @@ import { fileURLToPath } from 'node:url';
28
28
  import { SupercodeHarnessClient } from '@volter/supercode-harness-sdk';
29
29
  import { emptyRuntime, persistedView } from './ir.mjs';
30
30
  import { lockedNative } from './source-lock.mjs';
31
+ import { SESSION_READ_REQUEST_UNITS, stringifySessionWire } from '@volter/supercode-teams/session-read-client';
31
32
 
32
33
  const here = dirname(fileURLToPath(import.meta.url));
33
34
 
@@ -150,7 +151,7 @@ export async function load(root) {
150
151
  const { orchestration, vault_keys } = await through((d) => d.orchestrationLoad({ root }));
151
152
  let vault = readVault(orchestration);
152
153
  if (vault_keys.some((k) => !vault.has(k))) {
153
- await through((d) => d.orchestrationSave({ root, orchestration, preserve_workflow: true }));
154
+ await save(stateOf(orchestration), root);
154
155
  vault = readVault(orchestration);
155
156
  const missing = vault_keys.filter((k) => !vault.has(k));
156
157
  if (missing.length) throw new Error(`${root}: ${missing.join(', ')} referenced but not in .env after normalization`);
@@ -172,10 +173,36 @@ export async function read(root) {
172
173
  * written as the home last held it, or left out when it held none, and none refuses the rest of the save.
173
174
  */
174
175
  export async function save(state, root = state.root) {
175
- const saved = await through((d) => d.orchestrationSave({ root, orchestration: persistedView(state), preserve_workflow: true }));
176
+ const params = boundedRuntimeSave(state, root);
177
+ const saved = await through((d) => d.orchestrationSave(params));
178
+ // Apply retention in memory only after the owning door accepted it.
179
+ for (const [name, profile] of Object.entries(params.orchestration.profiles)) state.profiles[name].fires = profile.fires;
176
180
  return { rejected: Array.isArray(saved?.rejected) ? saved.rejected : [] };
177
181
  }
178
182
 
183
+ // A per-profile row count cannot bound the whole home's request. Keep active fires and all configuration, and
184
+ // remove the oldest terminal history across profiles until the actual native wire value fits, with envelope room.
185
+ export function boundedRuntimeSave(state, root = state.root) {
186
+ const orchestration = { ...persistedView(state), profiles: Object.fromEntries(Object.entries(state.profiles).map(([name, profile]) => [name, { ...profile, fires: [...profile.fires] }])) };
187
+ const params = { root, orchestration, preserve_workflow: true };
188
+ const budget = SESSION_READ_REQUEST_UNITS - 16 * 1024;
189
+ let units = Buffer.byteLength(stringifySessionWire(params));
190
+ if (units <= budget) return params;
191
+ const terminal = Object.entries(orchestration.profiles).flatMap(([name, profile]) => profile.fires
192
+ .filter(fire => !['claimed', 'running'].includes(fire.status)).map(fire => ({ name, fire })));
193
+ terminal.sort((a, b) => String(a.fire.claimed_at ?? '').localeCompare(String(b.fire.claimed_at ?? '')) || String(a.fire.id).localeCompare(String(b.fire.id)));
194
+ const drop = new Set();
195
+ for (const { fire } of terminal) {
196
+ if (units <= budget) break;
197
+ drop.add(fire);
198
+ units -= Buffer.byteLength(stringifySessionWire(fire)) + 1;
199
+ }
200
+ for (const profile of Object.values(orchestration.profiles)) profile.fires = profile.fires.filter(fire => !drop.has(fire));
201
+ const actual = Buffer.byteLength(stringifySessionWire(params));
202
+ if (actual > budget) throw new Error(`orchestrator runtime save needs ${actual} bytes after terminal-history retention (limit ${budget}); configuration and active jobs were preserved; no execution was started`);
203
+ return params;
204
+ }
205
+
179
206
  /** Another harness's home as a state value (no secret value; `vault_keys` names the `.env` keys it would need). */
180
207
  export async function compile(from, home) {
181
208
  const { orchestration, vault_keys } = await through((d) => d.orchestrationCompile({ from, home }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-orchestrator",
3
- "version": "0.5.211",
3
+ "version": "0.5.212",
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": {
@@ -32,7 +32,7 @@
32
32
  "check-types": "node scripts/generate-types.mjs --check"
33
33
  },
34
34
  "dependencies": {
35
- "@volter/supercode-teams": "^0.3.346",
35
+ "@volter/supercode-teams": "^0.3.347",
36
36
  "@volter/supercode-terminal": "^0.3.10",
37
37
  "@volter/supercode-harness-sdk": "^0.3.104",
38
38
  "@volter/teams": "0.2.99",
@@ -61,6 +61,5 @@
61
61
  "board",
62
62
  "apply",
63
63
  "README.md"
64
- ],
65
- "gitHead": "38f4a38369c31949d4d81fd3520a6aa678582ead"
64
+ ]
66
65
  }