@volter/supercode-orchestrator 0.5.204 → 0.5.206

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.
Files changed (51) hide show
  1. package/README.md +138 -4
  2. package/activation.mjs +93 -2
  3. package/adapters/rh2.mjs +7 -2
  4. package/agents.mjs +24 -2
  5. package/apply/doors.mjs +25 -2
  6. package/bin/agent-channel.mjs +41 -22
  7. package/bin/connection-runner.mjs +141 -0
  8. package/bin/orchestrator.mjs +11 -2
  9. package/bin/runtime.mjs +16 -9
  10. package/board/args.mjs +3 -2
  11. package/board/attachment-read.mjs +77 -0
  12. package/board/call.mjs +4 -2
  13. package/board/capacity.mjs +46 -9
  14. package/board/ci.mjs +157 -53
  15. package/board/cli.mjs +225 -87
  16. package/board/client.mjs +18 -2
  17. package/board/custody.mjs +109 -0
  18. package/board/dispatch.mjs +297 -75
  19. package/board/doors.mjs +47 -17
  20. package/board/engine.mjs +78 -24
  21. package/board/fleet-listeners.mjs +268 -0
  22. package/board/fleet-observer.mjs +79 -0
  23. package/board/fleet-targets.mjs +82 -0
  24. package/board/incidents.mjs +7 -0
  25. package/board/listing.mjs +5 -0
  26. package/board/machine-fleet.mjs +7 -0
  27. package/board/notice-receipts.mjs +67 -0
  28. package/board/owner-door-worker.mjs +61 -0
  29. package/board/owner-door.mjs +46 -0
  30. package/board/owner-health.mjs +8 -2
  31. package/board/published.mjs +2 -2
  32. package/board/record-view.mjs +2 -0
  33. package/board/store.mjs +55 -3
  34. package/board/tools.mjs +11 -4
  35. package/board/workflow.mjs +91 -28
  36. package/board/ztrack.mjs +87 -21
  37. package/board-input.d.ts +2 -0
  38. package/board-input.mjs +16 -0
  39. package/connection.mjs +153 -0
  40. package/index.d.ts +7 -5
  41. package/index.mjs +1 -1
  42. package/installed-apps.mjs +31 -20
  43. package/loop.mjs +91 -36
  44. package/mcp/bridge.mjs +12 -0
  45. package/mcp/hermes-kanban-tools.json +22 -14
  46. package/mcp/mount.mjs +10 -9
  47. package/ontology.d.ts +37 -0
  48. package/operator.mjs +72 -4
  49. package/orchestration.mjs +7 -2
  50. package/package.json +9 -5
  51. package/reducer.mjs +74 -5
package/board/tools.mjs CHANGED
@@ -7,7 +7,9 @@ import { mkdirSync, statSync, writeFileSync } from 'node:fs';
7
7
  import { basename, join } from 'node:path';
8
8
  import { DEFAULT_BOARD, actorOf, event, json, openBoard, setActor, tx } from './store.mjs';
9
9
  import * as engine from './engine.mjs';
10
+ import { assertSourceReceipt } from './ci.mjs';
10
11
  import { completionOwnerOf, isLatestReviewer, loadWorkflow } from './workflow.mjs';
12
+ import { attachmentRecord, readAttachment } from './attachment-read.mjs';
11
13
 
12
14
  const ok = (fields = {}) => JSON.stringify({ ok: true, ...fields });
13
15
  const error = (message) => JSON.stringify({ error: message });
@@ -30,6 +32,7 @@ const ORCHESTRATOR_ONLY = new Set(['kanban_list', 'kanban_unblock']);
30
32
 
31
33
  /** One board tool call. `root` is the home; `env` the worker's task environment (`HERMES_KANBAN_*`). */
32
34
  export async function boardTool(root, name, args = {}, env = {}) {
35
+ try { assertSourceReceipt(args); } catch (e) { return error(e.message); }
33
36
  const own = env.HERMES_KANBAN_TASK || null;
34
37
  const runId = env.HERMES_KANBAN_RUN_ID ? Number(env.HERMES_KANBAN_RUN_ID) : null;
35
38
  const author = env.HERMES_PROFILE || 'worker';
@@ -112,7 +115,7 @@ export async function boardTool(root, name, args = {}, env = {}) {
112
115
  }
113
116
  }
114
117
  if (env.HERMES_SESSION_ID && own === tid) metadata = { ...(metadata ?? {}), worker_session_id: env.HERMES_SESSION_ID };
115
- await engine.complete(board, tid, { summary, result, metadata, commit: args.commit ?? null, ci_run: args.ci_run ?? null, branch: args.branch ?? null, pr: args.pr ?? null, pr_head: args.pr_head ?? null, reviewed_sha: args.reviewed_sha ?? null });
118
+ await engine.complete(board, tid, { summary, result, metadata, commit: args.commit ?? null, branch: args.branch ?? null, pr: args.pr ?? null, pr_head: args.pr_head ?? null, reviewed_sha: args.reviewed_sha ?? null });
116
119
  return ok({ task_id: tid, status: 'done' });
117
120
  }
118
121
  case 'kanban_block': {
@@ -122,12 +125,12 @@ export async function boardTool(root, name, args = {}, env = {}) {
122
125
  }
123
126
  case 'kanban_request_state': {
124
127
  const stale = ownRun(); if (stale) return error(stale);
125
- await engine.requestStateMany(board, targets, { state: args.state, reason: args.reason, on: args.on ?? null, waiting_on: args.waiting_on ?? null, evidence: args.evidence ?? null, commit: args.commit ?? null, ci_run: args.ci_run ?? null, branch: args.branch ?? null, pr: args.pr ?? null, pr_head: args.pr_head ?? null, reviewed_sha: args.reviewed_sha ?? null, deployed_sha: args.deployed_sha ?? null });
128
+ await engine.requestStateMany(board, targets, { state: args.state, reason: args.reason, on: args.on ?? null, waiting_on: args.waiting_on ?? null, evidence: args.evidence ?? null, commit: args.commit ?? null, branch: args.branch ?? null, pr: args.pr ?? null, pr_head: args.pr_head ?? null, reviewed_sha: args.reviewed_sha ?? null });
126
129
  return ok({ task_id: tid, task_ids: targets, requested: args.state, changed: false });
127
130
  }
128
131
  case 'kanban_request_review': {
129
132
  const stale = ownRun(); if (stale) return error(stale);
130
- await engine.requestReview(board, tid, { summary: args.summary ? redact(String(args.summary)) : null, metadata: args.metadata ? redact(args.metadata) : null, commit: args.commit ?? null, ci_run: args.ci_run ?? null, branch: args.branch ?? null, pr: args.pr ?? null, pr_head: args.pr_head ?? args.commit ?? null, reviewer: args.reviewer ?? null, expectedRunId: own === tid && runId ? runId : null });
133
+ await engine.requestReview(board, tid, { summary: args.summary ? redact(String(args.summary)) : null, metadata: args.metadata ? redact(args.metadata) : null, commit: args.commit ?? null, branch: args.branch ?? null, pr: args.pr ?? null, pr_head: args.pr_head ?? args.commit ?? null, reviewer: args.reviewer ?? null, expectedRunId: own === tid && runId ? runId : null });
131
134
  return ok({ task_id: tid, status: 'review' });
132
135
  }
133
136
  case 'kanban_request_changes': {
@@ -172,11 +175,15 @@ export async function boardTool(root, name, args = {}, env = {}) {
172
175
  return ok({ task_id: tid, attachment_id: id, path });
173
176
  }
174
177
  case 'kanban_attachments':
178
+ if (args.attachment_id !== undefined) {
179
+ const row = tx(board, (db) => attachmentRecord(db, tid, args.attachment_id));
180
+ return ok(await readAttachment(root, slug, row, { offset: args.offset, length: args.length, version: args.version }));
181
+ }
175
182
  return ok({ task_id: tid, attachments: tx(board, (db) => engine.attachments(db, tid)).map((a) => ({ id: a.id, filename: a.filename, size: a.size, content_type: a.content_type, path: a.stored_path })) });
176
183
  case 'kanban_create': {
177
184
  const workspace = args.workspace_kind ? `${args.workspace_kind}${args.workspace_path ? `:${args.workspace_path}` : ''}` : undefined;
178
185
  const made = engine.createCard(board, await engine.prepareCard(board, {
179
- title: args.title, body: args.body ? redact(String(args.body)) : null, assignee: args.assignee ?? null, parents: [args.parents ?? []].flat(), tenant: args.tenant ?? null,
186
+ title: args.title, body: args.body ? redact(String(args.body)) : null, assignee: args.assignee ?? null, reviewer: args.reviewer ?? null, parents: [args.parents ?? []].flat(), tenant: args.tenant ?? null,
180
187
  priority: args.priority ?? 0, workspace, project: args.project ?? null, triage: Boolean(args.triage), idempotencyKey: args.idempotency_key ?? null,
181
188
  maxRuntime: args.max_runtime_seconds ?? null, initialStatus: args.initial_status ?? null, skills: args.skills ?? null, model: args.model ?? null, provider: args.provider ?? null,
182
189
  goal: args.goal_mode ? { maxTurns: args.goal_max_turns ?? null } : null, createdBy: author,
@@ -5,6 +5,7 @@
5
5
  // the engine's primitives (the rows Hermes's tables hold); the I/O an action asks for (stopping a worker, closing a
6
6
  // pane, sending a session a message, cleaning a workspace) is answered as effects the caller runs after commit.
7
7
  import { current } from './call.mjs';
8
+ import { detachedCard, detachedSession } from './custody.mjs';
8
9
  import { notRuntime } from './owner-files.mjs';
9
10
  import { machineCall } from './doors.mjs';
10
11
  import { execFile } from 'node:child_process';
@@ -20,6 +21,21 @@ import { cardCommitsOn, isLocalWorkspace, isWorkspacePath } from './workspace.mj
20
21
  import { identifyNotice } from './notices.mjs';
21
22
 
22
23
  const loaded = new Map();
24
+ // the owner's read door holds the workflow the owner reads (owner-door.mjs), told when it is read again
25
+ const workflowWatchers = new Set();
26
+ const hold = (root, entry) => {
27
+ const again = loaded.get(root)?.orchestration !== entry.orchestration;
28
+ loaded.set(root, entry);
29
+ if (again) for (const watcher of workflowWatchers) watcher(root, entry);
30
+ };
31
+ /** `watcher(root, entry)` is told each workflow this process holds, now and whenever one is read again; answers its stop. */
32
+ export function watchWorkflows(watcher) {
33
+ workflowWatchers.add(watcher);
34
+ for (const [root, entry] of loaded) watcher(root, entry);
35
+ return () => workflowWatchers.delete(watcher);
36
+ }
37
+ /** Hold the workflow another thread of this process read for `root` (the owner's, on its read door). */
38
+ export function holdWorkflow(root, entry) { loaded.set(root, { ...entry, root }); }
23
39
  const homes = new Map(); // root → Promise<orchestration>: the home as the IR reads it, held until it changes
24
40
 
25
41
  /**
@@ -49,15 +65,15 @@ export async function loadWorkflow(root) {
49
65
  entry = workflowEntry(orchestration);
50
66
  entry.root = root;
51
67
  entry.orchestration = orchestration;
52
- loaded.set(root, entry);
68
+ hold(root, entry);
53
69
  }
54
70
  current().workflows.set(root, entry);
55
71
  return entry;
56
72
  }
57
73
 
58
74
  // What the board's view of a home is decoded from: the workflow, each profile's config, credentials and the agent
59
- // layer, and which profiles there are. A session store or a run log changing is no change to it.
60
- const DECODED = /^(?:(?:profiles\/[^/]+\/)?(?:config\.yaml|\.env|agents\.json)|workflow\.yaml|profiles(?:\/[^/]+)?)$/;
75
+ // layer, persona, and which profiles there are. A session store or a run log changing is no change to it.
76
+ const DECODED = /^(?:(?:profiles\/[^/]+\/)?(?:config\.yaml|\.env|agents\.json|AGENTS\.md)|workflow\.yaml|profiles(?:\/[^/]+)?)$/;
61
77
 
62
78
  /**
63
79
  * Follow the home's own files, so the held view is read again on its next use after one it is decoded from changes:
@@ -75,7 +91,7 @@ export function followHome(root, { onChange = () => {}, onLost = () => {} } = {}
75
91
  const linked = new Map(); // target folder → watcher
76
92
  const followLinks = () => {
77
93
  const targets = new Map();
78
- const decoded = ['workflow.yaml', 'config.yaml', '.env', 'agents.json'];
94
+ const decoded = ['workflow.yaml', 'config.yaml', '.env', 'agents.json', 'AGENTS.md'];
79
95
  const watchIn = (folder, name) => { if (!targets.has(folder)) targets.set(folder, new Set()); targets.get(folder).add(name); };
80
96
  let realRoot = root;
81
97
  try { realRoot = realpathSync(root); } catch { /* the home's own watch reports it */ }
@@ -143,7 +159,7 @@ export function workflowFor(root) {
143
159
  /** Use `entry` for `root` (a caller that read the IR itself). */
144
160
  export function useWorkflow(root, entry) {
145
161
  entry.root = root;
146
- loaded.set(root, entry);
162
+ hold(root, entry);
147
163
  current().workflows.set(root, entry);
148
164
  return entry;
149
165
  }
@@ -170,6 +186,11 @@ export function completionOwnerOf(db, id) {
170
186
  return creatorOf(db, id);
171
187
  }
172
188
 
189
+ /** A card's explicitly named review profile, retained in its own history. */
190
+ export function reviewerOf(db, id) {
191
+ return json(one(db, "SELECT payload FROM task_events WHERE task_id=? AND kind IN ('created','reviewer_set') ORDER BY id DESC LIMIT 1", id)?.payload, {})?.reviewer ?? null;
192
+ }
193
+
173
194
  export function latestReview(db, id) {
174
195
  const row = one(db, "SELECT id,started_at,metadata FROM task_runs WHERE task_id=? AND json_extract(metadata,'$.supercode.role')='reviewer' ORDER BY id DESC LIMIT 1", id);
175
196
  if (!row) return null;
@@ -363,7 +384,8 @@ export function runWorkingSeconds(row, at = now()) {
363
384
  return Math.max(0, end - Number(row.started_at ?? end) - Number(json(row.metadata,{})?.supercode?.stopped_seconds??0));
364
385
  }
365
386
 
366
- function timeOf(db, task) {
387
+ /** Elapsed role totals need no review candidate or workflow guard scope. */
388
+ export function timeOf(db, task) {
367
389
  const t = now();
368
390
  const out = { coding: 0, reviewing: 0, auditing: 0, triaging: 0 };
369
391
  const key = { implementer: 'coding', reviewer: 'reviewing', auditor: 'auditing', triager: 'triaging' };
@@ -394,6 +416,14 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {}, lazy
394
416
  const lastRun = once(() => one(db, 'SELECT * FROM task_runs WHERE task_id = ? AND ended_at IS NOT NULL ORDER BY ended_at DESC, id DESC LIMIT 1', task.id));
395
417
  const lastCompleted = once(() => one(db, "SELECT * FROM task_runs WHERE task_id = ? AND outcome = 'completed' ORDER BY ended_at DESC LIMIT 1", task.id));
396
418
  const asked = once(() => json(one(db, "SELECT payload FROM task_events WHERE task_id = ? AND kind = 'review_requested' ORDER BY id DESC LIMIT 1", task.id)?.payload, {}) ?? {});
419
+ const entry = current().workflows.get(board.root) ?? loaded.get(board.root);
420
+ // Read the coding role, never the latest run of any role: after a review or a
421
+ // reassignment that run may belong to a different harness than the coder's.
422
+ const coding = once(() => one(db, "SELECT id,profile,metadata FROM task_runs WHERE task_id=? AND json_extract(metadata,'$.supercode.role')='implementer' ORDER BY id DESC LIMIT 1", task.id));
423
+ const codingSession = once(() => json(coding()?.metadata, {})?.supercode ?? {});
424
+ const codingProfile = once(() => coding()?.profile ?? asked().implementer ?? task.assignee);
425
+ const coderProfile = once(() => entry?.profiles?.[codingProfile()]);
426
+ const configuredCoder = once(() => coderProfile()?.worker);
397
427
  const changes = once(() => json(one(db, "SELECT payload FROM task_events WHERE task_id = ? AND kind = 'changes_requested' ORDER BY id DESC LIMIT 1", task.id)?.payload, {}) ?? {});
398
428
  const interruption = once(() => json(one(db, `SELECT payload FROM task_events WHERE task_id = ? AND kind IN ('blocked', 'block_loop_detected', 'dependency_wait', 'gave_up',
399
429
  'unblocked', 'changes_requested', 'review_reopened', 'status', 'reclaimed', 'stale', 'timed_out', 'crashed', 'spawn_failed', 'rate_limited', 'session_lost')
@@ -415,6 +445,7 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {}, lazy
415
445
  status,
416
446
  blocked_by: blocks,
417
447
  paused: json(state.paused, null),
448
+ detached: json(state.detached, null),
418
449
  archived: Boolean(state.archived),
419
450
  every: state.every ?? null,
420
451
  next_run_at: state.next_run_at ?? null,
@@ -436,6 +467,15 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {}, lazy
436
467
  get counters() { return { failures: Number(task.consecutive_failures || 0), block_recurrences: Number(task.block_recurrences || 0), goal_turns: Number(session().goal_turns || 0) }; },
437
468
  goal_mode: Boolean(task.goal_mode),
438
469
  get implementer() { return asked().implementer ?? task.assignee; },
470
+ get implementer_harness() { return codingSession().harness ?? configuredCoder()?.harness ?? null; },
471
+ get implementer_model() { return codingSession().model ?? (codingProfile() === task.assignee ? task.model_override : asked().implementer_model) ??
472
+ configuredCoder()?.model ?? coderProfile()?.residue?.config?.model?.default ?? null; },
473
+ get implementer_run() { return coding() ? { id: coding().id, profile: coding().profile, harness: codingSession().harness ?? null,
474
+ model: codingSession().model ?? null, machine: codingSession().machine ?? null } : null; },
475
+ get implementer_machine() { return Object.hasOwn(asked(), 'implementer_machine') ? asked().implementer_machine : task.machine ?? null; },
476
+ get implementer_model_override() { return Object.hasOwn(asked(), 'implementer_model_override') ? asked().implementer_model_override : task.model_override ?? null; },
477
+ get implementer_provider_override() { return Object.hasOwn(asked(), 'implementer_provider_override') ? asked().implementer_provider_override : task.provider_override ?? null; },
478
+ get reviewer() { return reviewerOf(db, task.id); },
439
479
  get last_reviewer() { return changes().reviewer ?? null; },
440
480
  get arc() { return task.subtask_of ? one(db, `SELECT t.id, ${related}, t.title, t.body, t.assignee FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id WHERE t.id = ?`, task.subtask_of) ?? null : null; },
441
481
  // the cards filed as its subtasks (`create --subtask-of`), in the order they were filed
@@ -485,13 +525,7 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {}, lazy
485
525
  Object.defineProperty(card, key, { get: read, enumerable: true, configurable: true });
486
526
  if (!lazy) read();
487
527
  }
488
- const derived = loaded.get(board.root)?.workflow?.derived ?? {};
489
- for (const [key, expr] of Object.entries(derived)) {
490
- // Preflight neither evaluates nor exposes a derived source value from a stale/missing observation.
491
- const value = deferSources && needsSources(expr, derived) ? undefined : evaluate(expr, { card, event, params, now: now() });
492
- Object.defineProperty(card, key, { value, writable: true, enumerable: true, configurable: true });
493
- }
494
- return {
528
+ const scope = {
495
529
  card,
496
530
  requester: actorOf(),
497
531
  event,
@@ -499,8 +533,15 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {}, lazy
499
533
  now: now(),
500
534
  board: board.label ?? board.slug,
501
535
  matches: (text, p) => (text === null || text === undefined ? false : pattern(p).test(String(text))),
536
+ lanes: entry ? lanesIn(entry) : {},
537
+ profile_harnesses: Object.fromEntries(Object.entries(entry?.profiles ?? {}).filter(([, profile]) => profile?.worker?.harness)
538
+ .map(([name, profile]) => [name, profile.worker.harness])),
502
539
  ...extra,
503
540
  };
541
+ // Derived facts see the same names as transitions, including the home's
542
+ // lanes. The pinned entry remains the basis if the home reloads mid-call.
543
+ for (const [key, expr] of Object.entries(entry?.workflow?.derived ?? {})) card[key] = deferSources && needsSources(expr, entry.workflow.derived) ? undefined : evaluate(expr, scope);
544
+ return scope;
504
545
  }
505
546
 
506
547
  // ------------------------------------------------------------------ events
@@ -528,13 +569,19 @@ function noticeRun(db, task) {
528
569
  : all(db, 'SELECT id, metadata FROM task_runs WHERE task_id = ? ORDER BY id DESC', task.id).find((r) => json(r.metadata, {})?.supercode?.session_id) ?? null;
529
570
  }
530
571
 
531
- function queueTransitionNotice(db, board, entry, task, from, name, payload, beforeRun, effects) {
572
+ function queueTransitionNotice(db, board, entry, task, from, name, payload, beforeRun, effects, noticeSession = null) {
532
573
  const after = cardOf(db, task.id), to = statusOf(db, after);
574
+ // A verified adoption starts a new run before binding its session. Its notice
575
+ // belongs to that new run and verified identity, never the last ended session
576
+ // that noticeRun found before the transition.
577
+ const targetRun = noticeSession && after.current_run_id
578
+ ? one(db, 'SELECT id, metadata FROM task_runs WHERE id = ?', after.current_run_id) : beforeRun;
533
579
  const state = (t, status) => JSON.stringify([status, t.current_run_id, t.paused, t.archived, t.blocked_by]);
534
580
  const beforeState = json(beforeRun?.metadata, {})?.supercode?.state;
535
581
  const afterState = beforeRun && json(one(db, 'SELECT metadata FROM task_runs WHERE id=?', beforeRun.id)?.metadata, {})?.supercode?.state;
536
- if (!beforeRun || (state(task, from) === state(after, to) && beforeState === afterState) || effects.some((e) => e.kind === 'session_notice' && e.runId === beforeRun.id)) return;
537
- const session = json(beforeRun.metadata, {})?.supercode;
582
+ if (!targetRun || (state(task, from) === state(after, to) && beforeState === afterState) || effects.some((e) => e.kind === 'session_notice' && e.runId === targetRun.id)) return;
583
+ const session = noticeSession ?? json(targetRun.metadata, {})?.supercode;
584
+ if (detachedSession(db, session)) return;
538
585
  const address = session?.address ?? (session?.session_id && session.machine ? `sc:${session.machine}:${session.harness ?? 'claude-code'}:${session.session_id}` : null);
539
586
  if (!address && !session?.launch_key && !session?.pane) return;
540
587
  // The session that made this change learns it from its own command's answer; a notice about
@@ -542,16 +589,19 @@ function queueTransitionNotice(db, board, entry, task, from, name, payload, befo
542
589
  // confirmed is not told either: its next run resumes it with the card's state in its prompt.
543
590
  if (address && address === actorOf()) return;
544
591
  if (session?.close_confirmed_at) return;
545
- const scope = scopeOf(db, board, after, { params: entry.params, event: { ...payload, verb: name, from, to }, extra: { run: { id: beforeRun.id } } });
546
- const body = render(entry.workflow.notify?.worker?.run_changed ?? '{{card.id}}: {{event.verb}} changed run {{run.id}} ({{event.from}} → {{event.to}}). Stop work under the previous run; follow the board’s current state. {{event.reason ?? ""}}', scope);
592
+ const scope = scopeOf(db, board, after, { params: entry.params, event: { ...payload, verb: name, from, to }, extra: { run: { id: targetRun.id } } });
593
+ const defaultNotice = noticeSession
594
+ ? '{{card.id}}: verified adoption started run {{run.id}} ({{event.from}} → {{event.to}}). Follow the board’s current state before continuing.'
595
+ : '{{card.id}}: {{event.verb}} changed run {{run.id}} ({{event.from}} → {{event.to}}). Stop work under the previous run; follow the board’s current state. {{event.reason ?? ""}}';
596
+ const body = render(entry.workflow.notify?.worker?.run_changed ?? defaultNotice, scope);
547
597
  const source = one(db, 'SELECT MAX(id) AS id FROM task_events WHERE task_id = ?', task.id)?.id;
548
- const text = identifyNotice({ id: source, kind: name, run_id: beforeRun.id, created_at: now() }, body);
549
- const effect = { kind: 'session_notice', id: task.id, runId: beforeRun.id, address, text, sourceEvent: source, verb: name, messageKey: `board-run-notice-${randomBytes(16).toString('hex')}` };
598
+ const text = identifyNotice({ id: source, kind: name, run_id: targetRun.id, created_at: now() }, body);
599
+ const effect = { kind: 'session_notice', id: task.id, runId: targetRun.id, address, text, sourceEvent: source, verb: name, messageKey: `board-run-notice-${randomBytes(16).toString('hex')}` };
550
600
  const inserted = run(db, 'INSERT INTO supercode_effects (effect) VALUES (?)', JSON.stringify(effect));
551
601
  effects.push({ ...effect, durableId: Number(inserted.lastInsertRowid) });
552
- const current = json(one(db, 'SELECT metadata FROM task_runs WHERE id = ?', beforeRun.id)?.metadata, {}) ?? {};
602
+ const current = json(one(db, 'SELECT metadata FROM task_runs WHERE id = ?', targetRun.id)?.metadata, {}) ?? {};
553
603
  current.supercode = { ...current.supercode, lifecycle_notice_pending: Number(inserted.lastInsertRowid) };
554
- run(db, 'UPDATE task_runs SET metadata = ? WHERE id = ?', JSON.stringify(current), beforeRun.id);
604
+ run(db, 'UPDATE task_runs SET metadata = ? WHERE id = ?', JSON.stringify(current), targetRun.id);
555
605
  }
556
606
 
557
607
  /**
@@ -644,7 +694,7 @@ export function validateEvent(db, board, entry, id, name, { roles = [], payload
644
694
  return { task, from, taken, scope, beforeRun: localOnly ? null : noticeRun(db, task) };
645
695
  }
646
696
 
647
- export function fire(db, board, entry, id, name, { roles = [], payload = {}, extra = {} } = {}) {
697
+ export function fire(db, board, entry, id, name, { roles = [], payload = {}, extra = {}, noticeSession = null } = {}) {
648
698
  const checked = validateEvent(db, board, entry, id, name, { roles, payload, extra });
649
699
  if (!checked) return fireRun(db, board, entry, id, name, { roles, payload, extra });
650
700
  const { task, from, taken, scope, beforeRun } = checked;
@@ -658,8 +708,10 @@ export function fire(db, board, entry, id, name, { roles = [], payload = {}, ext
658
708
  const ended = task.current_run_id && one(db, 'SELECT ended_at FROM task_runs WHERE id = ?', task.current_run_id)?.ended_at != null ? task.current_run_id : null;
659
709
  if ((scope.card.dispatch_hold || ended) && roles.some(role => ['manager','creator'].includes(role)) &&
660
710
  (['resume','reclaim','replace_session','reopen','reopen_review'].includes(name) || (name === 'set' && ['assignee','machine','model'].includes(payload.field))))
661
- record(db,id,'dispatch_released',{reason:`explicit ${name}`,incident:scope.card.last_events.run_incident?.payload?.run ?? null,...(ended ? { ended_run: ended } : {})});
662
- queueTransitionNotice(db, board, entry, task, from, name, payload, beforeRun, effects);
711
+ record(db,id,'dispatch_released',{reason:`explicit ${name}`,incident:scope.card.last_events.run_incident?.payload?.run ?? null,...(ended ? { ended_run: ended } : {}),
712
+ ...(ended && name === 'set' && payload.field === 'assignee' && (payload.value ?? null) !== (task.assignee ?? null)
713
+ ? { reassigned_from: task.assignee ?? null, reassigned_to: payload.value ?? null, abandon_waiting_input: true } : {})});
714
+ queueTransitionNotice(db, board, entry, task, from, name, payload, beforeRun, effects, noticeSession);
663
715
  return { from, to: statusOf(db, cardOf(db, id)), moved: to !== from, effects };
664
716
  }
665
717
 
@@ -690,7 +742,7 @@ function cardOf(db, id) {
690
742
  const row = one(db, 'SELECT * FROM tasks WHERE id = ?', id);
691
743
  if (!row) throw new Error(`no card ${id}`);
692
744
  const ours = one(db, 'SELECT * FROM supercode_cards WHERE task_id = ?', id);
693
- return { ...row, blocked_by: json(ours?.blocked_by, []) ?? [], paused: json(ours?.paused, null), archived: Boolean(ours?.archived), skills: json(row.skills, []) ?? [], machine: ours?.machine ?? null, subtask_of: ours?.subtask_of ?? null };
745
+ return { ...row, blocked_by: json(ours?.blocked_by, []) ?? [], paused: json(ours?.paused, null), detached: json(ours?.detached, null), archived: Boolean(ours?.archived), skills: json(row.skills, []) ?? [], machine: ours?.machine ?? null, subtask_of: ours?.subtask_of ?? null };
694
746
  }
695
747
 
696
748
  // one transition: the old status's exit actions, the move, the transition's actions, the new status's entry actions
@@ -724,6 +776,7 @@ export function setStatus(db, workflow, id, status) {
724
776
  /** Take every `always` transition that holds, from wherever the card is, until none does. */
725
777
  export function settle(db, board, entry, id, effects = []) {
726
778
  const { workflow, params } = entry;
779
+ if (detachedCard(db, id)) return effects;
727
780
  settleRelations(db, board, entry, id, effects);
728
781
  for (let hops = 0; hops < 16; hops += 1) {
729
782
  const task = cardOf(db, id);
@@ -813,7 +866,9 @@ const FORMAT = {
813
866
  blocked: ({ e, after, source }) => ({ reason: e.reason ?? null, on: e.on ?? null, waiting_on: e.waiting_on ?? null, recurrences: Number(after.block_recurrences || 0), source_status: source }),
814
867
  block_loop_detected: ({ e, after, source, params }) => ({ reason: e.reason ?? null, kind: e.kind ?? null, recurrences: Number(after.block_recurrences || 0), limit: params.block_recurrence_limit, source_status: source }),
815
868
  dependency_wait: ({ e, source }) => ({ reason: e.reason ?? null, on: e.on ?? null, source_status: source }),
816
- review_requested: ({ e, before, after }) => ({ summary: firstLine(e.summary), ci: e.ci ?? null, pr: e.ci?.pr ?? null, commit: e.ci?.commit ?? null, implementer: before.assignee, reviewer: after.assignee !== before.assignee ? after.assignee : (e.reviewer ?? null) }),
869
+ review_requested: ({ e, before, after }) => ({ summary: firstLine(e.summary), ci: e.ci ?? null, pr: e.ci?.pr ?? null, commit: e.ci?.commit ?? null,
870
+ implementer: before.assignee, implementer_machine: before.machine ?? null, implementer_model: before.implementer_model ?? null,
871
+ implementer_model_override: before.model_override ?? null, implementer_provider_override: before.provider_override ?? null, reviewer: after.assignee }),
817
872
  // the reviewer is the lane that reviewed (the card's assignee while in review), unless the verdict names one
818
873
  changes_requested: ({ e, before, after }) => ({ reason: e.reason ?? null, reviewer: e.reviewer ?? before.assignee ?? null, implementer: after.assignee }),
819
874
  review_reopened: ({ after, to }) => ({ status: to, implementer: after.assignee }),
@@ -922,9 +977,15 @@ const ACTIONS = {
922
977
  } else record(ctx.db, on ?? ctx.id, kind, payload && Object.keys(payload).length ? payload : null, on ? null : ctx.runId);
923
978
  },
924
979
  assign(ctx, { field, value }) {
980
+ if (field === 'reviewer') {
981
+ if (value != null && (typeof value !== 'string' || !ctx.entry.profiles?.[value]?.worker?.harness)) throw new Error('A card reviewer names a runnable profile in this home.');
982
+ record(ctx.db, ctx.id, 'reviewer_set', { reviewer: value ?? null }, ctx.runId);
983
+ return;
984
+ }
925
985
  if (field === 'machine') {
926
986
  const task = one(ctx.db, 'SELECT t.current_run_id,t.workspace_kind,c.workspace_anchor,c.machine FROM tasks t JOIN supercode_cards c ON c.task_id=t.id WHERE t.id=?', ctx.id);
927
987
  if (task.current_run_id) throw new Error('End the current run before moving its card.');
988
+ if ((value ?? null) === task.machine) return;
928
989
  if (task.workspace_kind !== 'scratch' && (!task.workspace_anchor || isWorkspacePath(task.workspace_anchor))) throw new Error('declare the repository and branch with workflow specify --workspace before moving a legacy path card');
929
990
  if ((value ?? null) !== task.machine) run(ctx.db, 'UPDATE tasks SET workspace_path=NULL WHERE id=?', ctx.id);
930
991
  cardField(ctx, 'machine', value ?? null);
@@ -984,6 +1045,7 @@ const ACTIONS = {
984
1045
  run(ctx.db, 'UPDATE tasks SET current_run_id = NULL, claim_lock = NULL, claim_expires = NULL, worker_pid = NULL WHERE id = ?', ctx.id);
985
1046
  },
986
1047
  claim(ctx, role, scope) {
1048
+ if (detachedCard(ctx.db, ctx.id)) throw new Error('a detached card must receive a verified adoption before starting another run');
987
1049
  const task = cardOf(ctx.db, ctx.id);
988
1050
  const t = now();
989
1051
  const ttl = Number(ctx.entry.params.claim_ttl_seconds) || 900;
@@ -1139,7 +1201,7 @@ export function retireSupersededRuns(db, id, runId, session) {
1139
1201
  OR (? IS NOT NULL AND json_extract(metadata, '$.supercode.pane') = ?))`,
1140
1202
  runId, session.machine ?? null, session.session_id ?? null, session.session_id ?? null, session.pane ?? null, session.pane ?? null)) {
1141
1203
  const whole = json(old.metadata, {}) ?? {}, s = whole.supercode;
1142
- if (!s || s.machine !== session.machine || !((session.session_id && s.session_id === session.session_id) || (session.pane && s.pane === session.pane))) continue;
1204
+ if (!s || detachedSession(db, s) || s.machine !== session.machine || !((session.session_id && s.session_id === session.session_id) || (session.pane && s.pane === session.pane))) continue;
1143
1205
  if (old.task_id !== id && one(db, 'SELECT 1 FROM tasks WHERE current_run_id = ?', old.id)) throw new Error(`session is still owned by active run ${old.id} of ${old.task_id}`);
1144
1206
  if (s.pane && s.pane === session.pane) whole.supercode = { ...s, close_transferred_to: runId, close_requested: null };
1145
1207
  run(db, "UPDATE task_runs SET status='superseded', outcome='superseded', ended_at=?, claim_lock=NULL, claim_expires=NULL, worker_pid=NULL, metadata=? WHERE id=?", now(), JSON.stringify(whole), old.id);
@@ -1159,6 +1221,7 @@ export function requestSessionClose(db, id, { runId = null, slot = null, through
1159
1221
  if ((runId != null && row.id !== runId) || (through != null && row.id > through)) continue;
1160
1222
  const whole = json(row.metadata, {}) ?? {};
1161
1223
  const session = whole.supercode;
1224
+ if (detachedSession(db, session)) continue;
1162
1225
  if ((!session?.pane && !session?.launch_key && !(session?.surface === 'native' && session.session_id)) || session.close_transferred_to || session.close_confirmed_at || (slot != null && (session.slot ?? 'worker') !== slot)) continue;
1163
1226
  if (session.close_requested && !session.closed_at) continue;
1164
1227
  whole.supercode = { ...session, close_requested: session.close_requested ?? now() };
package/board/ztrack.mjs CHANGED
@@ -7,6 +7,10 @@
7
7
  // triggers name the rows it touched. Only the cards whose text changed are rendered, and each is
8
8
  // spliced into the document at its indexed span. A document edited by hand is re-read only in the
9
9
  // region the edit touched. So a write costs what it changes, not the size of the board.
10
+ //
11
+ // A listing answered on the owner's read door (owner-door.mjs) opens the board read-only on a connection of its own and
12
+ // reads it in one SQLite read transaction: WAL gives it the rows as one committed state, and no writer waits on it or
13
+ // it on a writer. The documents are taken with that state (readSnapshot), never locked.
10
14
  import { current } from './call.mjs';
11
15
  import { fenced } from './fence.mjs';
12
16
  import { createRequire } from 'node:module';
@@ -14,7 +18,7 @@ import { createHash, randomBytes } from 'node:crypto';
14
18
  import { existsSync, linkSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
15
19
  import { basename, dirname, join, resolve } from 'node:path';
16
20
  import { DatabaseSync } from 'node:sqlite';
17
- import { actorOf, contention, event, json, now, principalOf, storageFault, STORAGE_TRIES } from './store.mjs';
21
+ import { actorOf, contention, event, json, now, principalOf, storageFault, STORAGE_TRIES, unreadable } from './store.mjs';
18
22
  import { GitDocument } from './git-document.mjs';
19
23
  import { unimported } from './legacy-store.mjs';
20
24
  import { noteStep, recordSlow } from '@volter/supercode-harness-sdk/slow-log';
@@ -68,11 +72,13 @@ const timedStatement = (sql, fn) => {
68
72
  finally { noteStatement(sql, started, before); }
69
73
  };
70
74
  import { fire, workflowFor } from './workflow.mjs';
71
- import { LIST_REVISION, listRevision, TASK_SUMMARY_INDEX, TASK_SUMMARY_INDEX_SQL } from './listing.mjs';
75
+ import { byOwner, LIST_REVISION, listRevision, TASK_SUMMARY_INDEX, TASK_SUMMARY_INDEX_SQL } from './listing.mjs';
72
76
 
73
77
  const require = createRequire(import.meta.url);
74
78
  const hash = (text) => createHash('sha256').update(text).digest('hex');
75
79
  const read = (path) => existsSync(path) ? readFileSync(path, 'utf8') : '';
80
+ // What says a document changed: a write in place moves its size or times, a replacement its inode.
81
+ const stampOf = (path) => { try { const s = statSync(path); return `${s.ino}:${s.size}:${s.mtimeMs}:${s.ctimeMs}`; } catch { return 'missing'; } };
76
82
  const lf = (text) => text.replace(/\r\n?/g, '\n');
77
83
  const quote = (s) => `"${String(s).replaceAll('"', '""')}"`;
78
84
  const nativeId = (id) => /^t-[a-f0-9]+$/.test(id) ? id.replace('t-', 't_') : id;
@@ -94,6 +100,9 @@ const sqliteContention = (error) => contention(error); // resolved at call time:
94
100
  const RETRY = new Error('board changed while this operation read it; retry');
95
101
  // The current archive's size before it rotates, unless the board declares its own (`archive.max_lines`).
96
102
  const ARCHIVE_LINES = 2000;
103
+ // How many read transactions a listing begins before it leaves the board to its owner, each begun again only because a
104
+ // document changed while it was read (readSnapshot).
105
+ const SNAPSHOT_TRIES = 8;
97
106
  const isArchive = (role) => role === 'archived' || String(role ?? '').startsWith('archived:');
98
107
 
99
108
  const BOARD_TABLES = `
@@ -109,7 +118,7 @@ DROP TABLE IF EXISTS board_export;`;
109
118
  // written to the document). board_tasks: a subtask's key on its arc's Tasks list
110
119
  // (`<arc>:c3`) and its `source:` lines, which live only in the document.
111
120
 
112
- export function openZtrackBoard(marker, { create = false, document = null, declared = null, seed = null, init, root, slug, held = false }) {
121
+ export function openZtrackBoard(marker, { create = false, document = null, declared = null, seed = null, init, root, slug, held = false, readOnly = false }) {
113
122
  if (!existsSync(marker)) {
114
123
  if (!create) throw new Error(`no ztrack board ${marker}`);
115
124
  mkdirSync(dirname(marker), { recursive: true });
@@ -123,18 +132,20 @@ export function openZtrackBoard(marker, { create = false, document = null, decla
123
132
  atomic(marker, JSON.stringify({ format: 1, document: join(realpathSync(dirname(named)), basename(named)), ...said }) + '\n');
124
133
  }
125
134
  const config = JSON.parse(read(marker));
126
- const backing = new ZtrackBoard({ ...config, seed, marker, init, root, slug, held });
135
+ const backing = new ZtrackBoard({ ...config, seed, marker, init, root, slug, held, readOnly });
127
136
  return { db: backing.db, tx: (fn) => backing.tx(fn), publishPending: () => backing.git?.publishPending(), unimported: () => backing.unimported(), close: () => backing.close(), document: backing.path,
128
137
  documentsChanged: () => backing.documentsChanged(), documentsUnwatched: (unwatched) => { backing.unwatched = unwatched; }, saidOnOpen: () => backing.sayLate() };
129
138
  }
130
139
 
131
140
 
132
141
  class ZtrackBoard {
133
- constructor({ document, seed, marker, init, root, slug, commit = false, manager = null, archive = {}, held = false }) {
142
+ constructor({ document, seed, marker, init, root, slug, commit = false, manager = null, archive = {}, held = false, readOnly = false }) {
134
143
  // held by the board owner (store.mjs holdBoards): opened once, its documents' stamps read again only when the
135
144
  // owner's watch says they changed or may have lost events (documentsChanged), and on every read while it keeps no
136
145
  // watch on them (unwatched); git's lateness held and refreshed on its own commits
137
146
  this.held = held; this.docsChanged = true; this.importDue = true; this.knownStamps = null;
147
+ // a listing's read-only view (readSnapshot): it makes, migrates, recovers, pulls and commits nothing
148
+ this.readOnly = readOnly;
138
149
  this.path = resolve(dirname(marker), document);
139
150
  this.archive = `${this.path}.archive.md`;
140
151
  this.baseDocs = [{ role: 'open', path: this.path }, { role: 'archived', path: this.archive }];
@@ -147,7 +158,7 @@ class ZtrackBoard {
147
158
  // `"manager"`: the board's manager (its principal or session address), whose writes it commits itself as part of its
148
159
  // own work; the backing commits only the writes the manager does not make.
149
160
  this.manager = manager;
150
- this.git = commit ? new GitDocument(this.baseDocs.map((doc) => doc.path), (base, theirs, ours, path) => { try { return this.codec.mergeBoardDocument(base, theirs, ours, path)?.text ?? null; } catch { return null; } }) : null;
161
+ this.git = commit && !readOnly ? new GitDocument(this.baseDocs.map((doc) => doc.path), (base, theirs, ours, path) => { try { return this.codec.mergeBoardDocument(base, theirs, ours, path)?.text ?? null; } catch { return null; } }) : null;
151
162
  // what a fetch brought is written in the board's next transaction: one soon, on this loop, if none comes
152
163
  if (this.git) this.git.onTaken = () => setImmediate(() => { try { if (!this.closed) this.tx(() => {}); } catch (error) { current().err(`board: ${error.message}\n`); } });
153
164
  // A publish that is behind is said once per call, on whichever workflow verb opened the board: a refused push
@@ -158,18 +169,25 @@ class ZtrackBoard {
158
169
  else if (this.git) this.sayLate(timedStep('board open git behind', () => this.git.behind()));
159
170
  this.seed = seed; this.root = root; this.slug = slug; this.init = init;
160
171
  this.file = `${marker}.board.db`;
161
- mkdirSync(dirname(this.path), { recursive: true });
172
+ // the store is the owner's to make, import and lay out: a listing that finds none leaves the board to it
173
+ if (readOnly && !(existsSync(this.file) && statSync(this.file).size > 0)) throw byOwner(`${this.file} is not laid out yet`);
174
+ if (!readOnly) mkdirSync(dirname(this.path), { recursive: true });
162
175
  const legacy = `${marker}.runtime.db`;
163
176
  // A 0-byte file is no store: a read through the sqlite3 CLI creates one at a path that has none (on 2026-10-01 a
164
177
  // `sqlite3 <store> "select …"` made the empty file the next release then opened instead of importing).
165
- if (existsSync(this.file) && statSync(this.file).size === 0) unlinkSync(this.file);
178
+ if (!readOnly && existsSync(this.file) && statSync(this.file).size === 0) unlinkSync(this.file);
166
179
  if (!existsSync(this.file)) this.create(legacy);
167
- this.conn = timedStep('board open connection', () => new DatabaseSync(this.file));
168
- this.refuseUnimported(legacy);
180
+ this.conn = timedStep('board open connection', () => {
181
+ if (!readOnly) return new DatabaseSync(this.file);
182
+ try { return new DatabaseSync(this.file, { readOnly: true }); } catch (error) { throw unreadable(error) ? byOwner(error.message) : error; }
183
+ });
184
+ if (!readOnly) this.refuseUnimported(legacy);
169
185
  this.errors = new Map(); this.statements = new Map(); this.hashes = new Map();
170
186
  this.active = false; this.overlay = false; this.seen = null; this.refused = new Set();
171
- timedStep('board open schema', () => this.prepareConnection());
172
- timedStep('board open errors', () => this.loadErrors());
187
+ if (!readOnly) {
188
+ timedStep('board open schema', () => this.prepareConnection());
189
+ timedStep('board open errors', () => this.loadErrors());
190
+ }
173
191
  const self = this;
174
192
  this.db = {
175
193
  [LIST_REVISION]: () => self.read(() => listRevision(self.conn)),
@@ -185,10 +203,49 @@ class ZtrackBoard {
185
203
  exec(sql) { const run = () => timedStatement(sql, () => self.conn.exec(sql)); return self.active ? self.noting(run) : self.tx(run); },
186
204
  close() { self.close(); },
187
205
  };
206
+ if (readOnly) {
207
+ try { timedStep('board read: snapshot', () => this.readSnapshot()); }
208
+ catch (error) { this.conn.close(); throw error; }
209
+ return;
210
+ }
188
211
  // A board's first open in this process recovers what its store accepted and derives what git owes for it (tx), so
189
212
  // a board that is only read, or whose owner runs no round, still finishes an earlier process's commit.
190
213
  if (seed || this.git?.underived) this.tx(() => {});
191
214
  }
215
+
216
+ // A listing's view, taken once at its open and kept until its close: the store's rows as of one read transaction, and
217
+ // every document as it stood when that transaction began. A document is written only after the transaction that
218
+ // published it committed, so a document that stood unchanged from before the transaction began until after it was
219
+ // read is one the transaction already describes: its published text, the text a pending publication replaces (the
220
+ // rows stay authoritative), or a hand edit, which is shown through connection-local copies of the card tables as
221
+ // every reader shows one. A document that changed in between is read again with a new transaction. Nothing here waits:
222
+ // WAL serves the read transaction beside any writer, and no busy timeout is set. A store not in WAL or not laid out,
223
+ // one a read-only connection cannot begin reading (store.mjs unreadable) or documents that keep changing leave the
224
+ // listing to the owner.
225
+ readSnapshot() {
226
+ const contended = (fn) => { try { return fn(); } catch (error) { throw unreadable(error) ? byOwner(error.message) : error; } };
227
+ if (String(contended(() => this.conn.prepare('PRAGMA journal_mode').get())?.journal_mode).toLowerCase() !== 'wal') throw byOwner(`${this.file} is not in WAL mode`);
228
+ for (let attempt = 0; attempt < SNAPSHOT_TRIES; attempt++) {
229
+ const stamps = () => this.docs.map((doc) => `${doc.path}\0${stampOf(doc.path)}`).join('\n');
230
+ const before = contended(stamps);
231
+ this.conn.exec('BEGIN');
232
+ let stale;
233
+ try {
234
+ // the transaction's first read begins its snapshot
235
+ if (!contended(() => this.one("SELECT 1 FROM main.sqlite_master WHERE type = 'index' AND name = ?", TASK_SUMMARY_INDEX))) throw byOwner(`${this.file} is not laid out yet`);
236
+ this.loadErrors();
237
+ stale = this.staleDocuments({ pending: true });
238
+ if (stamps() !== before) { this.conn.exec('ROLLBACK'); this.hashes.clear(); continue; }
239
+ } catch (error) { try { this.conn.exec('ROLLBACK'); } catch { /* not begun */ } throw error; }
240
+ if (!stale.length) return;
241
+ this.tables = this.shapes();
242
+ let plan;
243
+ try { plan = this.observe(stale); } catch { return; } // the next write reports it
244
+ this.applyPlan(plan, { overlay: true });
245
+ return;
246
+ }
247
+ throw byOwner('its documents changed during every read');
248
+ }
192
249
  /** The publish's lateness, said once per call (a held board's from its held reading, never by asking git). */
193
250
  sayLate(late = this.git?.lateness ?? null) {
194
251
  const said = current().said;
@@ -246,14 +303,9 @@ class ZtrackBoard {
246
303
  // What a transaction touched, for the renderer (cards whose text may change: anything beyond run fields and scheduling clocks). Plain inserts: a trigger's
247
304
  // own conflict clause yields to the outer statement's, so duplicates are removed when read.
248
305
  this.conn.exec('CREATE TEMP TABLE IF NOT EXISTS board_dirty (name TEXT NOT NULL, key TEXT NOT NULL); CREATE TEMP TABLE IF NOT EXISTS board_touched (id TEXT)');
249
- this.tables = new Map();
250
- const names = this.conn.prepare("SELECT name FROM main.sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE 'board_%'").all().map((row) => row.name);
306
+ this.tables = this.shapes();
251
307
  const trigger = (name, when, body) => this.conn.exec(`CREATE TEMP TRIGGER IF NOT EXISTS ${quote(name)} ${when} BEGIN ${body} END`);
252
- for (const name of names) {
253
- const columns = this.conn.prepare(`PRAGMA main.table_info(${quote(name)})`).all();
254
- const keys = columns.filter((c) => c.pk).sort((a, b) => a.pk - b.pk).map((c) => c.name);
255
- if (!keys.length) keys.push('rowid');
256
- this.tables.set(name, { name, keys, columns: columns.map((c) => c.name) });
308
+ for (const { name, keys } of this.tables.values()) {
257
309
  const key = (row) => `json_array(${keys.map((k) => `${row}.${quote(k)}`).join(',')})`;
258
310
  const mark = (row) => `INSERT INTO board_dirty VALUES ('${name}', ${key(row)});`;
259
311
  trigger(`board_i_${name}`, `AFTER INSERT ON main.${quote(name)}`, mark('NEW'));
@@ -268,6 +320,18 @@ class ZtrackBoard {
268
320
  }
269
321
  }
270
322
 
323
+ // Each runtime table's columns and key, as the store has them.
324
+ shapes() {
325
+ const tables = new Map();
326
+ for (const { name } of this.conn.prepare("SELECT name FROM main.sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE 'board_%'").all()) {
327
+ const columns = this.conn.prepare(`PRAGMA main.table_info(${quote(name)})`).all();
328
+ const keys = columns.filter((c) => c.pk).sort((a, b) => a.pk - b.pk).map((c) => c.name);
329
+ if (!keys.length) keys.push('rowid');
330
+ tables.set(name, { name, keys, columns: columns.map((c) => c.name) });
331
+ }
332
+ return tables;
333
+ }
334
+
271
335
  // A store holding fewer runs than the earlier store beside it was made without the one-time import (on 2026-10-01
272
336
  // one such store, created by an opener nobody named, sent the dispatcher on to start a second session on every
273
337
  // running card). The board refuses it, so nothing runs on a board without its history.
@@ -388,6 +452,8 @@ class ZtrackBoard {
388
452
  }
389
453
 
390
454
  read(fn) {
455
+ // a listing's view is its snapshot's (readSnapshot): its statements read that, never the documents again
456
+ if (this.readOnly) return fn();
391
457
  if (this.active) return this.noting(fn);
392
458
  this.observeForRead();
393
459
  for (let attempt = 0; ; attempt++) {
@@ -397,8 +463,7 @@ class ZtrackBoard {
397
463
  }
398
464
 
399
465
  fileHash(path) {
400
- let stamp;
401
- try { const s = statSync(path); stamp = `${s.ino}:${s.size}:${s.mtimeMs}:${s.ctimeMs}`; } catch { stamp = 'missing'; }
466
+ const stamp = stampOf(path);
402
467
  const known = this.hashes.get(path);
403
468
  if (known?.stamp === stamp) return known;
404
469
  const raw = read(path);
@@ -462,6 +527,7 @@ class ZtrackBoard {
462
527
  // ------------------------------------------------------------------ writes
463
528
 
464
529
  tx(fn) {
530
+ if (this.readOnly) throw new Error('a listing reads the board and writes nothing');
465
531
  // A write inside a write is part of it: the pull below happened once, for the outermost one.
466
532
  if (this.active) return fn(this.db);
467
533
  // A pull request merged on the remote (a page edited in Git Notes) is in the document before this write reads it.
@@ -0,0 +1,2 @@
1
+ /** Read a remote card write's selected text file (or stdin) on the caller's machine. */
2
+ export function prepareBoardText(argv: string[]): { argv: string[]; stdin: string | null };
@@ -0,0 +1,16 @@
1
+ // A remote card write reads its text on the caller's machine, before the board door is called.
2
+ // The board's own argument parser decides which file is used; its last value wins.
3
+ import { readFileSync } from 'node:fs';
4
+ import { parse } from './board/args.mjs';
5
+
6
+ /** Carry a comment/create/specify text input as the owner's existing `stdin` field. */
7
+ export function prepareBoardText(argv) {
8
+ const args = parse(argv);
9
+ const verb = args._[0];
10
+ const key = verb === 'comment' ? 'file' : ['create', 'specify'].includes(verb) ? 'body-file' : null;
11
+ const file = key === null || args.help ? undefined : args[key];
12
+ if (file === undefined) return { argv, stdin: null };
13
+ // Appending the selected flag preserves every other argument and overrides earlier occurrences. The owner
14
+ // reads the carried text at `-`, never an unrelated file at the caller's original path on another machine.
15
+ return { argv: [...argv, `--${key}`, '-'], stdin: readFileSync(file === '-' ? 0 : file, 'utf8') };
16
+ }