@volter/supercode-orchestrator 0.4.76 → 0.4.78

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
@@ -10,7 +10,8 @@ import { basename, join, resolve } from 'node:path';
10
10
  import { tmpdir } from 'node:os';
11
11
  import { read } from '../orchestration.mjs';
12
12
  import { holdDispatcherLock } from '../hermes-locks.mjs';
13
- import { backingOf, boardExists, boardPath, DEFAULT_BOARD, event, json, listBoards, logsDir, now, openBoard, setActor, tx } from './store.mjs';
13
+ import { backingPaths, layOut, backingOf, boardExists, boardPath, DEFAULT_BOARD, event, json, listBoards, logsDir, now, openBoard, setActor, tx } from './store.mjs';
14
+ import { openZtrackBoard } from './ztrack.mjs';
14
15
  import * as engine from './engine.mjs';
15
16
  import { adoptSession, boardAddress, boardLabel, boardSettings, callerSession, lanesOf, localMachine, observeFleet, paused, plan, startCard, tick } from './dispatch.mjs';
16
17
  import { loadWorkflow, scopeOf, statusOf, useWorkflow, workflowEntry, workflowFor } from './workflow.mjs';
@@ -19,7 +20,9 @@ import { evaluate } from './expr.mjs';
19
20
  const HELP = `supercode workflow <verb> [--root <home>] [--board <slug>] [--json]
20
21
 
21
22
  Hermes Kanban's verbs, with their flags:
22
- init · boards list|create|rm|switch|show|rename|set-default-workdir|export|import
23
+ init [--backing sqlite|files|ztrack] [--document <arcs.md>]
24
+ migrate --backing ztrack --document <arcs.md> --status-map <json>
25
+ boards list|create|rm|switch|show|rename|set-default-workdir|export|import
23
26
  create <title> [--body] [--assignee] [--parent]... [--workspace scratch|worktree[:<path>]|dir:<path>] [--branch] [--project]
24
27
  [--tenant] [--priority] [--triage] [--idempotency-key] [--max-runtime 30m] [--created-by] [--skill]... [--max-retries]
25
28
  [--model] [--provider] [--goal|--no-goal] [--goal-max-turns] [--initial-status blocked|running]
@@ -226,7 +229,7 @@ export async function runWorkflow(argv) {
226
229
  if (!verb || verb === 'help' || args.help) { process.stdout.write(HELP); return 0; }
227
230
  const root = homeOf(args);
228
231
  const slug = boardOf(root, args);
229
- const open = (create = false) => { const b = openBoard(root, slug, { create, backing: create ? args.backing ?? null : null }); b.label = boardLabel(root, slug); return b; };
232
+ const open = (create = false) => { const b = openBoard(root, slug, { create, document: args.document ?? null, backing: create ? args.backing ?? null : null }); b.label = boardLabel(root, slug); return b; };
230
233
  const withBoard = (fn, create = false) => { const b = open(create); try { return fn(b); } finally { b.close(); } };
231
234
  const withBoardAsync = async (fn) => { const b = open(); try { return await fn(b); } finally { b.close(); } };
232
235
  const id = rest[0];
@@ -248,6 +251,23 @@ export async function runWorkflow(argv) {
248
251
  if (!sid && verb !== 'help') { try { setActor([].concat(workflowFor(root).params?.managers ?? [])[0] ?? null); } catch { /* no workflow */ } }
249
252
 
250
253
  switch (verb) {
254
+ case 'migrate': {
255
+ if (args.backing !== 'ztrack' || !args.document || !args['status-map']) throw new Error('migrate needs --backing ztrack --document <arcs.md> --status-map <json>');
256
+ if (backingOf(root, slug) !== 'sqlite') throw new Error('migration starts from a SQLite board');
257
+ const paths = backingPaths(root, slug);
258
+ const preparing = `${paths.ztrack}.preparing`;
259
+ if (existsSync(preparing)) throw new Error(`unfinished migration retained at ${preparing}; inspect it before retrying`);
260
+ const mapping = JSON.parse(readFileSync(args['status-map'], 'utf8'));
261
+ const source = openBoard(root, slug);
262
+ try {
263
+ const migrated = openZtrackBoard(preparing, { create: true, document: args.document, seed: { db: source.db, mapping }, init: layOut, root, slug, compatPath: paths.sqlite });
264
+ migrated.close();
265
+ } finally { source.close(); }
266
+ renameSync(`${preparing}.runtime.db`, `${paths.ztrack}.runtime.db`);
267
+ renameSync(preparing, paths.ztrack);
268
+ say(args, `Board migrated to ${args.document}; kanban.db is now a Hermes export`, { ok: true, backing: 'ztrack', document: args.document });
269
+ return 0;
270
+ }
251
271
  case 'init': {
252
272
  const backing = withBoard((b) => b.backing, true);
253
273
  const { profiles } = await laneState(root).catch(() => ({ profiles: [] }));
@@ -532,7 +552,7 @@ export async function runWorkflow(argv) {
532
552
  case 'unblock': {
533
553
  need(1, 'a card id');
534
554
  const results = [];
535
- for (const t of ids()) results.push([t, (await withBoardAsync((b) => engine.unblock(b, t, { reason: args.reason ?? null }))).to]);
555
+ for (const t of ids()) results.push([t, (await withBoardAsync((b) => engine.unblock(b, t, { reason: args.reason ?? null, on: args.on ?? null }))).to]);
536
556
  // Hermes records the reason as a comment on each card it unblocks
537
557
  if (args.reason) for (const [t] of results) await withBoardAsync((b) => engine.comment(b, t, callerName(), `UNBLOCK: ${args.reason}`));
538
558
  say(args, results.map(([t, s]) => `Unblocked ${t} → ${s}`).join('\n'), { ok: true, results }); return 0;
@@ -841,7 +861,7 @@ async function boardsVerb(root, rest, args) {
841
861
  case 'create': case 'new': {
842
862
  const slug = normalSlug(target);
843
863
  const meta = writeMeta(root, slug, { name: args.name, description: args.description, icon: args.icon, color: args.color, default_workdir: args['default-workdir'] });
844
- openBoard(root, slug, { create: true, backing: args.backing ?? null }).close();
864
+ openBoard(root, slug, { create: true, document: args.document ?? null, backing: args.backing ?? null }).close();
845
865
  if (args.switch) { mkdirSync(join(root, 'kanban'), { recursive: true }); writeFileSync(currentFile(root), `${slug}\n`); }
846
866
  say(args, `Board '${slug}' ready.\n Display name: ${meta.name}\n DB path: ${meta.db_path}${args.switch ? `\n Switched to '${slug}'.` : ''}`, meta);
847
867
  return 0;
@@ -268,7 +268,7 @@ function limitsOf(db, board, entry, task, session) {
268
268
  const status = statusOf(db, task);
269
269
  const scope = scopeOf(db, board, task, { params: entry.params, extra: { run: { surface: session?.surface ?? null, local: true } } });
270
270
  const out = {};
271
- for (const [name, expr] of Object.entries(runSpec(entry, status)?.limits ?? {})) {
271
+ for (const [name, expr] of Object.entries({ ...entry.workflow.run?.limits, ...runSpec(entry, status, task)?.limits })) {
272
272
  const v = evaluate(expr, scope);
273
273
  out[name] = v === null || v === undefined || v === '' ? null : Number(v);
274
274
  }
@@ -321,7 +321,7 @@ function startCards(board, fleet, lanes, settings, report, entry, effects, { onl
321
321
  const t = transitionsFor(workflow, status, d.start).find((x) => (!x.by?.length || x.by.includes('platform')) && holds(x.guard ?? null, scope));
322
322
  if (!t) return null;
323
323
  const target = t.target ? String(templateValue(t.target, scope)) : status;
324
- const spec = runSpec(entry, target);
324
+ const spec = runSpec(entry, target, scope.card);
325
325
  const profile = spec ? evaluate(workflow.roles?.[spec.role] ?? 'card.assignee', scope) : null;
326
326
  return { target, runSpec: spec, profile, lane: profile ? lanes[profile] ?? null : null };
327
327
  };
@@ -419,7 +419,7 @@ function startCards(board, fleet, lanes, settings, report, entry, effects, { onl
419
419
  export function launchRun(board, entry, id, lane, fleet, settings) {
420
420
  const task = getTaskNow(board, id);
421
421
  const status = tx(board, (db) => statusOf(db, task));
422
- const spec = runSpec(entry, status);
422
+ const spec = runSpec(entry, status, task);
423
423
  if (!spec) return null;
424
424
  const runId = task.current_run_id;
425
425
  const surface = lane.surface === 'pane' ? 'pane' : 'headless';
@@ -431,6 +431,11 @@ export function launchRun(board, entry, id, lane, fleet, settings) {
431
431
  // the run's session record knows its status, role and slot (a reviewer's slot is never the implementer's); the slot
432
432
  // is a template, so a step can ask for isolation by naming a new one
433
433
  const slot = String(templateValue(spec.session ?? 'worker', scope) ?? 'worker');
434
+ if (spec.role === 'reviewer') {
435
+ const worker = tx(board, (db) => lastSession(db, id, 'worker'));
436
+ const reviewer = tx(board, (db) => lastSession(db, id, slot));
437
+ if (slot === 'worker' || (worker?.session_id && worker.session_id === reviewer?.session_id)) throw new Error('a reviewer must have a different session from the implementer');
438
+ }
434
439
  const meta = { status, role: spec.role, slot };
435
440
  return surface === 'pane'
436
441
  ? launchPane(board, task, runId, lane, fleet, { input: prompt, slot, meta })
@@ -443,7 +448,7 @@ export async function startCard(board, id, { roles = ['manager'], lanes, setting
443
448
  const fleet = observeFleet();
444
449
  const moved = tx(board, (db) => fire(db, board, entry, id, entry.workflow.dispatch.start, { roles, payload: { lock: claimer() } }));
445
450
  const task = getTaskNow(board, id);
446
- const spec = runSpec(entry, moved.to);
451
+ const spec = runSpec(entry, moved.to, task);
447
452
  const profile = spec ? tx(board, (db) => evaluate(entry.workflow.roles?.[spec.role] ?? 'card.assignee', scopeOf(db, board, task, { params: entry.params }))) : null;
448
453
  const lane = profile ? lanes[profile] : null;
449
454
  if (spec && !lane) throw new Error(`${id} is ${moved.to}, run by ${spec.role} (${profile ?? 'nobody'}), which is no lane of this home`);
package/board/engine.mjs CHANGED
@@ -325,7 +325,8 @@ export async function act(board, id, name, { roles = null, payload = {}, extra =
325
325
  roles ??= callerRoles(db, id);
326
326
  const r = fire(db, board, entry, id, name, { roles, payload, extra });
327
327
  // the cards that wait on it, and its subtasks, see it change
328
- const around = [...childrenOf(db, id), ...all(db, 'SELECT task_id AS id FROM supercode_cards WHERE subtask_of = ?', id).map((x) => x.id)];
328
+ const related = all(db, 'SELECT task_id, blocked_by FROM supercode_cards').filter((c) => json(c.blocked_by, []).some((b) => b.id === id)).map((c) => c.task_id);
329
+ const around = [...related, ...childrenOf(db, id), ...all(db, 'SELECT task_id AS id FROM supercode_cards WHERE subtask_of = ?', id).map((x) => x.id)];
329
330
  for (const other of new Set(around)) settle(db, board, entry, other, r.effects);
330
331
  return r;
331
332
  });
@@ -347,7 +348,8 @@ export async function goto(board, id, status, { roles = null, payload = {} } = {
347
348
 
348
349
  /** The I/O a committed move asked for. */
349
350
  export async function runEffects(board, effects = []) {
350
- for (const e of effects) {
351
+ const pending = tx(board, (db) => db.prepare('SELECT id, effect FROM supercode_effects ORDER BY id').all());
352
+ for (const e of [...pending.map((p) => ({ ...json(p.effect, {}), durableId: p.id })), ...effects]) {
351
353
  if (e.kind === 'cleanup_workspace') cleanupWorkspace(board, e.id);
352
354
  else if (e.kind === 'invalidate_descendants') tx(board, (db) => invalidateDescendants(db, board, e.id));
353
355
  else if ((e.kind === 'run_ended' && e.outcome === 'reclaimed') || e.kind === 'stop_run') stopWorker(board, e.runId);
@@ -355,6 +357,7 @@ export async function runEffects(board, effects = []) {
355
357
  const { sessionEffect } = await import('./dispatch.mjs');
356
358
  await sessionEffect(board, e);
357
359
  }
360
+ if (e.durableId) tx(board, (db) => db.prepare('DELETE FROM supercode_effects WHERE id = ?').run(e.durableId));
358
361
  }
359
362
  }
360
363
 
@@ -414,7 +417,7 @@ export const complete = (board, id, { result = null, summary = null, metadata =
414
417
  // a block names the one thing that ends it: a card it waits on (`on`), or the message whose answer it waits for (`waiting_on`)
415
418
  export const block = (board, id, { reason = null, on = null, waitingOn = null, roles, runId = null } = {}) =>
416
419
  act(board, id, 'block', { roles, payload: { reason, on, waiting_on: waitingOn, run_id: runId } });
417
- export const unblock = (board, id, { reason = null, roles } = {}) => act(board, id, 'unblock', { roles, payload: { reason } });
420
+ export const unblock = (board, id, { reason = null, on = null, roles } = {}) => act(board, id, 'unblock', { roles, payload: { reason, on } });
418
421
  export const requestReview = (board, id, { summary = null, metadata = null, reviewer = null, force = false, expectedRunId = null, roles } = {}) =>
419
422
  act(board, id, 'request_review', { roles, payload: { summary, metadata, reviewer, force, run_id: expectedRunId } });
420
423
  export const requestChanges = (board, id, { reason = null, reviewer = null, roles } = {}) => act(board, id, 'request_changes', { roles, payload: { reason, reviewer } });
package/board/store.mjs CHANGED
@@ -14,6 +14,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync } from 'node:fs';
14
14
  import { hostname } from 'node:os';
15
15
  import { dirname, join } from 'node:path';
16
16
  import { DatabaseSync } from 'node:sqlite';
17
+ import { openZtrackBoard } from './ztrack.mjs';
17
18
  import { isFilesBoard, openFilesBoard } from './files.mjs';
18
19
 
19
20
  export const DEFAULT_BOARD = 'default';
@@ -67,7 +68,8 @@ CREATE INDEX IF NOT EXISTS idx_events_run ON task_events(run_id, id);
67
68
 
68
69
  // supercode's part of a card, beside Hermes's row: where its session runs, and the arc a subtask belongs to (a
69
70
  // subtask is worked by its arc's session and never started on its own).
70
- const OURS = `CREATE TABLE IF NOT EXISTS supercode_cards (task_id TEXT PRIMARY KEY, machine TEXT, subtask_of TEXT, stage TEXT);`;
71
+ const OURS = `CREATE TABLE IF NOT EXISTS supercode_cards (task_id TEXT PRIMARY KEY, machine TEXT, subtask_of TEXT, stage TEXT);
72
+ CREATE TABLE IF NOT EXISTS supercode_effects (id INTEGER PRIMARY KEY AUTOINCREMENT, effect TEXT NOT NULL);`;
71
73
  // `stage`: the card's workflow status where kanban.db stores another (a `reviewing` card is `running` to Hermes)
72
74
  // `since`: when the card entered its workflow status; `after_fired`: which of the status's `after` moves have run since
73
75
  const OURS_COLUMNS = { subtask_of: 'TEXT', stage: 'TEXT', since: 'INTEGER', after_fired: 'TEXT', blocked_by: 'TEXT', paused: 'TEXT', archived: 'INTEGER DEFAULT 0', every: 'TEXT', next_run_at: 'INTEGER' };
@@ -78,7 +80,7 @@ const OURS_COLUMNS = { subtask_of: 'TEXT', stage: 'TEXT', since: 'INTEGER', afte
78
80
  // board. `sqlite` is Hermes's own `kanban.db`, one SQLite file Hermes and supercode both write; `files` (`files.mjs`)
79
81
  // is a directory with one JSON file per card that only supercode writes. A board is whichever backing is on disk; a
80
82
  // new one is `sqlite` unless its creator names another (`init --backing files`, `boards create <slug> --backing files`).
81
- export const BACKINGS = ['sqlite', 'files'];
83
+ export const BACKINGS = ['sqlite', 'files', 'ztrack'];
82
84
 
83
85
  const boardDirOf = (root, slug) => (!slug || slug === DEFAULT_BOARD ? join(root, 'kanban') : join(root, 'kanban', 'boards', slug));
84
86
 
@@ -87,13 +89,15 @@ export function backingPaths(root, slug = DEFAULT_BOARD) {
87
89
  return {
88
90
  sqlite: !slug || slug === DEFAULT_BOARD ? join(root, 'kanban.db') : join(boardDirOf(root, slug), 'kanban.db'),
89
91
  files: join(boardDirOf(root, slug), 'board'),
92
+ ztrack: join(boardDirOf(root, slug), 'ztrack.json'),
90
93
  };
91
94
  }
92
95
 
93
96
  /** The backing that keeps board `slug` now; null when it does not exist. */
94
97
  export function backingOf(root, slug = DEFAULT_BOARD) {
95
98
  const at = backingPaths(root, slug);
96
- const have = BACKINGS.filter((b) => (b === 'files' ? isFilesBoard(at.files) : existsSync(at.sqlite)));
99
+ if (existsSync(at.ztrack)) return 'ztrack'; // kanban.db is this backing's projection
100
+ const have = BACKINGS.filter((b) => b !== 'ztrack').filter((b) => (b === 'files' ? isFilesBoard(at.files) : existsSync(at.sqlite)));
97
101
  if (have.length > 1) throw new Error(`board ${slug} in ${root} is kept twice (${have.map((b) => at[b]).join(' and ')}); one backing keeps a board`);
98
102
  return have[0] ?? null;
99
103
  }
@@ -126,7 +130,7 @@ export function listBoards(root) {
126
130
  }
127
131
 
128
132
  /** Hermes's tables on a fresh board, supercode's own table and columns on every one. */
129
- function layOut(db, { fresh }) {
133
+ export function layOut(db, { fresh }) {
130
134
  if (fresh) db.exec(SCHEMA);
131
135
  db.exec(OURS);
132
136
  const have = new Set(db.prepare('PRAGMA table_info(supercode_cards)').all().map((c) => c.name));
@@ -137,13 +141,14 @@ function layOut(db, { fresh }) {
137
141
  * Open (creating when `create`, in `backing`) board `slug`: `{ db, root, slug, path, backing, tx, close }`. `db` answers
138
142
  * SQLite's statement API (`prepare(sql).get|all|run`, `exec`) whatever the backing.
139
143
  */
140
- export function openBoard(root, slug = DEFAULT_BOARD, { create = false, backing = null } = {}) {
144
+ export function openBoard(root, slug = DEFAULT_BOARD, { create = false, backing = null, document = null } = {}) {
141
145
  const kept = backingOf(root, slug);
142
146
  if (!kept && !create) throw new Error(`no board ${slug} in ${root} (${boardPath(root, slug)})`);
143
147
  if (kept && backing && kept !== backing) throw new Error(`board ${slug} in ${root} is kept by the ${kept} backing, not ${backing}`);
144
148
  const use = kept ?? backing ?? 'sqlite';
145
149
  if (!BACKINGS.includes(use)) throw new Error(`a board's backing is ${BACKINGS.join(' | ')}, not ${use}`);
146
150
  const path = backingPaths(root, slug)[use];
151
+ if (use === 'ztrack') return { ...openZtrackBoard(path, { create, document, init: layOut, root, slug, compatPath: backingPaths(root, slug).sqlite }), root, slug, path, backing: use };
147
152
  if (use === 'files') return { ...openFilesBoard(path, { create, init: layOut }), root, slug, path, backing: use };
148
153
  if (!kept) mkdirSync(dirname(path), { recursive: true });
149
154
  const db = new DatabaseSync(path);
@@ -472,7 +472,7 @@ const ACTIONS = {
472
472
  }
473
473
  if (remove) {
474
474
  if (!String(reason ?? '').trim()) throw new Error('unblock must say what arrived');
475
- blocks = blocks.filter((b) => remove !== '*' && b.id !== remove);
475
+ blocks = blocks.filter((b) => remove === 'messages' ? b.kind !== 'message' : remove !== '*' && b.id !== remove);
476
476
  }
477
477
  cardField(ctx, 'blocked_by', JSON.stringify(blocks));
478
478
  },
@@ -480,7 +480,7 @@ const ACTIONS = {
480
480
  if (!['paused', 'archived'].includes(name)) throw new Error(`unknown flag ${name}`);
481
481
  if (name === 'paused') {
482
482
  const was = json(one(ctx.db, 'SELECT paused FROM supercode_cards WHERE task_id = ?', ctx.id)?.paused, null);
483
- if (!value && was && was.by !== actorOf() && !ctx.scope.sender?.includes('owner')) throw new Error('only the pauser or owner may resume this card');
483
+ if (!value && was && was.by !== actorOf() && !ctx.scope.sender?.includes('owner') && !(ctx.scope.sender?.includes('hermes') && ctx.scope.event?.hermes)) throw new Error('only the pauser or owner may resume this card');
484
484
  if (value && !String(reason ?? '').trim()) throw new Error('pause needs a reason');
485
485
  cardField(ctx, name, value ? JSON.stringify({ by: actorOf(), reason }) : null);
486
486
  } else cardField(ctx, name, Number(Boolean(value)));
@@ -490,8 +490,8 @@ const ACTIONS = {
490
490
  cardField(ctx, 'every', interval == null ? null : String(interval));
491
491
  cardField(ctx, 'next_run_at', interval == null ? null : now());
492
492
  },
493
- card_event(ctx, { name, payload = {} }) {
494
- const result = fire(ctx.db, ctx.board, ctx.entry, ctx.id, name, { roles: ['platform'], payload, extra: ctx.scope });
493
+ card_event(ctx, { name, payload = {}, as = ['platform'] }) {
494
+ const result = fire(ctx.db, ctx.board, ctx.entry, ctx.id, name, { roles: [].concat(as), payload, extra: { lanes: lanesIn(ctx.entry) } });
495
495
  ctx.effects.push(...result.effects);
496
496
  },
497
497
  record(ctx, args, scope) {
@@ -566,8 +566,9 @@ const ACTIONS = {
566
566
  run(ctx.db, "UPDATE task_runs SET status = 'reclaimed', outcome = 'reclaimed', summary = COALESCE(summary, 'invariant recovery on re-claim'), ended_at = ?, claim_lock = NULL, claim_expires = NULL, worker_pid = NULL WHERE id = ? AND ended_at IS NULL", t, task.current_run_id);
567
567
  }
568
568
  const expires = t + ttl;
569
+ role = runSpec(ctx.entry, ctx.to, scope.card)?.role ?? role;
569
570
  const profile = evaluate(ctx.entry.workflow.roles?.[role] ?? 'card.assignee', scope) ?? task.assignee;
570
- const slot = String(templateValue(runSpec(ctx.entry, ctx.to)?.session ?? 'worker', scope) ?? 'worker');
571
+ const slot = String(templateValue(runSpec(ctx.entry, ctx.to, scope.card)?.session ?? 'worker', scope) ?? 'worker');
571
572
  run(ctx.db, 'UPDATE tasks SET claim_lock = ?, claim_expires = ?, started_at = COALESCE(started_at, ?) WHERE id = ?', lock, expires, t, ctx.id);
572
573
  const r = run(ctx.db, "INSERT INTO task_runs (task_id, profile, step_key, status, claim_lock, claim_expires, max_runtime_seconds, started_at, metadata) VALUES (?, ?, ?, 'running', ?, ?, ?, ?, ?)",
573
574
  ctx.id, profile, ctx.to, lock, expires, task.max_runtime_seconds, t, JSON.stringify({ supercode: { role, status: ctx.to, slot, state: ctx.entry.workflow.run?.initial ?? null } }));
@@ -705,9 +706,10 @@ export function intervalSeconds(value) {
705
706
  return seconds;
706
707
  }
707
708
 
708
- export function runSpec(entry, status) {
709
+ export function runSpec(entry, status, card = null) {
709
710
  const st = entry.workflow.statuses[status];
710
- const spec = st?.run_role ? entry.workflow.run?.roles?.[st.run_role] : st?.run;
711
+ const name = card?.every && entry.workflow.run?.recurring_role ? entry.workflow.run.recurring_role : st?.run_role;
712
+ const spec = name ? entry.workflow.run?.roles?.[name] : st?.run;
711
713
  if (!spec?.prompt_file) return spec;
712
714
  const file = resolve(entry.root, spec.prompt_file);
713
715
  const rel = relative(entry.root, file);
@@ -760,12 +762,21 @@ function settleRelations(db, board, entry, id, effects) {
760
762
  const row = one(db, 'SELECT blocked_by FROM supercode_cards WHERE task_id = ?', id);
761
763
  const blocks = json(row?.blocked_by, []) ?? [];
762
764
  for (const block of blocks) {
765
+ if (!json(one(db, 'SELECT blocked_by FROM supercode_cards WHERE task_id = ?', id)?.blocked_by, []).some((b) => b.id === block.id)) continue;
763
766
  const dependency = block.kind === 'card' ? one(db, 'SELECT * FROM tasks WHERE id = ?', block.id) : null;
764
767
  const status = dependency ? statusOf(db, dependency) : null;
765
768
  const answer = block.kind === 'message' ? answerTo(block.id) : null;
766
769
  const policy = entry.workflow.relations.blocked_by;
767
770
  const name = answer ? policy.answered : status === 'done' ? policy.completed : status === 'cancelled' ? policy.cancelled : null;
768
771
  if (name) effects.push(...fire(db, board, entry, id, name, { roles: ['platform'], payload: { on: block.id, reason: answer ? `reply ${answer}` : `${block.id} ${status}` } }).effects);
772
+ else if (policy.aged && policy.age_seconds != null) {
773
+ const seconds = Number(evaluate(String(policy.age_seconds), { params: entry.params }));
774
+ if (seconds > 0 && now() - Number(block.notified_at ?? block.since ?? now()) >= seconds) {
775
+ const current = json(one(db, 'SELECT blocked_by FROM supercode_cards WHERE task_id = ?', id)?.blocked_by, []);
776
+ run(db, 'UPDATE supercode_cards SET blocked_by = ? WHERE task_id = ?', JSON.stringify(current.map((b) => b.id === block.id ? { ...b, notified_at: now() } : b)), id);
777
+ effects.push(...fire(db, board, entry, id, policy.aged, { roles: ['platform'], payload: { on: block.id, reason: block.reason } }).effects);
778
+ }
779
+ }
769
780
  }
770
781
  }
771
782
 
@@ -0,0 +1,366 @@
1
+ // The document is card state; SQLite here is only a runtime journal and Hermes projection.
2
+ // ADR 0001. All markdown interpretation belongs to ztrack's public codec and kanban preset.
3
+ import { createRequire } from 'node:module';
4
+ import { createHash, randomBytes } from 'node:crypto';
5
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
6
+ import { dirname, resolve } from 'node:path';
7
+ import { spawnSync } from 'node:child_process';
8
+ import { DatabaseSync } from 'node:sqlite';
9
+ import { actorOf, event, json, now, setActor } from './store.mjs';
10
+ import { fire, projectStatus, statusOf, workflowFor } from './workflow.mjs';
11
+
12
+ const require = createRequire(import.meta.url);
13
+ const hash = (text) => createHash('sha256').update(text).digest('hex');
14
+ const read = (path) => existsSync(path) ? readFileSync(path, 'utf8') : '';
15
+ const quote = (s) => `"${s.replaceAll('"', '""')}"`;
16
+ const nativeId = (id) => /^t-[a-f0-9]+$/.test(id) ? id.replace('t-', 't_') : id;
17
+ const RUNTIME = new Set(['claim_lock', 'claim_expires', 'worker_pid', 'last_heartbeat_at', 'current_run_id', 'consecutive_failures', 'last_failure_error', 'block_recurrences', 'session_id']);
18
+ const CARD_MAIN = new Set(['id', 'title', 'body', 'status', 'assignee', 'priority', 'workspace_kind', 'workspace_path', 'branch_name']);
19
+ const EPHEMERAL = new Set(['tasks', 'supercode_cards', 'task_links', 'sqlite_sequence']);
20
+ const atomic = (path, text) => { const tmp = `${path}.${process.pid}.tmp`; writeFileSync(tmp, text, { mode: 0o600 }); renameSync(tmp, path); };
21
+
22
+ export function openZtrackBoard(marker, { create = false, document = null, seed = null, init, root, slug, compatPath }) {
23
+ if (!existsSync(marker)) {
24
+ if (!create) throw new Error(`no ztrack board ${marker}`);
25
+ mkdirSync(dirname(marker), { recursive: true });
26
+ atomic(marker, JSON.stringify({ format: 1, document: resolve(document ?? resolve(dirname(marker), 'arcs.md')) }) + '\n');
27
+ }
28
+ const config = JSON.parse(read(marker));
29
+ const backing = new ZtrackBoard({ ...config, seed, marker, init, root, slug, compatPath });
30
+ return { db: backing.db, tx: (fn) => backing.tx(fn), close: () => backing.close(), document: backing.path };
31
+ }
32
+
33
+ class ZtrackBoard {
34
+ constructor({ document, seed, marker, init, root, slug, compatPath }) {
35
+ this.path = resolve(dirname(marker), document);
36
+ this.archive = `${this.path}.archive.md`;
37
+ this.lockPath = `${marker}.lock`;
38
+ this.seed = seed;
39
+ this.root = root; this.slug = slug; this.init = init; this.compatPath = compatPath;
40
+ this.codec = require('@volter/ztrack/board-document');
41
+ this.preset = require('@volter/ztrack/kanban-preset');
42
+ this.runtime = new DatabaseSync(`${marker}.runtime.db`);
43
+ this.runtime.exec('PRAGMA busy_timeout = 10000; CREATE TABLE IF NOT EXISTS state (key TEXT PRIMARY KEY, value TEXT NOT NULL)');
44
+ this.mem = null; this.active = false;
45
+ mkdirSync(dirname(this.path), { recursive: true });
46
+ const self = this;
47
+ this.db = {
48
+ prepare(sql) { return Object.fromEntries(['get', 'all', 'run', 'iterate'].map((method) => [method, (...args) => self.access(() => method === 'iterate' ? self.mem.prepare(sql).all(...args)[Symbol.iterator]() : self.mem.prepare(sql)[method](...args), method !== 'run')])); },
49
+ exec(sql) { return self.access(() => self.mem.exec(sql)); },
50
+ close() { self.close(); },
51
+ };
52
+ this.tx(() => {});
53
+ }
54
+ get(key, fallback = null) { return json(this.runtime.prepare('SELECT value FROM state WHERE key = ?').get(key)?.value, fallback); }
55
+ put(key, value) { this.runtime.prepare('INSERT INTO state VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value').run(key, JSON.stringify(value)); }
56
+ stamp() {
57
+ const fileStamp = (path) => { try { const s = statSync(path); return `${s.ino}:${s.size}:${s.mtimeMs}`; } catch { return 'missing'; } };
58
+ return [fileStamp(this.path), fileStamp(this.archive), fileStamp(this.compatPath), this.get('version', '')].join('|');
59
+ }
60
+ access(fn, reading = false) {
61
+ if (this.active || (reading && this.mem && this.loadedStamp === this.stamp())) return fn();
62
+ return this.tx(fn);
63
+ }
64
+ close() { this.mem?.close(); this.runtime.close(); }
65
+ board() { return { root: this.root, slug: this.slug, label: this.slug, db: this.db }; }
66
+ recover() {
67
+ const pending = this.get('pending');
68
+ if (!pending) return;
69
+ for (const file of pending.files) {
70
+ const current = hash(read(file.path));
71
+ if (current !== file.before && current !== hash(file.text)) throw new Error(`unfinished board write conflicts with an edit to ${file.path}; retained runtime journal`);
72
+ if (current === file.before) atomic(file.path, file.text);
73
+ }
74
+ this.put('snapshot', pending.snapshot);
75
+ this.runtime.prepare("DELETE FROM state WHERE key = 'pending'").run();
76
+ }
77
+ tx(fn) {
78
+ if (this.active) return fn(this.db);
79
+ let lock;
80
+ for (let n = 0; ; n++) {
81
+ try { lock = openSync(this.lockPath, 'wx', 0o600); writeFileSync(lock, String(process.pid)); break; }
82
+ catch (error) {
83
+ if (error.code !== 'EEXIST' || n >= 100) throw error;
84
+ const pid = Number(read(this.lockPath));
85
+ if (pid) { try { process.kill(pid, 0); } catch (e) { if (e.code === 'ESRCH') { unlinkSync(this.lockPath); continue; } } }
86
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
87
+ }
88
+ }
89
+ try { return this.transaction(fn); } finally { closeSync(lock); unlinkSync(this.lockPath); }
90
+ }
91
+ transaction(fn) {
92
+ for (let attempt = 0; attempt < 8; attempt++) {
93
+ this.runtime.exec('BEGIN IMMEDIATE'); this.active = true;
94
+ let committed = false;
95
+ try {
96
+ this.recover();
97
+ this.load();
98
+ if (this.seed) this.seedIntoMemory();
99
+ else this.lift();
100
+ const result = fn(this.db);
101
+ const documents = this.documents();
102
+ const files = [{ path: this.path, before: hash(this.text), text: documents.open }, { path: this.archive, before: hash(this.archiveText), text: documents.archived }];
103
+ if (files.some((f) => hash(read(f.path)) !== f.before)) { this.runtime.exec('ROLLBACK'); continue; }
104
+ const snapshot = this.snapshot();
105
+ // A durable intent precedes filesystem writes; recovery completes exactly this version.
106
+ this.put('pending', { files, snapshot });
107
+ this.runtime.exec('COMMIT'); committed = true;
108
+ // Detect an editor racing the journal commit before replacing either document.
109
+ if (files.some((f) => hash(read(f.path)) !== f.before)) {
110
+ this.runtime.exec('BEGIN IMMEDIATE'); this.runtime.prepare("DELETE FROM state WHERE key = 'pending'").run(); this.runtime.exec('COMMIT');
111
+ continue;
112
+ }
113
+ for (const f of files) if (hash(f.text) !== f.before) atomic(f.path, f.text);
114
+ this.runtime.exec('BEGIN IMMEDIATE');
115
+ this.put('snapshot', snapshot); this.runtime.prepare("DELETE FROM state WHERE key = 'pending'").run();
116
+ this.export();
117
+ this.put('version', randomBytes(8).toString('hex'));
118
+ this.runtime.exec('COMMIT');
119
+ this.loadedStamp = this.stamp();
120
+ this.seed = null;
121
+ return result;
122
+ } catch (error) {
123
+ try { this.runtime.exec('ROLLBACK'); } catch { /* intent already durable */ }
124
+ if (committed) error.message += ' (the runtime journal retains any unfinished document write)';
125
+ throw error;
126
+ } finally { this.active = false; }
127
+ }
128
+ throw new Error('board document kept changing during eight version-checked retries; no edit was overwritten');
129
+ }
130
+ seedIntoMemory() {
131
+ const source = this.seed;
132
+ for (const table of this.mem.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all()) this.mem.exec(`DELETE FROM ${quote(table.name)}`);
133
+ for (const table of source.db.prepare("SELECT name, sql FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all()) {
134
+ if (!this.mem.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(table.name)) this.mem.exec(table.sql);
135
+ for (const row of source.db.prepare(`SELECT * FROM ${quote(table.name)}`).all()) this.insert(table.name, row);
136
+ }
137
+ for (const task of this.mem.prepare('SELECT * FROM tasks').all()) {
138
+ const old = this.mem.prepare('SELECT * FROM supercode_cards WHERE task_id = ?').get(task.id) ?? {};
139
+ if (old.subtask_of) { const key = /^([a-z]+\d+)\s/.exec(task.title)?.[1]; if (key) this.taskKeys.set(task.id, `${old.subtask_of}:${key}`); }
140
+ const status = old.stage ?? task.status;
141
+ const mapped = source.mapping[status];
142
+ if (!mapped) throw new Error(`migration needs an explicit mapping for ${status} (${task.id})`);
143
+ const stage = typeof mapped === 'string' ? mapped : mapped.status;
144
+ const parents = this.mem.prepare('SELECT parent_id FROM task_links WHERE child_id = ?').all(task.id).map((p) => ({ id: p.parent_id, kind: 'card' }));
145
+ const last = this.mem.prepare("SELECT kind, payload FROM task_events WHERE task_id = ? AND kind IN ('blocked','unblocked') ORDER BY id DESC LIMIT 1").get(task.id);
146
+ const wait = last?.kind === 'blocked' ? json(last.payload, {})?.waiting_on : null;
147
+ if (wait) parents.push({ id: wait, kind: 'message' });
148
+ this.insert('supercode_cards', { ...old, task_id: task.id, stage, archived: Number(Boolean(mapped.archived) || stage === 'cancelled'), blocked_by: JSON.stringify(parents),
149
+ paused: mapped.paused ? JSON.stringify({ by: 'migration', reason: `migrated ${status}: ${json(last?.payload, {})?.reason ?? 'held by previous workflow'}` }) : null });
150
+ this.mem.prepare('UPDATE tasks SET status = ? WHERE id = ?').run(stage, task.id);
151
+ if (task.current_run_id && ['done','cancelled'].includes(stage)) {
152
+ this.mem.prepare("UPDATE task_runs SET ended_at = COALESCE(ended_at, ?), status = 'reclaimed', outcome = COALESCE(outcome, 'reclaimed') WHERE id = ?").run(now(), task.current_run_id);
153
+ this.mem.prepare('UPDATE tasks SET current_run_id = NULL, claim_lock = NULL, claim_expires = NULL WHERE id = ?').run(task.id);
154
+ } else if (task.current_run_id) {
155
+ const run = this.mem.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(task.current_run_id);
156
+ const meta = json(run?.metadata, {}) ?? {}; meta.supercode = { ...meta.supercode, state: 'active' };
157
+ this.mem.prepare('UPDATE task_runs SET metadata = ? WHERE id = ?').run(JSON.stringify(meta), task.current_run_id);
158
+ }
159
+ }
160
+ this.put('hermes_cursor', Number(source.db.prepare('SELECT COALESCE(MAX(id),0) AS id FROM task_events').get().id));
161
+ }
162
+ insert(table, row) {
163
+ const columns = new Set(this.mem.prepare(`PRAGMA table_info(${quote(table)})`).all().map((c) => c.name));
164
+ const keys = Object.keys(row).filter((k) => columns.has(k));
165
+ if (!keys.length) return;
166
+ this.mem.prepare(`INSERT OR REPLACE INTO ${quote(table)} (${keys.map(quote).join(',')}) VALUES (${keys.map(() => '?').join(',')})`).run(...keys.map((k) => row[k] ?? null));
167
+ }
168
+ load() {
169
+ this.text = read(this.path); this.archiveText = read(this.archive);
170
+ this.mem?.close(); this.mem = new DatabaseSync(':memory:'); this.init(this.mem, { fresh: true });
171
+ this.saved = this.get('snapshot', { tables: {}, runtime: {}, clocks: {}, fingerprints: {} });
172
+ for (const item of Object.values(this.saved.tables ?? {})) {
173
+ if (item.sql) this.mem.exec(item.sql.replace('CREATE TABLE ', 'CREATE TABLE IF NOT EXISTS '));
174
+ for (const row of item.rows ?? []) this.insert(item.name, row);
175
+ }
176
+ const records = [...this.codec.parseBoardDocument(this.text, this.path), ...this.codec.parseBoardDocument(this.archiveText, this.archive)];
177
+ const parsed = this.preset.KanbanRootSchema.parse(this.preset.parseKanban(records));
178
+ this.originals = new Map(); this.fileIds = new Map(); this.taskKeys = new Map();
179
+ for (const card of parsed.issues) {
180
+ const id = nativeId(card.id);
181
+ if (this.originals.has(id)) throw new Error(`duplicate board card ${id}`);
182
+ this.originals.set(id, card); this.fileIds.set(id, card.id);
183
+ this.loadCard(id, card);
184
+ for (const task of card.acceptanceCriteria) {
185
+ const tid = task.metadata?.id ?? this.saved.taskIds?.[`${id}:${task.id}`] ?? `t_${randomBytes(4).toString('hex')}`;
186
+ this.taskKeys.set(tid, `${id}:${task.id}`);
187
+ const body = task.metadata?.body ?? '';
188
+ const taskCard = { ...task.metadata?.card, id: tid, title: `${task.id} ${task.text}`, body,
189
+ status: task.status === 'passed' ? 'done' : (task.metadata?.status === 'done' ? 'todo' : task.metadata?.status ?? 'todo'),
190
+ metadata: task.metadata?.properties ?? {}, assignee: task.metadata?.assignee ?? card.assignee,
191
+ machine: task.metadata?.machine ?? card.machine, acceptanceCriteria: [],
192
+ acceptance: task.lines ?? [], relations: (task.blockedBy ?? []).map((b) => ({ type: 'blocked-by', issueId: b.ac ? `${nativeId(b.issue)}:${b.ac}` : nativeId(b.issue) })) };
193
+ if (task.waitingOn) taskCard.relations.push({ type: 'blocked-by', issueId: task.waitingOn });
194
+ this.loadCard(tid, taskCard, id);
195
+ }
196
+ }
197
+ // Resolve local task refs only after all subtask identities are available.
198
+ const taskIds = new Map([...this.taskKeys].map(([id, key]) => [key, id]));
199
+ for (const row of this.mem.prepare('SELECT task_id, blocked_by FROM supercode_cards').all()) {
200
+ const blocks = json(row.blocked_by, []).map((b) => ({ ...b, id: taskIds.get(b.id) ?? b.id }));
201
+ this.mem.prepare('UPDATE supercode_cards SET blocked_by = ? WHERE task_id = ?').run(JSON.stringify(blocks), row.task_id);
202
+ for (const b of blocks.filter((b) => b.kind === 'card')) this.insert('task_links', { parent_id: b.id, child_id: row.task_id });
203
+ }
204
+ this.documentEvents();
205
+ }
206
+ loadCard(id, card, parent = null) {
207
+ const meta = card.metadata ?? {};
208
+ const stored = this.preset.serializeKanbanCard({ ...card, acceptanceCriteria: [], relations: [], metadata: undefined, paused: undefined, every: undefined, workspace: undefined, branch: undefined, machine: undefined, priority: undefined });
209
+ const workspace = /^(scratch|dir|worktree)(?::(.*))?$/.exec(card.workspace ?? 'scratch');
210
+ this.insert('tasks', { ...meta, ...this.saved.runtime?.[id], id, title: card.title, body: stored.body,
211
+ status: card.status, assignee: card.assignee ?? null, created_at: meta.created_at ?? now(), priority: card.priority ?? 0,
212
+ workspace_kind: workspace?.[1] ?? 'scratch', workspace_path: workspace?.[2] ?? null, branch_name: card.branch ?? null });
213
+ this.insert('supercode_cards', { ...this.saved.clocks?.[id], task_id: id, stage: card.status, machine: card.machine ?? null, subtask_of: parent,
214
+ blocked_by: JSON.stringify((card.relations ?? []).map((r) => ({ ...(meta.blocked_by ?? []).find((b) => b.id === nativeId(r.issueId)), id: nativeId(r.issueId), kind: /^[ma]-/.test(r.issueId) ? 'message' : 'card' }))),
215
+ paused: card.paused ? JSON.stringify(card.paused) : null, archived: meta.archived ? 1 : 0, every: card.every ?? null });
216
+ }
217
+ fingerprints() {
218
+ return Object.fromEntries(this.mem.prepare('SELECT * FROM tasks').all().map((t) => {
219
+ const c = this.mem.prepare('SELECT * FROM supercode_cards WHERE task_id = ?').get(t.id) ?? {};
220
+ return [t.id, { hash: hash(JSON.stringify([t.title, t.body, c.stage, t.assignee, c.machine, c.blocked_by, c.paused, c.every])), title: hash(t.title), body: hash(t.body ?? ''), status: c.stage, assignee: t.assignee, machine: c.machine, parent: c.subtask_of }];
221
+ }));
222
+ }
223
+ documentEvents() {
224
+ const previous = this.saved.fingerprints ?? {};
225
+ const current = this.fingerprints();
226
+ if (!Object.keys(previous).length) return;
227
+ const by = this.committer();
228
+ for (const [id, value] of Object.entries(current)) {
229
+ const old = previous[id];
230
+ if (old?.hash === value.hash) continue;
231
+ let entry; try { entry = workflowFor(this.root); } catch {}
232
+ if (old && entry?.workflow.verbs?.document_changed) {
233
+ const change = fire(this.mem, this.board(), entry, id, 'document_changed', { roles: ['platform'], payload: { previous_status: old.status, by, source: 'document' } });
234
+ for (const effect of change.effects) this.mem.prepare('INSERT INTO supercode_effects (effect) VALUES (?)').run(JSON.stringify(effect));
235
+ }
236
+ const kind = !old ? 'created' : old.status !== value.status ? 'status' : old.assignee !== value.assignee ? 'assigned' : 'specified';
237
+ event(this.mem, id, kind, { by, source: 'document', status: value.status, assignee: value.assignee, author: by });
238
+ if (!old && value.parent) event(this.mem, value.parent, 'subtask_added', { by, subtask: id, title: this.mem.prepare('SELECT title FROM tasks WHERE id = ?').get(id).title });
239
+ }
240
+ for (const id of Object.keys(previous)) if (!current[id]) {
241
+ if (this.saved.runtime?.[id]?.current_run_id) throw new Error(`document removed ${id} with a live run; cancel it through its workflow first`);
242
+ event(this.mem, id, 'removed', { by, source: 'document' });
243
+ }
244
+ }
245
+ committer() {
246
+ const r = spawnSync('git', ['-C', dirname(this.path), 'log', '-1', '--format=%an <%ae>', '--', this.path], { encoding: 'utf8', timeout: 5000 });
247
+ return r.status === 0 && r.stdout.trim() ? `git:${r.stdout.trim()}` : 'document-editor';
248
+ }
249
+ documents() {
250
+ const rows = this.mem.prepare('SELECT * FROM tasks ORDER BY created_at, id').all();
251
+ const states = new Map(this.mem.prepare('SELECT * FROM supercode_cards').all().map((c) => [c.task_id, c]));
252
+ const byId = new Map(rows.map((r) => [r.id, r]));
253
+ const cards = new Map();
254
+ for (const row of rows.filter((r) => !states.get(r.id)?.subtask_of)) {
255
+ const c = states.get(row.id) ?? {};
256
+ const parsed = this.preset.KanbanRootSchema.parse(this.preset.parseKanban([{ id: this.fileIds.get(row.id) ?? row.id, title: row.title, status: c.stage ?? row.status, assignee: row.assignee || undefined, body: row.body ?? '' }])).issues[0];
257
+ const metadata = Object.fromEntries(Object.entries(row).filter(([k, v]) => !RUNTIME.has(k) && !CARD_MAIN.has(k) && v != null));
258
+ if (c.archived) metadata.archived = true;
259
+ if (json(c.blocked_by, []).length) metadata.blocked_by = json(c.blocked_by, []);
260
+ cards.set(row.id, { ...parsed, metadata, priority: row.priority ?? 0, workspace: row.workspace_kind + (row.workspace_path ? `:${row.workspace_path}` : ''),
261
+ branch: row.branch_name || undefined, machine: c.machine || undefined, paused: json(c.paused, null) ?? undefined, every: c.every || undefined,
262
+ relations: json(c.blocked_by, []).map((b) => ({ type: 'blocked-by', issueId: this.fileIds.get(b.id) ?? b.id })), acceptanceCriteria: [] });
263
+ }
264
+ const occupied = new Map();
265
+ for (const row of rows) {
266
+ const parent = states.get(row.id)?.subtask_of;
267
+ if (!parent) continue;
268
+ const key = this.taskKeys.get(row.id)?.split(':').at(-1) ?? /^([a-z]+\d+)\s/.exec(row.title)?.[1];
269
+ if (key) { const set = occupied.get(parent) ?? new Set(); set.add(key); occupied.set(parent, set); }
270
+ }
271
+ const renderedKeys = new Set();
272
+ for (const row of rows.filter((r) => states.get(r.id)?.subtask_of)) {
273
+ const c = states.get(row.id); const parent = cards.get(c.subtask_of);
274
+ if (!parent) throw new Error(`subtask ${row.id} has no arc ${c.subtask_of}`);
275
+ const taken = occupied.get(c.subtask_of) ?? new Set(); occupied.set(c.subtask_of, taken);
276
+ const match = /^([a-z]+\d+)\s+(.+)$/.exec(row.title);
277
+ let key = this.taskKeys.get(row.id)?.split(':').at(-1) ?? match?.[1];
278
+ if (!key || renderedKeys.has(`${c.subtask_of}:${key}`)) { let n = 1; while (taken.has(`c${n}`)) n++; key = `c${n}`; }
279
+ taken.add(key); renderedKeys.add(`${c.subtask_of}:${key}`); this.taskKeys.set(row.id, `${c.subtask_of}:${key}`);
280
+ const parsed = this.preset.KanbanRootSchema.parse(this.preset.parseKanban([{ id: row.id, title: row.title, status: c.stage ?? row.status, body: row.body ?? '' }])).issues[0];
281
+ const originalTask = this.originals.get(c.subtask_of)?.acceptanceCriteria.find((t) => t.id === key);
282
+ parent.acceptanceCriteria.push({ sources: originalTask?.sources, id: key, status: ['done', 'cancelled'].includes(c.stage ?? row.status) ? 'passed' : 'pending', evidence: [], text: match?.[2] ?? row.title,
283
+ metadata: { id: row.id, status: c.stage ?? row.status, body: parsed.body, assignee: row.assignee, machine: c.machine,
284
+ properties: Object.fromEntries(Object.entries(row).filter(([k,v]) => !RUNTIME.has(k) && !CARD_MAIN.has(k) && v != null)),
285
+ card: { paused: json(c.paused, null) ?? undefined, every: c.every || undefined } }, lines: parsed.acceptance,
286
+ blockedBy: json(c.blocked_by, []).map((b) => ({ issue: b.id })) });
287
+ }
288
+ const open = [], archived = [];
289
+ for (const [id, card] of cards) {
290
+ const serialized = this.preset.serializeKanbanCard(card);
291
+ const record = { id: card.id, title: card.title, status: card.status, ...(card.assignee ? { assignee: card.assignee } : {}), body: serialized.body };
292
+ (states.get(id)?.archived || ['done', 'cancelled'].includes(card.status) ? archived : open).push(record);
293
+ }
294
+ return { open: this.codec.renderBoardDocument(this.text, open, this.path), archived: this.codec.renderBoardDocument(this.archiveText, archived, this.archive) };
295
+ }
296
+ snapshot() {
297
+ const tables = {};
298
+ for (const table of this.mem.prepare("SELECT name, sql FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all()) {
299
+ if (!EPHEMERAL.has(table.name)) tables[table.name] = { ...table, rows: this.mem.prepare(`SELECT * FROM ${quote(table.name)}`).all() };
300
+ }
301
+ const runtime = Object.fromEntries(this.mem.prepare('SELECT * FROM tasks').all().map((t) => [t.id, Object.fromEntries(Object.entries(t).filter(([k]) => RUNTIME.has(k)))]));
302
+ const clocks = Object.fromEntries(this.mem.prepare('SELECT * FROM supercode_cards').all().map((c) => [c.task_id, { since: c.since, after_fired: c.after_fired, next_run_at: c.next_run_at }]));
303
+ return { tables, runtime, clocks, fingerprints: this.fingerprints(), taskIds: Object.fromEntries([...this.taskKeys].map(([id, key]) => [key, id])) };
304
+ }
305
+ lift() {
306
+ if (!existsSync(this.compatPath)) return;
307
+ let entry; try { entry = workflowFor(this.root); } catch { return; }
308
+ const compat = new DatabaseSync(this.compatPath);
309
+ try {
310
+ const cursor = this.get('hermes_cursor', 0);
311
+ const rows = compat.prepare('SELECT * FROM task_events WHERE id > ? ORDER BY id').all(cursor);
312
+ const before = actorOf();
313
+ try {
314
+ for (const row of rows) {
315
+ const payload = json(row.payload, {}) ?? {};
316
+ if (payload.__supercode_export) continue;
317
+ const task = this.mem.prepare('SELECT * FROM tasks WHERE id = ?').get(row.task_id);
318
+ if (!task) continue;
319
+ const roles = ['manager', 'reviewer', 'platform', 'hermes'];
320
+ setActor(payload.by ?? 'hermes');
321
+ let name = null; let words = { ...payload, reason: payload.reason ?? 'unblocked in Hermes' };
322
+ if (row.kind === 'unblocked') { name = 'unblock'; words = { ...words, reason: 'unblocked in Hermes', hermes: true }; }
323
+ else if (row.kind === 'completed') { name = 'complete'; words.summary = payload.summary ?? task.result; }
324
+ else if (row.kind === 'blocked' || row.kind === 'dependency_wait') { name = 'block'; words.on = payload.on ?? payload.parent ?? null; words.hermes = true; }
325
+ else if (row.kind === 'archived' && statusOf(this.mem, task) !== 'done') name = 'cancel';
326
+ if (row.kind === 'unblocked' && entry.workflow.verbs?.hermes_unpause) {
327
+ const resumed = fire(this.mem, this.board(), entry, task.id, 'hermes_unpause', { roles, payload: { hermes: true, reason: 'unblocked in Hermes' } });
328
+ for (const effect of resumed.effects) this.mem.prepare('INSERT INTO supercode_effects (effect) VALUES (?)').run(JSON.stringify(effect));
329
+ }
330
+ if (name) {
331
+ const result = fire(this.mem, this.board(), entry, task.id, name, { roles, payload: words });
332
+ for (const effect of result.effects) this.mem.prepare('INSERT INTO supercode_effects (effect) VALUES (?)').run(JSON.stringify(effect));
333
+ }
334
+ event(this.mem, task.id, 'hermes_lifted', { by: 'hermes', source_event: row.id, verb: name, kind: row.kind });
335
+ }
336
+ } finally { setActor(before); }
337
+ if (rows.length) this.put('hermes_cursor', rows.at(-1).id);
338
+ } finally { compat.close(); }
339
+ }
340
+ export() {
341
+ let entry; try { entry = workflowFor(this.root); } catch { return; }
342
+ for (const { id } of this.mem.prepare('SELECT id FROM tasks').all()) projectStatus(this.mem, this.board(), entry, id);
343
+ const compat = new DatabaseSync(this.compatPath);
344
+ compat.exec('PRAGMA busy_timeout = 10000');
345
+ try {
346
+ compat.exec('BEGIN IMMEDIATE'); this.init(compat, { fresh: true });
347
+ // Every import is event-based. Existing unconsumed acts must never be erased by an export race.
348
+ const cursor = this.get('hermes_cursor', 0);
349
+ const unseen = compat.prepare('SELECT payload FROM task_events WHERE id > ?').all(cursor).some((r) => !json(r.payload, {})?.__supercode_export);
350
+ if (unseen) throw new Error('Hermes wrote during board export; retained its events for the next read');
351
+ const tables = this.mem.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all();
352
+ for (const { name } of tables) {
353
+ if (!compat.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name)) continue;
354
+ compat.exec(`DELETE FROM ${quote(name)}`);
355
+ for (const source of this.mem.prepare(`SELECT * FROM ${quote(name)}`).all()) {
356
+ const row = { ...source };
357
+ if (name === 'task_events') row.payload = JSON.stringify({ ...json(row.payload, {}), __supercode_export: true });
358
+ const keys = Object.keys(row); compat.prepare(`INSERT INTO ${quote(name)} (${keys.map(quote)}) VALUES (${keys.map(() => '?')})`).run(...keys.map((k) => row[k]));
359
+ }
360
+ }
361
+ compat.exec('COMMIT');
362
+ this.put('hermes_cursor', Number(compat.prepare('SELECT COALESCE(MAX(id),0) AS id FROM task_events').get().id));
363
+ } catch (error) { try { compat.exec('ROLLBACK'); } catch {} throw error; }
364
+ finally { compat.close(); }
365
+ }
366
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-orchestrator",
3
- "version": "0.4.76",
3
+ "version": "0.4.78",
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": {
@@ -25,7 +25,8 @@
25
25
  },
26
26
  "dependencies": {
27
27
  "yaml": "^2.9.0",
28
- "@volter/supercode-harness-sdk": "^0.3.41"
28
+ "@volter/supercode-harness-sdk": "^0.3.41",
29
+ "@volter/ztrack": "^2.4.14"
29
30
  },
30
31
  "devDependencies": {
31
32
  "@chat-adapter/state-memory": "4.39.0",