@volter/supercode-orchestrator 0.5.226 → 0.5.227

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/doors.mjs CHANGED
@@ -43,7 +43,9 @@ async function opened(machine) {
43
43
  * harness's own service, spoken by its SDK over the machine's harness stream; every other door is the daemon's node
44
44
  * service. Throws the door's refusal (with its code).
45
45
  */
46
- export async function machineCall(machine, method, params = {}, { timeoutMs = 60_000 } = {}) {
46
+ // `signal`: a caller that stops wanting the answer (a parked board generation's read) ends the call at once: the phase in
47
+ // progress is aborted with the signal's reason, as its own deadline would abort it.
48
+ export async function machineCall(machine, method, params = {}, { timeoutMs = 60_000, signal = null } = {}) {
47
49
  if (!method.startsWith('harness.v1.')) throw new Error('a machine door is a harness.v1 method');
48
50
  if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error('a machine door timeout is a positive finite number of milliseconds');
49
51
  const started = performance.now();
@@ -57,15 +59,18 @@ export async function machineCall(machine, method, params = {}, { timeoutMs = 60
57
59
  code: 'REQUEST_TIMEOUT', phase: name, requestNotSent: name !== 'request',
58
60
  });
59
61
  if (remaining <= 0) throw expired();
62
+ if (signal?.aborted) throw signal.reason;
60
63
  const controller = new AbortController();
61
- let timer;
64
+ let timer, onAbort;
62
65
  try {
63
66
  const timeout = new Promise((_, reject) => {
64
67
  timer = setTimeout(() => { const error = expired(); controller.abort(error); reject(error); }, remaining);
68
+ if (signal) { onAbort = () => { controller.abort(signal.reason); reject(signal.reason); }; signal.addEventListener('abort', onAbort, { once: true }); }
65
69
  });
66
70
  return await Promise.race([work({ timeoutMs: remaining, signal: controller.signal }), timeout]);
67
71
  } finally {
68
72
  clearTimeout(timer);
73
+ if (onAbort) signal.removeEventListener('abort', onAbort);
69
74
  noteStep(`door ${method}: ${name}`, performance.now() - at);
70
75
  }
71
76
  };
package/board/engine.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { allocateReadableId } from './readable-id.mjs';
1
+ import { allocateReadableId, cardReadableId, requireReadableId, validateReadableId } from './readable-id.mjs';
2
2
  // The board's transitions (docs/architecture/orchestrator.md §2.9): what a card may do, written for supercode and
3
3
  // kept to Hermes Kanban's lifecycle so the two read each other's boards. Statuses: triage · todo · scheduled ·
4
4
  // ready · running · blocked · review · done · archived. A card with a parent that is not done waits in `todo`
@@ -9,7 +9,7 @@ import { allocateReadableId } from './readable-id.mjs';
9
9
  // Each function takes an open board (`store.mjs`) and runs in one write transaction. Nothing here launches,
10
10
  // observes or sends: the dispatcher (`dispatch.mjs`) does, and records what it did through these.
11
11
  import { current } from './call.mjs';
12
- import { detachedCard, detachedSession, personCustody, sessionAddress, sessionOperation } from './custody.mjs';
12
+ import { detachedCard, detachedSession, personCustody, personOf, sessionAddress, sessionOperation } from './custody.mjs';
13
13
  import { executionOf, readingContext } from './session-work.mjs';
14
14
  import { cardState, cardStateSql, holdDescription } from './card-state.mjs';
15
15
  import { spawn, execFile } from 'node:child_process';
@@ -18,9 +18,9 @@ import { join, resolve } from 'node:path';
18
18
  import { checkWorkspace, assertWorkspaceOwner, declareWorkspace, isLocalWorkspace, plainWorkspace } from './workspace.mjs';
19
19
  import { DatabaseSync } from 'node:sqlite';
20
20
  import { CARD_VISIBILITY_SQL } from './visibility.mjs';
21
- import { view, actorOf, principalOf, boardKey, cardBranch, cardHandle, CARD_NUMBERS, claimer, contention, event, json, newTaskId, now, retryContention, tx } from './store.mjs';
21
+ import { view, actorOf, principalOf, cardBranch, CARD_NUMBERS, claimer, contention, event, json, newTaskId, now, retryContention, tx } from './store.mjs';
22
22
  import { arcRepositories, arcSources, assertSourceReceipt, completionPhase, outsideArcRepositories, inspectCompletionEvidence, observeCompletionEvidence, recordedArcRepositories, withCompletionEvidence } from './ci.mjs';
23
- import { addDependency, retireSupersededRuns, refreshCardInputs, requestSessionClose, reviewerOf, eventTo, fire, validateEvent, loadWorkflow, setStatus, settle, startNew, statusOf, runEvent, workflowFor } from './workflow.mjs';
23
+ import { addDependency, retireSupersededRuns, refreshCardInputs, requestSessionClose, reviewerOf, eventTo, fire, validateEvent, loadWorkflow, setStatus, settle, startNew, statusOf, runEvent, transitionsFor, workflowFor } from './workflow.mjs';
24
24
  import { refuseCredentialDump, withheldIfDump } from './credential-guard.mjs';
25
25
  import { AGENT_KIND_LIST, compactCardAgentEvents } from './published.mjs';
26
26
 
@@ -42,16 +42,29 @@ const all = (db, sql, ...args) => db.prepare(sql).all(...args);
42
42
  const run = (db, sql, ...args) => db.prepare(sql).run(...args);
43
43
 
44
44
  // A card's number: what the store gave it when it was made (store.mjs layOutNumbers), read with its board's key
45
- // (`VO-42`, store.mjs boardKey). A person names a card by it wherever its id is taken (`VO-42`, `vo-42`, `#42`, `42`),
46
- // and every view of a card shows it first, before the id.
45
+ // (`VO-42`, store.mjs boardKey). A person names a card by it, or by its readable id, wherever its id is taken (`VO-42`,
46
+ // `vo-42`, `#42`, `42`, `voice-calls`), and every view of a card shows its readable id first, then its number and id.
47
47
  /** Card `n`'s id on this board, or null when no card here has that number. */
48
48
  export const cardByNumber = (db, n) => one(db, `SELECT t.id FROM ${CARD_NUMBERS} n JOIN tasks t ON t.id = n.task_id WHERE n.number = ?`, Number(n))?.id ?? null;
49
49
  /** The number of card `id`, or null. */
50
50
  export const numberOf = (db, id) => one(db, `SELECT number FROM ${CARD_NUMBERS} WHERE task_id = ?`, id)?.number ?? null;
51
- /** The numbers of cards `ids`: id → number. */
52
- export const numbersOf = (db, ids) => new Map(ids.length ? all(db, `SELECT task_id, number FROM ${CARD_NUMBERS} WHERE task_id IN (SELECT value FROM json_each(?))`, JSON.stringify([...new Set(ids)])).map((r) => [r.task_id, r.number]) : []);
53
- /** A card as a person reads it: its keyed number first (store.mjs boardKey), then its id (`VO-42 t_4406c88a`). */
54
- export const cardLabel = (key, number, id, readableId = null) => `${number ? `${key}-${number} ${id}` : String(id)}${readableId ? ` (${readableId})` : ''}`;
51
+ /** A card as a person reads it: its readable id first, then its keyed number (store.mjs boardKey) and its id
52
+ * (`voice-calls (VO-42 t_4406c88a)`); one with no readable id by its number and id (`VO-42 t_4406c88a`). */
53
+ export const cardLabel = (key, number, id, readableId = null) => {
54
+ const ref = number ? `${key}-${number} ${id}` : String(id);
55
+ return readableId ? `${readableId} (${ref})` : ref;
56
+ };
57
+ /** Cards `ids` as a person reads them (cardLabel): id → label. A subtask, which no person named, by its number and id. */
58
+ export const labelsOf = (db, key, ids) => new Map(ids.length ? all(db, `SELECT t.id, t.readable_id, n.number, c.subtask_of FROM tasks t LEFT JOIN ${CARD_NUMBERS} n ON n.task_id = t.id
59
+ LEFT JOIN supercode_cards c ON c.task_id = t.id WHERE t.id IN (SELECT value FROM json_each(?))`, JSON.stringify([...new Set(ids)]))
60
+ .map((r) => [r.id, cardLabel(key, r.number, r.id, r.subtask_of ? null : r.readable_id)]) : []);
61
+ /** The card named `readableId` on this board: the open one holding it, else the one card that ever did; null otherwise. */
62
+ export const cardByReadableId = (db, readableId) => {
63
+ const open = one(db, "SELECT id FROM tasks WHERE readable_id = ? AND status NOT IN ('done','cancelled','archived')", readableId)?.id;
64
+ if (open) return open;
65
+ const ever = all(db, 'SELECT id FROM tasks WHERE readable_id = ? LIMIT 2', readableId);
66
+ return ever.length === 1 ? ever[0].id : null;
67
+ };
55
68
 
56
69
  export function getTask(db, id) {
57
70
  const row = one(db, 'SELECT * FROM tasks WHERE id = ?', id);
@@ -205,6 +218,7 @@ export function bodyTasks(body) {
205
218
  * before the card is filed: the filing (createCard) is one write that asks no machine. Answers the spec to file.
206
219
  */
207
220
  export async function prepareCard(board, spec) {
221
+ if (!spec.subtaskOf) requireReadableId(spec.readableId);
208
222
  const ws = parseWorkspace(spec.workspace ?? 'scratch');
209
223
  const project = spec.project ? projectOf(board.root, spec.project) : null;
210
224
  if (project) {
@@ -222,6 +236,8 @@ export async function prepareCard(board, spec) {
222
236
  export function createCard(board, spec) {
223
237
  const title = String(spec.title ?? '').trim();
224
238
  if (!title) throw new Error('a card has a title');
239
+ // A card is named by its creator (readable-id.mjs); a subtask, a row under its card that no person names, by its title.
240
+ if (!spec.subtaskOf) requireReadableId(spec.readableId);
225
241
  refuseCredentialDump('the card', { title, body: spec.body ?? null });
226
242
  const steps = spec.subtaskOf ? [] : bodyTasks(spec.body).tasks;
227
243
  if (steps.length) spec = { ...spec, body: bodyTasks(spec.body).body };
@@ -275,8 +291,9 @@ export function createCard(board, spec) {
275
291
  assertWorkspaceOwner(db, workspaceTask, checked);
276
292
  // The repository declaration stays separate from the resolved worktree path.
277
293
  if (project && ws.kind === 'scratch') ws.kind = 'worktree';
278
- // A worktree card's branch is named for it once the store has numbered it (below), unless its creator named one.
279
- const namedBranch = ws.kind === 'worktree' && !spec.branch ? (handle) => `${project ? `${project.slug}/` : ''}${cardBranch(handle, readableId)}` : null;
294
+ // A worktree card's branch is named by its readable id (below; store.mjs cardBranch), unless its creator named one; a
295
+ // subtask's, which no person names, by its id.
296
+ const namedBranch = ws.kind === 'worktree' && !spec.branch;
280
297
  spec = { ...spec, project: project?.id ?? null };
281
298
  const t = now();
282
299
  run(db, `INSERT INTO tasks (id, title, body, assignee, status, priority, created_by, created_at, workspace_kind, workspace_path, branch_name,
@@ -295,10 +312,10 @@ export function createCard(board, spec) {
295
312
  run(db, `DELETE FROM ${CARD_NUMBERS} WHERE task_id = ?`, id);
296
313
  if (taken != null) run(db, 'UPDATE sqlite_sequence SET seq = ? WHERE name = ? AND seq = ?', taken - 1, CARD_NUMBERS, taken);
297
314
  }
298
- const readableId = allocateReadableId(db, title, spec.readableId ?? null, id);
315
+ const readableId = arc ? allocateReadableId(db, title, spec.readableId ?? null, id) : cardReadableId(db, spec.readableId, id);
299
316
  run(db, 'UPDATE tasks SET readable_id=? WHERE id=?', readableId, id);
300
- const handle = cardHandle(boardKey(board.root, board.slug), numberOf(db, id));
301
- if (namedBranch) run(db, 'UPDATE tasks SET branch_name = ? WHERE id = ?', handle ? namedBranch(handle) : `wt/${id}`, id);
317
+ const named = namedBranch && !arc ? cardBranch(db, id, readableId, project ? `${project.slug}/` : '') : null;
318
+ if (namedBranch) run(db, 'UPDATE tasks SET branch_name = ? WHERE id = ?', named ?? `wt/${id}`, id);
302
319
  const machine = spec.machine ?? arc?.machine ?? null;
303
320
  run(db, 'INSERT OR REPLACE INTO supercode_cards (task_id,machine,subtask_of,workspace_anchor,workspace_root,tags,requested_by_unverified) VALUES (?,?,?,?,?,?,?)', id, machine, arc?.id ?? null, declared.repository, checked?.workspaceRoot ?? null,
304
321
  tags.length ? JSON.stringify(tags) : null, spec.requestedByUnverified ? JSON.stringify(spec.requestedByUnverified) : null);
@@ -309,11 +326,15 @@ export function createCard(board, spec) {
309
326
  every: spec.every ?? null, goal: spec.goal === undefined || spec.goal === null ? null : spec.goal !== false, subtask_of: arc?.id ?? null });
310
327
  event(db, id, 'created', { creator: actorOf(), created_by: one(db, 'SELECT created_by FROM tasks WHERE id=?', id)?.created_by ?? null, assignee: spec.assignee || null,
311
328
  ...(spec.reviewer != null ? { reviewer: spec.reviewer } : {}), status, parents, tags, ...(spec.requestedByUnverified ? { requested_by_unverified: spec.requestedByUnverified } : {}), ...(machine ? { machine } : {}), ...(arc ? { subtask_of: arc.id } : {}) });
329
+ // its branch is claimed until its worktree is made: its machine takes none of that name that already exists
330
+ // (workspace.mjs branchClaim)
331
+ if (named) event(db, id, 'branch_named', { branch: named, repository: declared.repository ?? null });
312
332
  if ((spec.noStart || spec.adopt) && !arc) event(db, id, 'dispatch_held', { session: spec.adopt ?? null, reason: spec.adopt ? 'awaiting adoption' : 'created with --no-start' });
313
333
  if (parents.length) inheritSubscriptions(db, id, parents);
314
334
  if (arc) {
315
- // the arc's subscribers hear its subtasks, and the arc's session works them
316
- inheritSubscriptions(db, id, [arc.id], { workers: true });
335
+ // the arc's session works its subtasks and hears them; the arc's other followers follow the arc, not each subtask
336
+ // (a subtask's watchers are its own: its creator, its worker, whoever comments on it or is mentioned there)
337
+ inheritSubscriptions(db, id, [arc.id], { workers: true, only: 'worker' });
317
338
  event(db, arc.id, 'subtask_added', { subtask: id, title });
318
339
  }
319
340
  if (initial === 'running' && arc && entry.workflow.run) setStatus(db, entry.workflow, id, 'running');
@@ -421,12 +442,14 @@ export function extendClaim(db, id, { ttl, reason, extra = {} }) {
421
442
 
422
443
  /**
423
444
  * A completed card's workspace goes, as Hermes cleans up on completion: a scratch directory under the board's own
424
- * workspaces, or a worktree (its branch stays); never while a child still waits on it, never a `dir`.
445
+ * workspaces, or a worktree (its branch stays); never while a child still waits on it or another open card works in
446
+ * it, never a `dir`.
425
447
  */
426
448
  async function cleanupWorkspace(board, id) {
427
449
  const task = view(board, (db) => getTask(db, id));
428
450
  if (!task?.workspace_path || !isLocalWorkspace(task) || !['scratch', 'worktree'].includes(task.workspace_kind)) return;
429
- const waiting = view(board, (db) => one(db, "SELECT 1 FROM task_links l JOIN tasks t ON t.id = l.child_id WHERE l.parent_id = ? AND t.status NOT IN ('done', 'archived') LIMIT 1", id));
451
+ const waiting = view(board, (db) => one(db, "SELECT 1 FROM task_links l JOIN tasks t ON t.id = l.child_id WHERE l.parent_id = ? AND t.status NOT IN ('done', 'archived') LIMIT 1", id)
452
+ ?? one(db, "SELECT 1 FROM tasks WHERE workspace_path = ? AND id <> ? AND status NOT IN ('done', 'archived', 'cancelled') LIMIT 1", task.workspace_path, id));
430
453
  if (waiting || !existsSync(task.workspace_path)) return;
431
454
  // awaited, never a wait that holds the event loop
432
455
  if (task.workspace_kind === 'worktree') { await new Promise((resolve) => execFile('git', ['-C', task.workspace_path, 'worktree', 'remove', '--force', task.workspace_path], { encoding: 'utf8' }, () => resolve())); return; }
@@ -459,7 +482,12 @@ async function cleanupWorkspace(board, id) {
459
482
  export function setCaller(fn) { current().roles = fn; }
460
483
  const callerRoles = (db, cardId) => current().roles(db, cardId);
461
484
 
462
- export async function act(board, id, name, { roles = null, payload = {}, extra = {} } = {}) {
485
+ /**
486
+ * Fire `name` on card `id` as its caller (or `roles`), in one board write with what the move settles around it.
487
+ * `instead(db, error)`, when given, is asked in that same write when the event is refused: it answers another event's
488
+ * name to fire in its place (read and decided against the same state the refusal was), or null to keep the refusal.
489
+ */
490
+ export async function act(board, id, name, { roles = null, payload = {}, extra = {}, instead = null } = {}) {
463
491
  assertSourceReceipt(payload);
464
492
  const entry = await loadWorkflow(board.root);
465
493
  if (name === 'comment' && (!String(payload.body ?? '').trim() || !String(payload.author ?? '').trim())) throw new Error('a comment has an author and body');
@@ -472,9 +500,18 @@ export async function act(board, id, name, { roles = null, payload = {}, extra =
472
500
  if (payload.ci?.required && !payload.ci.ready && (name !== 'request_state' || evidencePhase === 'review_verdict')) throw new Error(payload.ci.reason ?? 'The PR candidate could not be verified.');
473
501
  const result = tx(board, (db) => {
474
502
  roles ??= callerRoles(db, id);
475
- const r = fire(db, board, entry, id, name, { roles, payload, extra });
503
+ let r;
504
+ try { r = fire(db, board, entry, id, name, { roles, payload, extra }); }
505
+ catch (error) {
506
+ const other = (error.refusal || error.noTransition) && instead ? instead(db, error, entry) : null;
507
+ if (!other) throw error;
508
+ r = { ...fire(db, board, entry, id, other.name, { roles, payload, extra }), instead: other };
509
+ }
476
510
  // Authorization and the comment row share one transaction: a refusal stores nothing.
477
- if (name === 'comment') r.commentId = Number(run(db, 'INSERT INTO task_comments (task_id, author, body, created_at) VALUES (?, ?, ?, ?)', id, payload.author.trim(), payload.body.trim(), now()).lastInsertRowid);
511
+ if (name === 'comment') {
512
+ r.commentId = Number(run(db, 'INSERT INTO task_comments (task_id, author, body, created_at) VALUES (?, ?, ?, ?)', id, payload.author.trim(), payload.body.trim(), now()).lastInsertRowid);
513
+ joinThread(db, entry, id, payload.author.trim(), payload.body);
514
+ }
478
515
  // the cards that wait on it, and its subtasks, see it change
479
516
  // Card blockers are mirrored to task_links by relation writes and document imports. Its parent key is the reverse
480
517
  // lookup; reading every card's blocked_by JSON on each public verb only rediscovers the same children.
@@ -485,7 +522,7 @@ export async function act(board, id, name, { roles = null, payload = {}, extra =
485
522
  await runEffects(board, result.effects);
486
523
  // A card this verb closed ends its agents' assignments; the verb hands the ends on itself (agent-declarations.mjs).
487
524
  if (result.moved) { const { endFinishedAgents } = await import('./agent-declarations.mjs'); await endFinishedAgents(board, [id]); }
488
- return { from: result.from, to: result.to, moved: result.moved, ...(result.commentId ? { commentId: result.commentId } : {}) };
525
+ return { from: result.from, to: result.to, moved: result.moved, ...(result.commentId ? { commentId: result.commentId } : {}), ...(result.instead ? { instead: result.instead } : {}) };
489
526
  }
490
527
 
491
528
  export async function requestStateMany(board, ids, payload = {}) {
@@ -496,6 +533,8 @@ export async function requestStateMany(board, ids, payload = {}) {
496
533
  const prepared = new Map(await Promise.all(targets.map(async id => {
497
534
  const roles = callerRoles(board.db, id);
498
535
  const phase = completionPhase(board, id, entry, 'request_state', payload, roles);
536
+ // As in complete: only a reviewer's verdict reads PRs from a state request; anyone else's would be dropped unread.
537
+ if (phase !== 'review_verdict' && [].concat(payload.pr ?? [], payload.pr_head ?? []).length) throw new Error(`A state request records no PRs: declare the arc's PRs on it with comment <arc> "<why>" --pr URL --pr-head SHA (one pair per PR), then request the state without them. Nothing was requested.`);
499
538
  view(board, db => validateEvent(db, board, entry, id, 'request_state', { roles, payload, localOnly: true }));
500
539
  const evidence = await inspectCompletionEvidence(board, id, entry, payload, phase);
501
540
  // An ordinary request mails a decision to the manager, not a completion.
@@ -686,7 +725,8 @@ function invalidateDescendants(db, board, id) {
686
725
  for (const row of rows) {
687
726
  if (!['ready', 'review', 'running', 'done'].includes(row.status)) continue;
688
727
  const resume = row.status === 'review' ? 'review' : row.status === 'running' ? retryStatus(db, row.id) : 'ready';
689
- const runId = row.status === 'running' ? endRun(db, row.id, { outcome: 'reclaimed', status: 'todo', summary: `ancestor ${id} reopened` }) : null;
728
+ // a card holds a current run in review as well as in running: whichever it holds is ended with the card's pull back
729
+ const runId = endRun(db, row.id, { outcome: 'reclaimed', status: 'todo', summary: `ancestor ${id} reopened` });
690
730
  run(db, 'UPDATE tasks SET completed_at = NULL, claim_lock = NULL, claim_expires = NULL, worker_pid = NULL, current_run_id = NULL, consecutive_failures = 0 WHERE id = ?', row.id);
691
731
  setStatus(db, workflow, row.id, 'todo');
692
732
  event(db, row.id, 'descendant_invalidated', { ancestor: id, prior_status: row.status, new_status: 'todo', resume_status: resume }, runId);
@@ -702,20 +742,75 @@ function invalidateDescendants(db, board, id) {
702
742
  export const complete = async (board, id, options = {}) => {
703
743
  assertSourceReceipt(options);
704
744
  const { result = null, summary = null, metadata = null, roles, runId = null, commit = null, branch = null, pr = null, pr_head = null, reviewed_sha = null } = options;
745
+ // Only a reviewer's verdict reads PRs from a completion (ci.completionPhase); anyone else's would be dropped unread,
746
+ // so it is refused: an arc's PRs are declared on the arc with comment --pr URL --pr-head SHA.
747
+ // A person's card closes only on that person's completion (personDone, as the board's platform event): no session,
748
+ // creator or manager completes it through this verb.
749
+ const person = view(board, (db) => personOf(getTask(db, id)?.assignee));
750
+ if (person) throw Object.assign(new Error(`${id} is assigned to ${person}: it closes when that person completes it in the Workplace (person-done), never by complete. Nothing was completed.`), { refusal: true });
751
+ if ([].concat(pr ?? [], pr_head ?? []).length && !view(board, (db) => roles ?? callerRoles(db, id)).includes('reviewer')) {
752
+ const arc = view(board, (db) => one(db, 'SELECT subtask_of FROM supercode_cards WHERE task_id = ?', id))?.subtask_of ?? id;
753
+ throw new Error(`complete records no PRs: declare the arc's PRs on it with comment ${arc} "<why>" --pr URL --pr-head SHA (one pair per PR), then complete ${id} without them. Nothing was completed.`);
754
+ }
705
755
  return await act(board, id, 'complete', { roles, payload: { result, summary, metadata, run_id: runId, commit, branch, pr, pr_head, reviewed_sha } });
706
756
  };
757
+ /**
758
+ * A person completed the card assigned to them (`person:<Teams principal>`), as the Workplace reported it: the board sends
759
+ * its own `complete` as the platform with the person and their answer, and the workflow closes the card only when that
760
+ * person is the one it is assigned to. The cards blocked on it are released by `dependency_resolved`. The same report
761
+ * again (a retried delivery) answers `already`.
762
+ */
763
+ export const personDone = async (board, id, { person, result, task = null } = {}) => {
764
+ const refused = (message) => Object.assign(new Error(message), { refusal: message });
765
+ if (!/^person:\S+$/.test(String(person ?? ''))) throw refused('a person\'s completion names the person: --person person:<principal>');
766
+ if (!String(result ?? '').trim()) throw refused('a person\'s completion carries their answer (--result)');
767
+ const done = view(board, (db) => {
768
+ const card = getTask(db, id);
769
+ if (!card) throw refused(`no card ${id}`);
770
+ // done, or archived after it (stored as archived): the person's own completion recorded on it is the same report
771
+ if (!['done', 'archived'].includes(card.status)) return false;
772
+ const completed = db.prepare("SELECT payload FROM task_events WHERE task_id = ? AND kind = 'completed' ORDER BY id DESC LIMIT 1").get(id);
773
+ return json(completed?.payload, {})?.person === person;
774
+ });
775
+ if (done) return { already: true };
776
+ return { already: false, ...await act(board, id, 'complete', { roles: ['platform'], payload: { person, result: String(result).trim(), task } }) };
777
+ };
707
778
  // 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`)
708
779
  export const block = async (board, id, { reason = null, on = null, waitingOn = null, kind = 'needs_input', roles, runId = null } = {}) =>
709
780
  await act(board, id, 'block', { roles, payload: { reason, on, kind, waiting_on: waitingOn, run_id: runId } });
710
781
  export const unblock = (board, id, { reason = null, on = null, roles } = {}) => act(board, id, 'unblock', { roles, payload: { reason, on } });
711
782
  export const requestReview = async (board, id, options = {}) => {
712
783
  assertSourceReceipt(options);
713
- const { summary = null, metadata = null, reviewer = null, force = false, expectedRunId = null, roles, commit = null, branch = null, pr = null, pr_head = null } = options;
714
- return await act(board, id, 'request_review', { roles, payload: { summary, metadata, reviewer, force, run_id: expectedRunId, commit, branch, pr, pr_head } });
784
+ const { summary = null, metadata = null, reviewer = null, force = false, expectedRunId = null, roles, commit = null, branch = null, pr = null, pr_head = null, plan = false } = options;
785
+ // What a session's request asks for is what it says, never its summary's words: `--plan` asks the plan review of the
786
+ // proposed ADR's PR it names, and one PR named with its head (--pr, --pr-head) is that PR's review. The workflow's
787
+ // clauses read `review`; a request naming several PRs asks neither.
788
+ const one = [].concat(pr ?? []).length === 1 && [].concat(pr_head ?? []).length === 1;
789
+ const review = plan ? 'plan' : one ? 'pr' : null;
790
+ return await act(board, id, 'request_review', { roles, payload: { summary, metadata, reviewer, force, run_id: expectedRunId, commit, branch, pr, pr_head, review, plan: Boolean(plan) } });
715
791
  };
716
792
  export const requestChanges = (board, id, { reason = null, reviewer = null, roles } = {}) => act(board, id, 'request_changes', { roles, payload: { reason, reviewer } });
717
793
  export const reopen = (board, id, { author = 'user', reason = null, roles } = {}) => act(board, id, 'reopen', { roles, payload: { author, reason } });
718
- export const reopenReview = (board, id, { roles, reason = null } = {}) => act(board, id, 'reopen_review', { roles, payload: { reason } });
794
+ /** The manager's door out of review: the card goes back to its coder with no verdict, its review run ending as returned
795
+ * (the workflow's `return_review`, where it declares one). */
796
+ export const returnReview = (board, id, { reason = null, roles } = {}) => act(board, id, 'return_review', { roles, payload: { reason } });
797
+ /**
798
+ * Void the latest verdict so the board reviews again; or, for a review whose run is still open with no verdict (its
799
+ * reviewer stalled, VO-3259 run 2408), restart that run: the workflow's own `reclaim` ends it and closes its reviewer
800
+ * session, the card stays in review, and the board's next review run resumes that same reviewer session with the
801
+ * round's brief. Nothing is voided there, because no verdict was given. Answers the move, with `restarted_run` when it
802
+ * restarted one.
803
+ */
804
+ export async function reopenReview(board, id, { roles, reason = null } = {}) {
805
+ // the refusal, the check that the open run is a reviewer's and the restart are one write: no verdict lands between
806
+ const moved = await act(board, id, 'reopen_review', { roles, payload: { reason }, instead: (db, _error, entry) => {
807
+ const run = currentRun(db, id);
808
+ const stays = transitionsFor(entry.workflow, 'review', 'reclaim').some((t) => !t.target || t.target === 'review');
809
+ return run && run.ended_at == null && run.metadata?.supercode?.slot === 'reviewer' && stays ? { name: 'reclaim', run: run.id } : null;
810
+ } });
811
+ const { instead, ...rest } = moved;
812
+ return instead ? { ...rest, restarted_run: instead.run } : rest;
813
+ }
719
814
  export const schedule = (board, id, reason = null, { roles } = {}) => act(board, id, 'schedule', { roles, payload: { reason } });
720
815
  export const promote = (board, id, { actor = 'user', reason = null, force = false, roles } = {}) => act(board, id, 'promote', { roles, payload: { actor, reason, force } });
721
816
  export const reclaim = (board, id, reason = null, { roles } = {}) => act(board, id, 'reclaim', { roles, payload: { reason } });
@@ -734,8 +829,13 @@ export async function specify(board, id, { title = null, body = null, readableId
734
829
  if (branch != null && ws.kind === 'scratch') throw new Error('a scratch workspace has no repository branch');
735
830
  declaration = { kind: ws.kind, ...(await declareWorkspace(ws.kind, ws.path, target, branch)), ...(machine != null && target !== task.machine ? { machine: target } : {}) };
736
831
  if (declaration.repository === task.workspace_anchor) declaration.branch ??= task.branch_name;
737
- const handle = cardHandle(boardKey(board.root, board.slug), task.number);
738
- if (declaration.kind === 'worktree') declaration.branch ??= handle ? cardBranch(handle, task.readable_id) : `wt/${id}`;
832
+ const named = task.subtask_of ? null : readableId ?? task.readable_id;
833
+ // a retarget's new branch is named as a new card's is, under its project's `<slug>/` (createCard)
834
+ if (declaration.kind === 'worktree' && declaration.branch == null && named) {
835
+ const project = task.project_id ? projectOf(board.root, task.project_id) : null;
836
+ Object.assign(declaration, { branch: cardBranch(board.db, id, named, project ? `${project.slug}/` : ''), named: true });
837
+ }
838
+ if (declaration.kind === 'worktree') declaration.branch ??= `wt/${id}`;
739
839
  if (declaration.kind === 'dir') declaration.path = (await checkWorkspace(board, { ...task, machine: target, workspace_kind: declaration.kind, workspace_anchor: declaration.repository, workspace_path: declaration.path, branch_name: declaration.branch })).path;
740
840
  }
741
841
  // an arc's new task lines are filed as its subtasks once the specify itself is accepted
@@ -851,11 +951,120 @@ export async function comment(board, id, author, body, { roles, pr = null, pr_he
851
951
  * Subscribe `address` (a supercode session address) to a card's events from now on. `role` is `creator` (told of
852
952
  * starts, completion and blocks) or `worker` (told of updates to the card it works).
853
953
  */
854
- export function subscribeIn(db, id, address, role = 'creator') {
954
+ export function subscribeIn(db, id, address, role = 'creator', { explicit = false, own = false } = {}) {
955
+ // a session that is an agent's main follows a card under its agent's address: one follower, one subscription
956
+ if (!['worker', 'dependency'].includes(role)) address = followerAddress(address);
855
957
  if (personCustody(db, address)) return;
856
958
  const cursor = one(db, 'SELECT COALESCE(MAX(id), 0) AS c FROM task_events WHERE task_id = ?', id).c;
857
959
  run(db, `INSERT OR IGNORE INTO kanban_notify_subs (task_id, platform, chat_id, thread_id, chat_type, delivery_mode, delivery_metadata, created_at, last_event_id)
858
- VALUES (?, 'supercode', ?, '', 'dm', 'notify+wake', ?, ?, ?)`, id, address, JSON.stringify({ role }), now(), cursor);
960
+ VALUES (?, 'supercode', ?, '', 'dm', 'notify+wake', ?, ?, ?)`, id, address, JSON.stringify({ role, ...(own ? { own: true } : {}) }), now(), cursor);
961
+ // the card's own creator's subscription says so (`own`): it is the card's, never a copy of its arc's followers
962
+ // (so does an explicit notify-subscribe: a follower who asked for this card follows it, not its arc)
963
+ own ||= explicit;
964
+ if (own) run(db, `UPDATE kanban_notify_subs SET delivery_metadata = json_set(delivery_metadata, '$.own', json('true'))
965
+ WHERE task_id = ? AND platform = 'supercode' AND chat_id = ? AND thread_id = ''`, id, address);
966
+ // opting in again (notify-subscribe) ends an unsubscribe, from now on: what it missed meanwhile is not told
967
+ if (explicit) run(db, `UPDATE kanban_notify_subs SET delivery_metadata = json_remove(delivery_metadata, '$.unsubscribed'), last_event_id = MAX(last_event_id, ?)
968
+ WHERE task_id = ? AND platform = 'supercode' AND chat_id = ? AND thread_id = '' AND json_extract(delivery_metadata, '$.unsubscribed')`, cursor, id, address);
969
+ // A follower that takes a stronger role on the card hears it in that role from now on (its unsubscribe stands): a
970
+ // participant or creator that is the board's manager hears it as the manager, never as both. What the new role hears
971
+ // is told from now on, never the card's history (the cursor moves to the card's last event).
972
+ // A worker or a dependency follower takes over a participant's subscription, as before.
973
+ const outranked = FOLLOWER_RANKS.includes(role) ? FOLLOWER_RANKS.slice(FOLLOWER_RANKS.indexOf(role) + 1) : ['participant'];
974
+ // the role it held is kept beside it (`also`): what that role heard, the follower still hears (a manager that created
975
+ // a card is told its audit rounds as its creator was)
976
+ // (only a follower's role is kept so, appended to what it kept before; a worker or dependency hears its own kinds)
977
+ if (!outranked.length) return;
978
+ const held = all(db, `SELECT delivery_metadata FROM kanban_notify_subs WHERE task_id = ? AND platform = 'supercode' AND chat_id = ? AND thread_id = ''
979
+ AND json_extract(delivery_metadata, '$.role') IN (${outranked.map(() => '?').join(', ')})`, id, address, ...outranked)[0];
980
+ if (!held) return;
981
+ const meta = json(held.delivery_metadata, {}) ?? {};
982
+ const also = FOLLOWER_RANKS.includes(role) ? [...new Set([...[].concat(meta.also ?? []), meta.role ?? 'creator'])].filter((r) => r !== role) : [].concat(meta.also ?? []);
983
+ run(db, `UPDATE kanban_notify_subs SET delivery_metadata = ?, last_event_id = MAX(last_event_id, ?) WHERE task_id = ? AND platform = 'supercode' AND chat_id = ? AND thread_id = ''`,
984
+ JSON.stringify({ ...meta, role, ...(also.length ? { also } : {}) }), cursor, id, address);
985
+ }
986
+
987
+ /** The roles a follower of a card holds, strongest first; a follower holds one (subscribeIn). */
988
+ const FOLLOWER_RANKS = ['manager', 'creator', 'participant'];
989
+
990
+ /**
991
+ * The one address a follower is subscribed under: a session that is the main session of an agent this call read (the
992
+ * board's managers, read before each verb: cli readAgentMains) follows as its agent's address, so a manager commenting
993
+ * or mentioned from its main session is not a second follower beside its agent (sc:<machine>:agent:manager). Any other
994
+ * address is itself.
995
+ */
996
+ export function followerAddress(address) {
997
+ for (const [agent, main] of current().agentMains ?? []) if (typeof main === 'string' && main === address) return agent;
998
+ return address;
999
+ }
1000
+
1001
+ /**
1002
+ * A card's thread is followed as an issue tracker's is: its author and everyone it @mentions follow the card from this
1003
+ * comment on (`participant`, unless they already follow it in another role), and a follower who had unsubscribed and is
1004
+ * mentioned, or comments, follows it again. Every comment then reaches every follower but its author (notify).
1005
+ */
1006
+ function joinThread(db, entry, id, author, body) {
1007
+ const said = one(db, "SELECT MAX(id) AS id FROM task_events WHERE task_id = ? AND kind = 'commented'", id)?.id ?? null;
1008
+ // the board's own comments (an unsubscribe's) bring no one in, whoever they name
1009
+ if (said == null || author === 'board') return;
1010
+ if (/^sc:/.test(author)) follow(db, id, author, said);
1011
+ for (const address of mentioned(db, entry, body)) if (address !== author) follow(db, id, address, said - 1);
1012
+ }
1013
+
1014
+ /** Follow card `id` from event `cursor` on: a new follower as a participant, an unsubscribed one again. */
1015
+ function follow(db, id, address, cursor) {
1016
+ address = followerAddress(address);
1017
+ if (personCustody(db, address)) return;
1018
+ const row = one(db, "SELECT delivery_metadata, last_event_id FROM kanban_notify_subs WHERE task_id = ? AND platform = 'supercode' AND chat_id = ? AND thread_id = ''", id, address);
1019
+ if (!row) {
1020
+ run(db, `INSERT INTO kanban_notify_subs (task_id, platform, chat_id, thread_id, chat_type, delivery_mode, delivery_metadata, created_at, last_event_id)
1021
+ VALUES (?, 'supercode', ?, '', 'dm', 'notify+wake', ?, ?, ?)`, id, address, JSON.stringify({ role: 'participant' }), now(), cursor);
1022
+ return;
1023
+ }
1024
+ const meta = json(row.delivery_metadata, {}) ?? {};
1025
+ if (!meta.unsubscribed) return;
1026
+ const { unsubscribed: _u, ...kept } = meta;
1027
+ run(db, "UPDATE kanban_notify_subs SET delivery_metadata = ?, last_event_id = ? WHERE task_id = ? AND platform = 'supercode' AND chat_id = ? AND thread_id = ''",
1028
+ JSON.stringify(kept), Math.max(Number(row.last_event_id), cursor), id, address);
1029
+ }
1030
+
1031
+ /** The addresses a comment @mentions: `@manager` (the board's managers), a session's or agent's full address
1032
+ * (`@sc:<machine>:<kind>:<name>`), or an agent's name (`@<name>`) that a board address already names. Other words
1033
+ * after an @ (a package scope, an e-mail) name no one. */
1034
+ export function mentioned(db, entry, body) {
1035
+ const managers = [].concat(entry?.params?.managers ?? []).map(String);
1036
+ const found = new Set();
1037
+ for (const [, name] of String(body ?? '').matchAll(/(?:^|[^\w@/.-])@([A-Za-z0-9_][\w.:-]*[\w])/g)) {
1038
+ if (name === 'manager') { managers.forEach((m) => found.add(m)); continue; }
1039
+ if (/^sc:[^:\s]+:[^:\s]+:[^:\s]+$/.test(name)) { found.add(name); continue; }
1040
+ const known = [...managers, ...all(db, "SELECT DISTINCT chat_id FROM kanban_notify_subs WHERE platform = 'supercode' AND chat_id LIKE ?", `%:agent:${name}`).map((r) => r.chat_id)]
1041
+ .filter((address) => address.endsWith(`:agent:${name}`));
1042
+ for (const address of new Set(known)) found.add(address);
1043
+ }
1044
+ return [...found];
1045
+ }
1046
+
1047
+ /** How a follower is mentioned: an agent by its name (`manager`), a session by its address. */
1048
+ export const mentionName = (address) => /^sc:[^:]+:agent:([^:]+)$/.exec(String(address))?.[1] ?? String(address);
1049
+
1050
+ /**
1051
+ * `address` stops following card `id`: its subscription is kept, marked unsubscribed, so no round subscribes it again
1052
+ * (a manager or a worker is subscribed each round), and the board says so on the card with how to bring it back, a
1053
+ * comment every remaining follower hears. A mention or its own comment makes it follow again (follow).
1054
+ */
1055
+ export function unsubscribe(board, id, address) {
1056
+ return tx(board, (db) => {
1057
+ const row = one(db, "SELECT delivery_metadata FROM kanban_notify_subs WHERE task_id = ? AND platform = 'supercode' AND chat_id = ? AND thread_id = ''", id, address);
1058
+ const meta = json(row?.delivery_metadata, {}) ?? {};
1059
+ if (!row || meta.unsubscribed) return false;
1060
+ run(db, "UPDATE kanban_notify_subs SET delivery_metadata = ? WHERE task_id = ? AND platform = 'supercode' AND chat_id = ? AND thread_id = ''", JSON.stringify({ ...meta, unsubscribed: true }), id, address);
1061
+ // the board's own comment, written with the mark: no workflow's comment verb stands between them
1062
+ const name = mentionName(address), body = `${name} unsubscribed; mention @${name} to bring them back.`;
1063
+ // the event first, as a verb's comment is: its notice reads the comment written at or after it
1064
+ event(db, id, 'commented', { author: 'board', len: body.length, by: 'board' });
1065
+ run(db, 'INSERT INTO task_comments (task_id, author, body, created_at) VALUES (?, ?, ?, ?)', id, 'board', body, now());
1066
+ return true;
1067
+ });
859
1068
  }
860
1069
 
861
1070
  /** Reconcile only the current worker's audience, inside the binding transaction. */
@@ -881,12 +1090,8 @@ export function replaceWorkerSubscriptions(db, id, address) {
881
1090
  return true;
882
1091
  }
883
1092
 
884
- export function subscribe(board, id, address, role = 'creator') {
885
- return tx(board, (db) => { must(db, id); subscribeIn(db, id, address, role); });
886
- }
887
-
888
- export function unsubscribe(board, id, address) {
889
- return tx(board, (db) => run(db, "DELETE FROM kanban_notify_subs WHERE task_id = ? AND platform = 'supercode' AND chat_id = ?", id, address).changes > 0);
1093
+ export function subscribe(board, id, address, role = 'creator', { own = false } = {}) {
1094
+ return tx(board, (db) => { must(db, id); subscribeIn(db, id, address, role, { explicit: true, own }); });
890
1095
  }
891
1096
 
892
1097
  function dependencyRecipient(db, id) {
@@ -925,20 +1130,27 @@ export function reconcileDependencySubscriptions(db) {
925
1130
  const where = "WHERE task_id=? AND platform='supercode' AND chat_id=? AND thread_id=?";
926
1131
  const identity = [row.task_id, row.chat_id, row.thread_id];
927
1132
  if (!owner || owner.detached || owner.archived || ['done', 'cancelled', 'archived'].includes(owner.status)) run(db, `DELETE FROM kanban_notify_subs ${where}`, ...identity);
928
- else if (address && (address !== row.chat_id || metadata.waiting_recipient)) run(db, `UPDATE kanban_notify_subs SET chat_id=?,delivery_metadata=? ${where}`, address, JSON.stringify({ ...metadata, notice_wait: undefined, waiting_recipient: false, failures: 0 }), ...identity);
1133
+ // a subscription moved to its arc's new session starts from the card's last event: the new session is told what
1134
+ // happens from now on, never the card's history the old one was told or missed
1135
+ // (a subscription that waited for its arc to have a session keeps its cursor: what it waited to tell is still told)
1136
+ else if (address && (address !== row.chat_id || metadata.waiting_recipient)) run(db, `UPDATE kanban_notify_subs SET chat_id=?,delivery_metadata=?,last_event_id=CASE WHEN ? THEN MAX(last_event_id,(SELECT COALESCE(MAX(id),0) FROM task_events WHERE task_id=?)) ELSE last_event_id END ${where}`,
1137
+ address, JSON.stringify({ ...metadata, notice_wait: undefined, waiting_recipient: false, failures: 0 }), address !== row.chat_id ? 1 : 0, row.task_id, ...identity);
929
1138
  else if (!address && !metadata.waiting_recipient) run(db, `UPDATE kanban_notify_subs SET delivery_metadata=? ${where}`, JSON.stringify({ ...metadata, waiting_recipient: true }), ...identity);
930
1139
  }
931
1140
  }
932
1141
 
933
- /** A child hears what its parents' subscribers hear (Hermes copies them at link time); a subtask its arc's session too. */
934
- function inheritSubscriptions(db, child, parents, { workers = false } = {}) {
1142
+ /** A child hears what its parents' subscribers hear (Hermes copies them at link time); a subtask only its arc's session. */
1143
+ function inheritSubscriptions(db, child, parents, { workers = false, only = null } = {}) {
935
1144
  const cursor = one(db, 'SELECT COALESCE(MAX(id), 0) AS c FROM task_events WHERE task_id = ?', child).c;
936
1145
  for (const p of parents) {
937
1146
  for (const s of all(db, 'SELECT * FROM kanban_notify_subs WHERE task_id = ?', p)) {
938
1147
  const metadata = json(s.delivery_metadata, {}) ?? {}, role = metadata.role;
939
1148
  if ((role === 'worker' && !workers) || role === 'dependency') continue; // a parent's session does not work the child
1149
+ if (only && role !== only) continue;
940
1150
  // A child's subscription is a new wait, not the parent's retained delivery state.
941
1151
  delete metadata.notice_wait;
1152
+ // a parent's own creator or kept roles are not the child's
1153
+ delete metadata.own; delete metadata.also;
942
1154
  run(db, `INSERT OR IGNORE INTO kanban_notify_subs (task_id, platform, chat_id, thread_id, user_id, user_id_alt, chat_type, notifier_profile, delivery_mode, delivery_metadata, created_at, last_event_id)
943
1155
  VALUES (?, ?, ?, ?, ?, ?, COALESCE(?, 'dm'), ?, COALESCE(?, 'notify'), ?, ?, ?)`,
944
1156
  child, s.platform, s.chat_id, s.thread_id, s.user_id, s.user_id_alt, s.chat_type, s.notifier_profile, s.delivery_mode, JSON.stringify(metadata), now(), cursor);
@@ -1014,22 +1226,25 @@ export function cardContext(db, id, options = {}) {
1014
1226
 
1015
1227
 
1016
1228
 
1017
- /** Hermes's swarm: a completed root as the shared blackboard, workers after it, a verifier after them all, a synthesizer last. */
1018
- export function swarm(board, { goal, workers, verifier, synthesizer, tenant = null, createdBy = null, priority = 0, idempotencyKey = null }) {
1229
+ /** Hermes's swarm: a completed root as the shared blackboard, workers after it, a verifier after them all, a synthesizer last.
1230
+ * Its cards are named by the swarm's readable id: the root by it, then `<id>-worker-<n>`, `<id>-verify`, `<id>-synthesis`. */
1231
+ export function swarm(board, { goal, readableId, workers, verifier, synthesizer, tenant = null, createdBy = null, priority = 0, idempotencyKey = null }) {
1019
1232
  if (!String(goal ?? '').trim()) throw new Error('a swarm has a goal');
1020
1233
  if (!workers.length) throw new Error('a swarm has at least one --worker PROFILE:TITLE[:SKILL,SKILL]');
1234
+ requireReadableId(readableId);
1235
+ for (const name of [...workers.map((_, n) => `${readableId}-worker-${n + 1}`), `${readableId}-verify`, `${readableId}-synthesis`]) validateReadableId(name);
1021
1236
  const protocol = (root) => `\n\n## Swarm protocol\n- Swarm root / shared blackboard: \`${root}\`.\n- Read sibling/parent handoffs from the board's context before working.\n`
1022
1237
  + `- Put machine-readable facts in completion metadata.\n- Put cross-worker notes on the root card as comments.\n- Goal: ${goal.trim()}\n`;
1023
- const root = createCard(board, { title: `Swarm: ${goal.trim()}`.slice(0, 200), body: goal, tenant, createdBy, priority, idempotencyKey, initialStatus: 'blocked' });
1238
+ const root = createCard(board, { title: `Swarm: ${goal.trim()}`.slice(0, 200), readableId, body: goal, tenant, createdBy, priority, idempotencyKey, initialStatus: 'blocked' });
1024
1239
  if (root.existing) return { root_id: root.id, existing: true };
1025
1240
  tx(board, (db) => {
1026
1241
  run(db, "UPDATE tasks SET status = 'done', completed_at = ? WHERE id = ?", now(), root.id);
1027
1242
  const runId = synthesizeRun(db, root.id, { outcome: 'completed', summary: 'swarm blackboard', metadata: { swarm: true } });
1028
1243
  event(db, root.id, 'completed', { result_len: 0, summary: 'swarm blackboard' }, runId);
1029
1244
  });
1030
- const workerIds = workers.map((w) => createCard(board, { title: w.title, body: `${w.title}${protocol(root.id)}`, assignee: w.profile, skills: w.skills, parents: [root.id], tenant, createdBy, priority }).id);
1031
- const verifierId = createCard(board, { title: 'Verify swarm outputs', body: `Verify the workers' outputs.${protocol(root.id)}`, assignee: verifier, parents: workerIds, tenant, createdBy, priority }).id;
1032
- const synthesizerId = createCard(board, { title: 'Synthesize swarm outputs', body: `Synthesize the verified outputs.${protocol(root.id)}`, assignee: synthesizer, parents: [verifierId], tenant, createdBy, priority }).id;
1245
+ const workerIds = workers.map((w, n) => createCard(board, { title: w.title, readableId: `${readableId}-worker-${n + 1}`, body: `${w.title}${protocol(root.id)}`, assignee: w.profile, skills: w.skills, parents: [root.id], tenant, createdBy, priority }).id);
1246
+ const verifierId = createCard(board, { title: 'Verify swarm outputs', readableId: `${readableId}-verify`, body: `Verify the workers' outputs.${protocol(root.id)}`, assignee: verifier, parents: workerIds, tenant, createdBy, priority }).id;
1247
+ const synthesizerId = createCard(board, { title: 'Synthesize swarm outputs', readableId: `${readableId}-synthesis`, body: `Synthesize the verified outputs.${protocol(root.id)}`, assignee: synthesizer, parents: [verifierId], tenant, createdBy, priority }).id;
1033
1248
  return { root_id: root.id, worker_ids: workerIds, verifier_id: verifierId, synthesizer_id: synthesizerId };
1034
1249
  }
1035
1250
 
@@ -1081,13 +1296,14 @@ export function runs(db, id, { stateType = null, stateName = null } = {}) {
1081
1296
  return all(db, `SELECT * FROM task_runs WHERE task_id = ?${where} ORDER BY id`, ...(where ? [id, stateName] : [id])).map((r) => ({ ...r, metadata: json(r.metadata, null) }));
1082
1297
  }
1083
1298
 
1084
- /** Retain assignment facts in checkpoints, then delete old thread events of done and archived cards. */
1299
+ /** Retain assignment facts in checkpoints, then delete old thread events of done and archived cards; their creation and
1300
+ * branch records (branch_named, worktree_made) stay, by which a reopened card finds its worktree folder. */
1085
1301
  export function gcEvents(board, seconds) {
1086
1302
  return tx(board, db=>{
1087
1303
  const cut=now()-seconds;
1088
1304
  const cards=all(db,`SELECT DISTINCT task_id FROM task_events WHERE kind IN (${AGENT_KIND_LIST.map(()=>'?').join(',')}) AND created_at<? AND task_id IN (SELECT id FROM tasks WHERE status IN ('done','archived'))`,...AGENT_KIND_LIST,cut);
1089
1305
  let compacted=0;for(const card of cards)compacted+=compactCardAgentEvents(db,card.task_id);
1090
- return compacted+Number(run(db,"DELETE FROM task_events WHERE kind!='created' AND created_at<? AND task_id IN (SELECT id FROM tasks WHERE status IN ('done','archived'))",cut).changes);
1306
+ return compacted+Number(run(db,"DELETE FROM task_events WHERE kind NOT IN ('created','branch_named','worktree_made') AND created_at<? AND task_id IN (SELECT id FROM tasks WHERE status IN ('done','archived'))",cut).changes);
1091
1307
  });
1092
1308
  }
1093
1309
 
@@ -24,11 +24,7 @@ export async function* followBoardPublications({root,after,signal}) {
24
24
  const settled=new Map();
25
25
  const statOf=path=>{try{const s=statSync(path);return `${s.size}:${s.mtimeMs}`;}catch{return '-';}};
26
26
  const notify=(name=null)=>{
27
- // A store in rollback-journal mode is written in place, and on macOS FSEvents names no change to the store file
28
- // itself, only its journal coming and going (a board's sqlite store, kept in `delete` journal mode; VO-3761, where
29
- // every start waited out RESETTLE_MS): a journal's notice is its store's, read against the store's own stamp.
30
- const store=name!==null&&name.endsWith('-journal')?name.slice(0,-'-journal'.length):name;
31
- const named=store===null?[]:stores.filter(path=>basename(path)===store);
27
+ const named=name===null?[]:stores.filter(path=>basename(path)===name);
32
28
  // A store file no pass stamps (the root's kanban.db where only named boards exist) reads '-' until one does.
33
29
  if(named.length&&named.every(path=>statOf(path)===(settled.get(path)??'-')))return;
34
30
  dirty=true;wake?.();
@@ -44,12 +40,12 @@ export async function* followBoardPublications({root,after,signal}) {
44
40
  const register=slugs=>{
45
41
  for(const entry of watchers.values())entry.accept=[];
46
42
  stores=[join(root,'kanban.db'),join(root,'kanban.db-wal')];
47
- directory(root,name=>['kanban.db','kanban.db-wal','kanban.db-journal','kanban'].includes(name));
43
+ directory(root,name=>['kanban.db','kanban.db-wal','kanban'].includes(name));
48
44
  directory(join(root,'kanban'),name=>['boards','ztrack.json','board'].includes(name));
49
45
  directory(join(root,'kanban','boards'),()=>true);
50
46
  for(const slug of slugs){
51
47
  const paths=backingPaths(root,slug);
52
- file(paths.sqlite);file(`${paths.sqlite}-wal`);file(`${paths.sqlite}-journal`);file(paths.ztrack);stores.push(paths.sqlite,`${paths.sqlite}-wal`);
48
+ file(paths.sqlite);file(`${paths.sqlite}-wal`);file(paths.ztrack);stores.push(paths.sqlite,`${paths.sqlite}-wal`);
53
49
  directory(paths.files,name=>name.endsWith('.json'));
54
50
  try {const cfg=JSON.parse(readFileSync(paths.ztrack,'utf8'));const document=resolve(dirname(paths.ztrack),cfg.document);file(document);const own=basename(document);directory(dirname(document),name=>name===`${own}.archive.md`||(name.startsWith(`${own}.archive-`)&&name.endsWith('.md')));}catch{/* other backing */}
55
51
  }
package/board/files.mjs CHANGED
@@ -12,7 +12,7 @@ import { fillReadableIds } from './readable-id.mjs';
12
12
  import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
13
13
  import { fenced } from './fence.mjs';
14
14
  import { markRemovedHeads } from './published.mjs';
15
- import { CARD_NUMBERS } from './store.mjs';
15
+ import { CARD_NUMBERS, unnumberTasks } from './store.mjs';
16
16
  import { createHash } from 'node:crypto';
17
17
  import { join } from 'node:path';
18
18
  import { DatabaseSync } from 'node:sqlite';
@@ -133,6 +133,9 @@ class FilesBoard {
133
133
  cards.sort((a, b) => madeAt(a) - madeAt(b) || (String(a.id) < String(b.id) ? -1 : String(a.id) > String(b.id) ? 1 : 0));
134
134
  for (const card of cards) for (const [table, rows] of Object.entries(card.tables ?? {})) if (table !== CARD_NUMBERS) insert(table, rows);
135
135
  for (const [table, rows] of Object.entries(state?.tables ?? {})) insert(table, rows);
136
+ // A task whose file names no number keeps none: only cards are numbered.
137
+ unnumberTasks(mem, new Set(cards.filter((card) => card.tables?.[CARD_NUMBERS]?.length).map((card) => card.id)),
138
+ (state?.sequence ?? []).find((row) => row.name === CARD_NUMBERS)?.seq ?? 0);
136
139
  // Loading inserts every card, which marks every card changed (published.mjs). The first load of this projection keeps
137
140
  // those marks: its next write compares each card with its published head once (a named limit: each projection opened
138
141
  // compares every card once, as each load already reads every card's file). A reload keeps the marks not yet