@volter/supercode-orchestrator 0.5.207 → 0.5.209

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/engine.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { allocateReadableId } from './readable-id.mjs';
1
2
  // The board's transitions (docs/architecture/orchestrator.md §2.9): what a card may do, written for supercode and
2
3
  // kept to Hermes Kanban's lifecycle so the two read each other's boards. Statuses: triage · todo · scheduled ·
3
4
  // ready · running · blocked · review · done · archived. A card with a parent that is not done waits in `todo`
@@ -10,18 +11,20 @@
10
11
  import { current } from './call.mjs';
11
12
  import { detachedCard, detachedSession, personCustody, sessionAddress, sessionOperation } from './custody.mjs';
12
13
  import { executionOf } from './session-work.mjs';
14
+ import { cardState, cardStateSql, holdDescription } from './card-state.mjs';
13
15
  import { spawn, execFile } from 'node:child_process';
14
16
  import { existsSync, rmSync } from 'node:fs';
15
17
  import { join, resolve } from 'node:path';
16
18
  import { checkWorkspace, assertWorkspaceOwner, declareWorkspace, isLocalWorkspace, plainWorkspace } from './workspace.mjs';
17
19
  import { DatabaseSync } from 'node:sqlite';
18
- import { view, actorOf, principalOf, CARD_NUMBERS, claimer, contention, event, json, newTaskId, now, retryContention, tx } from './store.mjs';
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';
19
22
  import { assertSourceReceipt, completionPhase, inspectCompletionEvidence, observeCompletionEvidence, withCompletionEvidence } from './ci.mjs';
20
23
  import { addDependency, retireSupersededRuns, refreshCardInputs, requestSessionClose, reviewerOf, eventTo, fire, validateEvent, loadWorkflow, setStatus, settle, startNew, statusOf, runEvent, workflowFor } from './workflow.mjs';
21
24
  import { refuseCredentialDump, withheldIfDump } from './credential-guard.mjs';
22
25
  import { AGENT_KIND_LIST, compactCardAgentEvents } from './published.mjs';
23
26
 
24
- export const STATUSES = ['triage', 'todo', 'scheduled', 'ready', 'running', 'blocked', 'review', 'done', 'archived'];
27
+ export const STATUSES = ['triage', 'todo', 'scheduled', 'ready', 'running', 'blocked', 'paused', 'review', 'done', 'archived'];
25
28
  export const WORKSPACE_KINDS = ['scratch', 'dir', 'worktree'];
26
29
  /** A card blocked this many times in a row for the same kind goes to triage for a person (Hermes's limit). */
27
30
  export const BLOCK_RECURRENCE_LIMIT = 2;
@@ -38,23 +41,24 @@ const one = (db, sql, ...args) => db.prepare(sql).get(...args);
38
41
  const all = (db, sql, ...args) => db.prepare(sql).all(...args);
39
42
  const run = (db, sql, ...args) => db.prepare(sql).run(...args);
40
43
 
41
- // A card's number (#42): what the store gave it when it was made (store.mjs layOutNumbers). A person names a card by
42
- // it wherever its id is taken (`42` or `#42`), and every view of a card shows it first, before the id.
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.
43
47
  /** Card `n`'s id on this board, or null when no card here has that number. */
44
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;
45
49
  /** The number of card `id`, or null. */
46
50
  export const numberOf = (db, id) => one(db, `SELECT number FROM ${CARD_NUMBERS} WHERE task_id = ?`, id)?.number ?? null;
47
51
  /** The numbers of cards `ids`: id → number. */
48
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]) : []);
49
- /** A card as a person reads it: its number first, then its id (`#42 t_4406c88a`). */
50
- export const cardLabel = (number, id) => (number ? `#${number} ${id}` : String(id));
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
55
 
52
56
  export function getTask(db, id) {
53
57
  const row = one(db, 'SELECT * FROM tasks WHERE id = ?', id);
54
58
  if (!row) return null;
55
59
  const ours = one(db, 'SELECT * FROM supercode_cards WHERE task_id = ?', id);
56
- return { ...row, number: numberOf(db, id), workspace_anchor: ours?.workspace_anchor ?? null, blocked_by: json(ours?.blocked_by, []) ?? [], paused: json(ours?.paused, null), detached: json(ours?.detached, null), archived: Boolean(ours?.archived), every: ours?.every ?? null, skills: json(row.skills, []) ?? [], machine: ours?.machine ?? null, subtask_of: ours?.subtask_of ?? null,
57
- tags: json(ours?.tags, null) ?? (row.tenant ? [row.tenant] : []), sponsor: ours?.sponsor ?? null, sponsor_evidence: json(ours?.sponsor_evidence, null), requested_by_unverified: json(ours?.requested_by_unverified, null) };
60
+ return { ...row, number: numberOf(db, id), stage: ours?.stage ?? row.status, workspace_anchor: ours?.workspace_anchor ?? null, blocked_by: json(ours?.blocked_by, []) ?? [], paused: json(ours?.paused, null), detached: json(ours?.detached, null), archived: Boolean(ours?.archived), every: ours?.every ?? null, skills: json(row.skills, []) ?? [], machine: ours?.machine ?? null, subtask_of: ours?.subtask_of ?? null,
61
+ tags: json(ours?.tags, null) ?? (row.tenant ? [row.tenant] : []), requested_by_unverified: json(ours?.requested_by_unverified, null) };
58
62
  }
59
63
 
60
64
  function must(db, id) {
@@ -243,8 +247,6 @@ export function createCard(board, spec) {
243
247
  if (spec.provider && !spec.model) throw new Error('a provider needs a model');
244
248
  // Several ordered tags (C4); Hermes's single `tenant` is the first of them, as its export lowers them.
245
249
  const tags = [...new Set([spec.tenant, ...(spec.tags ?? [])].filter((tag) => typeof tag === 'string' && tag.trim()).map((tag) => tag.trim()))];
246
- // A sponsor is a person, never an agent or an automation (Teams refuses any principal that is not a person).
247
- if (spec.sponsor && /^(agent_|svc_|service_)/.test(spec.sponsor)) throw new Error('a sponsor is a person; an agent or an automation cannot sponsor a card');
248
250
  return tx(board, (db) => {
249
251
  if (spec.idempotencyKey) {
250
252
  const got = one(db, "SELECT id FROM tasks WHERE idempotency_key = ? AND status != 'archived' ORDER BY created_at DESC LIMIT 1", spec.idempotencyKey);
@@ -272,14 +274,10 @@ export function createCard(board, spec) {
272
274
  }
273
275
  assertWorkspaceOwner(db, workspaceTask, checked);
274
276
  // The repository declaration stays separate from the resolved worktree path.
275
- if (project) {
276
- if (ws.kind === 'scratch') ws.kind = 'worktree';
277
- if (ws.kind === 'worktree' && !spec.branch) {
278
- const t = title.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40).replace(/-+$/, '');
279
- spec = { ...spec, branch: `${project.slug}/${id}${t ? `-${t}` : ''}` };
280
- }
281
- }
282
- spec = { ...spec, project: project?.id ?? null, branch: spec.branch ?? (ws.kind === 'worktree' ? `wt/${id}` : null) };
277
+ 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;
280
+ spec = { ...spec, project: project?.id ?? null };
283
281
  const t = now();
284
282
  run(db, `INSERT INTO tasks (id, title, body, assignee, status, priority, created_by, created_at, workspace_kind, workspace_path, branch_name,
285
283
  tenant, idempotency_key, max_runtime_seconds, skills, max_retries, model_override, provider_override, reasoning_effort, session_id,
@@ -289,16 +287,20 @@ export function createCard(board, spec) {
289
287
  spec.branch ?? null, tags[0] ?? null, spec.idempotencyKey ?? null, spec.maxRuntime ?? null,
290
288
  spec.skills?.length ? JSON.stringify(spec.skills) : null, spec.maxRetries ?? null, spec.model ?? null, spec.provider ?? null,
291
289
  spec.reasoning ?? null, spec.sessionId ?? null, spec.project ?? null, spec.goal ? 1 : 0, spec.goal?.maxTurns ?? null);
290
+ const readableId = allocateReadableId(db, title, spec.readableId ?? null, id);
291
+ run(db, 'UPDATE tasks SET readable_id=? WHERE id=?', readableId, id);
292
+ const handle = cardHandle(boardKey(board.root, board.slug), numberOf(db, id));
293
+ if (namedBranch) run(db, 'UPDATE tasks SET branch_name = ? WHERE id = ?', handle ? namedBranch(handle) : `wt/${id}`, id);
292
294
  const machine = spec.machine ?? arc?.machine ?? null;
293
- run(db, 'INSERT OR REPLACE INTO supercode_cards (task_id,machine,subtask_of,workspace_anchor,workspace_root,tags,sponsor,sponsor_evidence,requested_by_unverified) VALUES (?,?,?,?,?,?,?,?,?)', id, machine, arc?.id ?? null, declared.repository, checked?.mainRoot ?? null,
294
- tags.length ? JSON.stringify(tags) : null, spec.sponsor ?? null, spec.sponsorEvidence ? JSON.stringify(spec.sponsorEvidence) : null, spec.requestedByUnverified ? JSON.stringify(spec.requestedByUnverified) : null);
295
+ run(db, 'INSERT OR REPLACE INTO supercode_cards (task_id,machine,subtask_of,workspace_anchor,workspace_root,tags,requested_by_unverified) VALUES (?,?,?,?,?,?,?)', id, machine, arc?.id ?? null, declared.repository, checked?.mainRoot ?? null,
296
+ tags.length ? JSON.stringify(tags) : null, spec.requestedByUnverified ? JSON.stringify(spec.requestedByUnverified) : null);
295
297
  for (const p of parents) addDependency(db, entry, id, p);
296
298
  // where it starts is the workflow's `initial` (Hermes: triage, blocked, todo behind open parents, else ready)
297
299
  // the create's own words: whether it asked to be a goal card (null: the workflow decides), triage, its arc
298
300
  const status = startNew(db, board, entry, getTask(db, id), { triage: Boolean(spec.triage), initial_status: initial,
299
301
  every: spec.every ?? null, goal: spec.goal === undefined || spec.goal === null ? null : spec.goal !== false, subtask_of: arc?.id ?? null });
300
302
  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,
301
- ...(spec.reviewer != null ? { reviewer: spec.reviewer } : {}), status, parents, tags, ...(spec.sponsor ? { sponsor: spec.sponsor } : {}), ...(spec.requestedByUnverified ? { requested_by_unverified: spec.requestedByUnverified } : {}), ...(machine ? { machine } : {}), ...(arc ? { subtask_of: arc.id } : {}) });
303
+ ...(spec.reviewer != null ? { reviewer: spec.reviewer } : {}), status, parents, tags, ...(spec.requestedByUnverified ? { requested_by_unverified: spec.requestedByUnverified } : {}), ...(machine ? { machine } : {}), ...(arc ? { subtask_of: arc.id } : {}) });
302
304
  if ((spec.noStart || spec.adopt) && !arc) event(db, id, 'dispatch_held', { session: spec.adopt ?? null, reason: spec.adopt ? 'awaiting adoption' : 'created with --no-start' });
303
305
  if (parents.length) inheritSubscriptions(db, id, parents);
304
306
  if (arc) {
@@ -693,8 +695,8 @@ export const complete = async (board, id, options = {}) => {
693
695
  return await act(board, id, 'complete', { roles, payload: { result, summary, metadata, run_id: runId, commit, branch, pr, pr_head, reviewed_sha } });
694
696
  };
695
697
  // 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`)
696
- export const block = async (board, id, { reason = null, on = null, waitingOn = null, roles, runId = null } = {}) =>
697
- await act(board, id, 'block', { roles, payload: { reason, on, waiting_on: waitingOn, run_id: runId } });
698
+ export const block = async (board, id, { reason = null, on = null, waitingOn = null, kind = 'needs_input', roles, runId = null } = {}) =>
699
+ await act(board, id, 'block', { roles, payload: { reason, on, kind, waiting_on: waitingOn, run_id: runId } });
698
700
  export const unblock = (board, id, { reason = null, on = null, roles } = {}) => act(board, id, 'unblock', { roles, payload: { reason, on } });
699
701
  export const requestReview = async (board, id, options = {}) => {
700
702
  assertSourceReceipt(options);
@@ -708,7 +710,7 @@ export const schedule = (board, id, reason = null, { roles } = {}) => act(board,
708
710
  export const promote = (board, id, { actor = 'user', reason = null, force = false, roles } = {}) => act(board, id, 'promote', { roles, payload: { actor, reason, force } });
709
711
  export const reclaim = (board, id, reason = null, { roles } = {}) => act(board, id, 'reclaim', { roles, payload: { reason } });
710
712
  export const archive = (board, id, { roles, reason } = {}) => act(board, id, 'archive', { roles, payload: { reason } });
711
- export async function specify(board, id, { title = null, body = null, workspace = null, branch = null, machine = null, author = 'user', roles } = {}) {
713
+ export async function specify(board, id, { title = null, body = null, readableId = null, workspace = null, branch = null, machine = null, author = 'user', roles } = {}) {
712
714
  refuseCredentialDump(`${id}'s specification`, { title, body });
713
715
  let declaration = null;
714
716
  if (workspace != null || branch != null || machine != null) {
@@ -722,12 +724,13 @@ export async function specify(board, id, { title = null, body = null, workspace
722
724
  if (branch != null && ws.kind === 'scratch') throw new Error('a scratch workspace has no repository branch');
723
725
  declaration = { kind: ws.kind, ...(await declareWorkspace(ws.kind, ws.path, target, branch)), ...(machine != null && target !== task.machine ? { machine: target } : {}) };
724
726
  if (declaration.repository === task.workspace_anchor) declaration.branch ??= task.branch_name;
725
- if (declaration.kind === 'worktree') declaration.branch ??= `wt/${id}`;
727
+ const handle = cardHandle(boardKey(board.root, board.slug), task.number);
728
+ if (declaration.kind === 'worktree') declaration.branch ??= handle ? cardBranch(handle, task.readable_id) : `wt/${id}`;
726
729
  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;
727
730
  }
728
731
  // an arc's new task lines are filed as its subtasks once the specify itself is accepted
729
732
  const steps = body != null && !one(board.db, 'SELECT subtask_of FROM supercode_cards WHERE task_id = ?', id)?.subtask_of ? bodyTasks(body) : { body, tasks: [] };
730
- const done = await act(board, id, 'specify', { roles, payload: { title, body: steps.body, author, ...(declaration ? { workspace: declaration } : {}) } });
733
+ const done = await act(board, id, 'specify', { roles, payload: { title, body: steps.body, readable_id: readableId, author, ...(declaration ? { workspace: declaration } : {}) } });
731
734
  for (const step of steps.tasks) createCard(board, { ...step, subtaskOf: id, assignee: getTask(board.db, id)?.assignee || null });
732
735
  return done;
733
736
  }
@@ -767,10 +770,26 @@ export async function unlink(board, parent, child, { roles } = {}) {
767
770
  }
768
771
 
769
772
  /** A comment on the card's thread; answers its id. */
770
- export async function comment(board, id, author, body, { roles, pr = null, pr_head = null } = {}) {
773
+ export async function comment(board, id, author, body, { roles, pr = null, pr_head = null, source = null } = {}) {
771
774
  if (!String(body ?? '').trim()) throw new Error('a comment has a body');
772
775
  if (!String(author ?? '').trim()) throw new Error('a comment has an author');
773
- let sources;
776
+ let sources, branches;
777
+ // The repositories and branches an arc changes are the arc's own: declared on it (`--source owner/repo:branch`, each
778
+ // repository the arc changes), never registered as a subtask's workspace to be reviewed (VO-2858 had to register
779
+ // volter-org and runhuman-2 on its tasks, VO-2860). The latest declaration is the arc's complete set.
780
+ if (source != null) {
781
+ const named = [].concat(source).map((item) => String(item).trim());
782
+ branches = named.map((item) => {
783
+ const found = /^(?:https:\/\/github\.com\/|github\.com\/)?([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?)(?:\.git)?:([^\s:]+)$/.exec(item);
784
+ if (!found) throw new Error(`Declare each source as owner/repository:branch, not ${JSON.stringify(item)}.`);
785
+ return { repository: found[1], branch: found[2] };
786
+ });
787
+ if (!branches.length) throw new Error('Declare at least one source as owner/repository:branch.');
788
+ if (new Set(branches.map((b) => `${b.repository.toLowerCase()}:${b.branch}`)).size !== branches.length) throw new Error('Declare each source once.');
789
+ const arc = one(board.db, 'SELECT subtask_of FROM supercode_cards WHERE task_id = ?', id)?.subtask_of;
790
+ if (arc) throw new Error(`${id} is a task of ${arc}: an arc's sources are declared on the arc (comment ${arc} … --source owner/repository:branch).`);
791
+ if (!(roles ?? callerRoles(board.db, id)).some((role) => role === 'implementer' || role === 'manager')) throw new Error(`Only ${id}'s implementer or the manager declares its sources.`);
792
+ }
774
793
  if (pr != null || pr_head != null) {
775
794
  const urls = [].concat(pr ?? []), heads = [].concat(pr_head ?? []);
776
795
  if (!urls.length || urls.length !== heads.length) throw new Error('Record every PR with --pr URL --pr-head SHA, one full head per URL.');
@@ -782,7 +801,7 @@ export async function comment(board, id, author, body, { roles, pr = null, pr_he
782
801
  // Only the card's own implementer or the board's manager declares its sources; the author text is not identity.
783
802
  if (!(roles ?? callerRoles(board.db, id)).some((role) => role === 'implementer' || role === 'manager')) throw new Error(`Only ${id}'s implementer or the manager records its PR sources.`);
784
803
  }
785
- const result = await act(board, id, 'comment', { roles, payload: { author: author.trim(), body, ...(sources ? { sources } : {}) } });
804
+ const result = await act(board, id, 'comment', { roles, payload: { author: author.trim(), body, ...(sources ? { sources } : {}), ...(branches ? { branches } : {}) } });
786
805
  return result.commentId;
787
806
  }
788
807
 
@@ -866,7 +885,7 @@ export function reconcileDependencySubscriptions(db) {
866
885
  const where = "WHERE task_id=? AND platform='supercode' AND chat_id=? AND thread_id=?";
867
886
  const identity = [row.task_id, row.chat_id, row.thread_id];
868
887
  if (!owner || owner.detached || owner.archived || ['done', 'cancelled', 'archived'].includes(owner.status)) run(db, `DELETE FROM kanban_notify_subs ${where}`, ...identity);
869
- 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, waiting_recipient: false, failures: 0 }), ...identity);
888
+ 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);
870
889
  else if (!address && !metadata.waiting_recipient) run(db, `UPDATE kanban_notify_subs SET delivery_metadata=? ${where}`, JSON.stringify({ ...metadata, waiting_recipient: true }), ...identity);
871
890
  }
872
891
  }
@@ -876,11 +895,13 @@ function inheritSubscriptions(db, child, parents, { workers = false } = {}) {
876
895
  const cursor = one(db, 'SELECT COALESCE(MAX(id), 0) AS c FROM task_events WHERE task_id = ?', child).c;
877
896
  for (const p of parents) {
878
897
  for (const s of all(db, 'SELECT * FROM kanban_notify_subs WHERE task_id = ?', p)) {
879
- const role = json(s.delivery_metadata, {})?.role;
898
+ const metadata = json(s.delivery_metadata, {}) ?? {}, role = metadata.role;
880
899
  if ((role === 'worker' && !workers) || role === 'dependency') continue; // a parent's session does not work the child
900
+ // A child's subscription is a new wait, not the parent's retained delivery state.
901
+ delete metadata.notice_wait;
881
902
  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)
882
903
  VALUES (?, ?, ?, ?, ?, ?, COALESCE(?, 'dm'), ?, COALESCE(?, 'notify'), ?, ?, ?)`,
883
- 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, s.delivery_metadata, now(), cursor);
904
+ 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);
884
905
  }
885
906
  }
886
907
  }
@@ -890,20 +911,21 @@ export const subscriptions = (db, id = null) => (id ? all(db, 'SELECT * FROM kan
890
911
 
891
912
  // ------------------------------------------------------------------ reading
892
913
 
893
- export function listCards(db, { status = null, assignee = null, machine = null, includeArchived = false } = {}) {
894
- const rows = all(db, `SELECT t.*, n.number, c.machine, c.subtask_of, c.stage, c.tags, c.sponsor FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id LEFT JOIN ${CARD_NUMBERS} n ON n.task_id = t.id
895
- WHERE (? IS NULL OR t.status = ? OR c.stage = ?) AND (? IS NULL OR t.assignee = ?) AND (? IS NULL OR c.machine = ?) AND (? OR t.status != 'archived')
896
- ORDER BY t.priority DESC, t.created_at ASC`, status, status, status, assignee, assignee, machine, machine, includeArchived ? 1 : 0);
897
- return rows.map((r) => ({ ...r, skills: json(r.skills, []) ?? [], execution: executionOf(r.current_run_id ? one(db, 'SELECT metadata,ended_at FROM task_runs WHERE id=?', r.current_run_id) : null) }));
914
+ export function listCards(db, { status = null, assignee = null, machine = null, includeArchived = false, includeSubtasks = true, nativeHolds = false } = {}) {
915
+ const stateSql = cardStateSql({ nativeHolds });
916
+ const rows = all(db, `SELECT t.*, n.number, c.machine, c.subtask_of, c.stage, c.tags, c.paused, c.blocked_by FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id LEFT JOIN ${CARD_NUMBERS} n ON n.task_id = t.id
917
+ WHERE (? IS NULL OR ${stateSql} = ? OR (? NOT IN ('paused','blocked') AND c.stage = ?)) AND (? IS NULL OR t.assignee = ?) AND (? IS NULL OR c.machine = ?) AND ${CARD_VISIBILITY_SQL}
918
+ ORDER BY t.priority DESC, t.created_at ASC`, status, status, status, status, assignee, assignee, machine, machine, includeSubtasks ? 1 : 0, includeArchived ? 1 : 0);
919
+ return rows.map((r) => cardState({ ...r, skills: json(r.skills, []) ?? [], execution: executionOf(r.current_run_id ? one(db, 'SELECT metadata,ended_at FROM task_runs WHERE id=?', r.current_run_id) : null) }, { nativeHolds }));
898
920
  }
899
921
 
900
- export function showCard(db, id) {
901
- const task = { ...must(db, id), reviewer: reviewerOf(db, id) };
922
+ export function showCard(db, id, options = {}) {
923
+ const task = cardState({ ...must(db, id), reviewer: reviewerOf(db, id) }, options);
902
924
  return {
903
925
  task,
904
926
  parents: parentsOf(db, id),
905
927
  children: childrenOf(db, id),
906
- subtasks: all(db, `SELECT t.id, n.number, t.status, t.title FROM tasks t JOIN supercode_cards c ON c.task_id = t.id LEFT JOIN ${CARD_NUMBERS} n ON n.task_id = t.id WHERE c.subtask_of = ? ORDER BY t.created_at`, id),
928
+ subtasks: all(db, `SELECT t.id, n.number, t.status, t.title, c.stage, c.paused, c.blocked_by FROM tasks t JOIN supercode_cards c ON c.task_id = t.id LEFT JOIN ${CARD_NUMBERS} n ON n.task_id = t.id WHERE c.subtask_of = ? ORDER BY t.created_at`, id).map(row => cardState(row, options)),
907
929
  comments: all(db, 'SELECT author, body, created_at FROM task_comments WHERE task_id = ? ORDER BY id', id),
908
930
  runs: all(db, 'SELECT * FROM task_runs WHERE task_id = ? ORDER BY id', id).map((r) => ({ ...r, metadata: json(r.metadata, null) })),
909
931
  events: all(db, 'SELECT id, kind, payload, created_at, run_id FROM task_events WHERE task_id = ? ORDER BY id', id).map((e) => ({ ...e, payload: json(e.payload, null) })),
@@ -916,9 +938,10 @@ export function showCard(db, id) {
916
938
  const cap = (text, n) => { text = text ? withheldIfDump(text) : text; return text && text.length > n ? `${text.slice(0, n)}… (${text.length - n} more characters)` : text; };
917
939
 
918
940
  /** What a session starting on a card reads first: the card, its parents' results, earlier attempts and the thread. */
919
- export function cardContext(db, id) {
920
- const task = must(db, id);
941
+ export function cardContext(db, id, options = {}) {
942
+ const task = cardState(must(db, id), options);
921
943
  const out = [`# Card ${task.id}: ${task.title}`, ''];
944
+ out.push(`Readable id: ${task.readable_id}.`);
922
945
  out.push(`Status: ${task.status}. Machine: ${task.machine ?? 'unset'}. Workspace: ${task.workspace_kind}${task.workspace_anchor ? ` ${task.workspace_anchor}` : task.workspace_path ? ` ${task.workspace_path}` : ''}${task.branch_name ? ` (branch ${task.branch_name})` : ''}.${task.subtask_of ? ` A subtask of ${task.subtask_of}.` : ''}`);
923
946
  if (task.body) out.push('', cap(task.body, 8 * 1024));
924
947
  const parents = all(db, 'SELECT t.id, t.title, t.result FROM tasks t JOIN task_links l ON l.parent_id = t.id WHERE l.child_id = ?', id);
@@ -998,14 +1021,16 @@ export function removeAttachment(board, attachmentId) {
998
1021
  }
999
1022
 
1000
1023
  /** Per status, per assignee, and the oldest ready card's age (Hermes's `stats`). */
1001
- export function stats(db) {
1024
+ export function stats(db, options = {}) {
1025
+ const stateSql = cardStateSql(options);
1026
+ const rows = `FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id`;
1002
1027
  const byStatus = {};
1003
- for (const r of all(db, "SELECT status, COUNT(*) AS n FROM tasks WHERE status != 'archived' GROUP BY status")) byStatus[r.status] = Number(r.n);
1028
+ for (const r of all(db, `SELECT ${stateSql} AS board_status, COUNT(*) AS n ${rows} WHERE t.status != 'archived' GROUP BY board_status`)) byStatus[r.board_status] = Number(r.n);
1004
1029
  const byAssignee = {};
1005
- for (const r of all(db, "SELECT assignee, status, COUNT(*) AS n FROM tasks WHERE status != 'archived' AND assignee IS NOT NULL GROUP BY assignee, status")) (byAssignee[r.assignee] ??= {})[r.status] = Number(r.n);
1030
+ for (const r of all(db, `SELECT t.assignee, ${stateSql} AS board_status, COUNT(*) AS n ${rows} WHERE t.status != 'archived' AND t.assignee IS NOT NULL GROUP BY t.assignee, board_status`)) (byAssignee[r.assignee] ??= {})[r.board_status] = Number(r.n);
1006
1031
  const byMachine = {};
1007
- for (const r of all(db, "SELECT c.machine, t.status, COUNT(*) AS n FROM tasks t JOIN supercode_cards c ON c.task_id = t.id WHERE t.status != 'archived' AND c.machine IS NOT NULL GROUP BY c.machine, t.status")) (byMachine[r.machine] ??= {})[r.status] = Number(r.n);
1008
- const oldest = one(db, "SELECT MIN(created_at) AS ts FROM tasks WHERE status = 'ready'")?.ts;
1032
+ for (const r of all(db, `SELECT c.machine, ${stateSql} AS board_status, COUNT(*) AS n ${rows} WHERE t.status != 'archived' AND c.machine IS NOT NULL GROUP BY c.machine, board_status`)) (byMachine[r.machine] ??= {})[r.board_status] = Number(r.n);
1033
+ const oldest = one(db, `SELECT MIN(t.created_at) AS ts ${rows} WHERE ${stateSql} = 'ready'`)?.ts;
1009
1034
  const t = now();
1010
1035
  return { by_status: byStatus, by_assignee: byAssignee, by_machine: byMachine, oldest_ready_age_seconds: oldest ? t - Number(oldest) : null, now: t };
1011
1036
  }
@@ -1043,7 +1068,8 @@ export function diagnosePublishing(publishing) {
1043
1068
  * same thing can go wrong, and supercode's own where only its sessions can): each `{ kind, severity, title, data,
1044
1069
  * actions }`, worst first.
1045
1070
  */
1046
- export function diagnose(db, task, { failureLimit = DEFAULT_FAILURE_LIMIT, strandedSeconds = 30 * 60, blockedHours = 24, fleet = null, launchWindowSeconds = 300, publishing = null, expiredMail = null, waitingMail = null } = {}) {
1071
+ export function diagnose(db, task, { failureLimit = DEFAULT_FAILURE_LIMIT, strandedSeconds = 30 * 60, blockedHours = 24, fleet = null, launchWindowSeconds = 300, publishing = null, expiredMail = null, waitingMail = null, nativeHolds = false } = {}) {
1072
+ task = cardState(task, { nativeHolds });
1047
1073
  const out = [];
1048
1074
  const t = now();
1049
1075
  const events = all(db, 'SELECT kind, payload, created_at FROM task_events WHERE task_id = ? ORDER BY id', task.id);
@@ -1070,9 +1096,10 @@ export function diagnose(db, task, { failureLimit = DEFAULT_FAILURE_LIMIT, stran
1070
1096
  if (lost >= 2) out.push({ kind: 'repeated_session_loss', severity: 'warning', title: `its session was lost ${lost}x in 24h`, data: { lost }, actions: [`supercode workflow set ${task.id} machine <machine>`] });
1071
1097
  const crashes = events.filter((e) => e.kind === 'crashed' && t - e.created_at < 24 * 3600).length;
1072
1098
  if (crashes >= 2) out.push({ kind: 'repeated_crashes', severity: 'error', title: `its worker crashed ${crashes}x in 24h`, data: { crashes }, actions: [`supercode workflow log ${task.id}`] });
1073
- if (task.status === 'blocked') {
1099
+ if (task.status === 'paused') out.push({ kind: 'paused', severity: 'info', title: holdDescription(task), data: { paused: task.paused }, actions: [`supercode workflow resume ${task.id}`] });
1100
+ if (task.status === 'blocked' && (!nativeHolds || task.blocked_by.length)) {
1074
1101
  const since = [...events].reverse().find((e) => e.kind === 'blocked' || e.kind === 'gave_up')?.created_at ?? task.created_at;
1075
- if (hours(t - since) >= blockedHours) out.push({ kind: 'stuck_in_blocked', severity: 'warning', title: `blocked for ${Math.floor(hours(t - since))}h`, data: { hours: Math.floor(hours(t - since)) }, actions: [`supercode workflow unblock ${task.id}`] });
1102
+ if (hours(t - since) >= blockedHours) out.push({ kind: 'stuck_in_blocked', severity: 'warning', title: `blocked for ${Math.floor(hours(t - since))}h${holdDescription(task) ? `; ${holdDescription(task)}` : ''}`, data: { hours: Math.floor(hours(t - since)), blocked_by: task.blocked_by }, actions: [`supercode workflow unblock ${task.id}`] });
1076
1103
  const cycles = events.filter((e) => e.kind === 'unblocked' && t - e.created_at < 24 * 3600).length;
1077
1104
  if (cycles >= 3) out.push({ kind: 'block_unblock_cycling', severity: 'warning', title: `block→unblock cycled ${cycles}x in 24h`, data: { cycles }, actions: [] });
1078
1105
  }
@@ -92,7 +92,12 @@ export async function* followBoardPublications({root,after,signal}) {
92
92
  // A pruned floor above the cursor means changes it never read are gone; a new log id means a new store.
93
93
  if(!position||position.log!==boundary.log||!Number.isSafeInteger(position.sequence)||position.sequence<boundary.floor||position.sequence>boundary.high)throw resync(`Workflow publication log changed: ${slug}`);
94
94
  for(;;){
95
- const rows=board.db.prepare('SELECT * FROM supercode_publications WHERE sequence>? ORDER BY sequence LIMIT 32').all(positions[slug].sequence);if(!rows.length)break;
95
+ const rows=board.tx(db=>{
96
+ const current=db.prepare('SELECT id,pruned_through FROM supercode_publication_log').get();
97
+ if(!current||current.id!==position.log||positions[slug].sequence<Number(current.pruned_through))throw resync(`Workflow publication log changed: ${slug}`);
98
+ return db.prepare('SELECT * FROM supercode_publications WHERE sequence>? AND sequence<=? ORDER BY sequence LIMIT 32').all(positions[slug].sequence,boundary.high);
99
+ });
100
+ if(!rows.length)break;
96
101
  for(const row of rows){positions[slug].sequence=row.sequence;yield {type:'event',id:row.id,kind:row.kind,sequence:row.sequence,cursor:cursorOf(home,positions),data:{board:slug,card:JSON.parse(row.snapshot)}};}
97
102
  }
98
103
  }
package/board/files.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { fillReadableIds } from './readable-id.mjs';
1
2
  // The `files` backing of a board (docs/architecture/orchestrator.md §2.9, "Backings"): the same tables as a
2
3
  // Hermes `kanban.db`, kept as a directory of JSON files instead of one SQLite file. `<id>.json` holds everything of
3
4
  // one card (its `tasks` row, its `supercode_cards` row, the links to its parents, its comments, events, runs,
@@ -45,7 +46,7 @@ export function openFilesBoard(dir, { create = false, init, held = false }) {
45
46
  // events, and on every read while it keeps no watch on them; a write still compares the files it read (persist)
46
47
  board.held = held;
47
48
  if (!isFilesBoard(dir)) board.tx(() => {}, { force: true });
48
- return { db: board.db, tx: (fn) => board.tx(fn), close: () => board.close(),
49
+ return { db: board.db, snapshot: (path) => board.read(() => board.mem.prepare('VACUUM INTO ?').run(path)), tx: (fn) => board.tx(fn), close: () => board.close(),
49
50
  documentsChanged: () => { board.changed = true; }, documentsUnwatched: (unwatched) => { board.unwatched = unwatched; } };
50
51
  }
51
52
 
@@ -147,6 +148,7 @@ class FilesBoard {
147
148
  if (mem.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'supercode_publication_heads'").get()) markRemovedHeads(mem);
148
149
  this.mem = mem;
149
150
  this.watch();
151
+ fillReadableIds(mem);
150
152
  this.stamp = stamp;
151
153
  }
152
154
 
package/board/listing.mjs CHANGED
@@ -3,6 +3,8 @@
3
3
  import { setImmediate as nextTurn } from 'node:timers/promises';
4
4
  import { noteStep } from '@volter/supercode-harness-sdk/slow-log';
5
5
  import { executionOf } from './session-work.mjs';
6
+ import { CARD_VISIBILITY_SQL } from './visibility.mjs';
7
+ import { cardState, cardStateSql } from './card-state.mjs';
6
8
 
7
9
  export const LIST_BATCH = 64;
8
10
  // A listing on the owner's read door (owner-door.mjs) that can read no committed state without waiting on a writer (a
@@ -45,9 +47,9 @@ const sameRevision = (a, b) => Object.keys(a).every(key => a[key] === b[key]);
45
47
 
46
48
  // Only the fields cardDict, human lines, filters and sorters use. A human listing never reads
47
49
  // body/result/skills or run history. A JSON listing retains its existing full-text contract.
48
- const HUMAN_FIELDS = 'id,title,assignee,status,priority,tenant,created_at,started_at,completed_at';
50
+ const HUMAN_FIELDS = 'id,readable_id,title,assignee,status,priority,tenant,created_at,started_at,completed_at';
49
51
  const JSON_FIELDS = `${HUMAN_FIELDS},body,result,workspace_kind,workspace_path,branch_name,project_id,created_by,skills,max_retries,model_override,provider_override,session_id,workflow_template_id,current_step_key`;
50
- export const TASK_SUMMARY_INDEX = 'idx_tasks_list_summary';
52
+ export const TASK_SUMMARY_INDEX = 'idx_tasks_list_summary_readable';
51
53
  export const TASK_SUMMARY_INDEX_SQL = `CREATE INDEX IF NOT EXISTS ${TASK_SUMMARY_INDEX} ON tasks(${HUMAN_FIELDS},current_run_id,session_id,workflow_template_id,current_step_key)`;
52
54
  const RUN_METADATA = `CASE WHEN json_valid(r.metadata) THEN json_object('supercode', json_object(
53
55
  'address',json_extract(r.metadata,'$.supercode.address'),'session_id',json_extract(r.metadata,'$.supercode.session_id'),
@@ -66,16 +68,16 @@ export const LAST_BLOCK_SQL = `SELECT CASE WHEN kind = 'blocked' THEN payload EN
66
68
  UNION ALL SELECT (SELECT id FROM task_events WHERE task_id = t.id AND kind = 'dependency_wait' ORDER BY id DESC LIMIT 1)
67
69
  ))`;
68
70
 
69
- export function listPageSql(detailed, tagCount = 0) {
71
+ export function listPageSql(detailed, tagCount = 0, options = {}) {
70
72
  return `SELECT t.rowid AS list_order,${(detailed ? JSON_FIELDS : HUMAN_FIELDS).split(',').map(name => `t.${name}`).join(',')},
71
- n.number,c.machine,c.stage${detailed ? ',c.subtask_of' : ''},
73
+ n.number,c.machine,c.stage,c.paused,c.blocked_by,c.detached${detailed ? ',c.subtask_of' : ''},
72
74
  r.id AS run_id,r.status AS run_status,r.started_at AS run_started_at,r.ended_at AS run_ended_at,${RUN_METADATA} AS run_metadata
73
75
  ${detailed ? `,(SELECT json_group_array(parent_id) FROM (SELECT parent_id FROM task_links WHERE child_id = t.id ORDER BY parent_id)) AS parents_json,
74
76
  (${LAST_BLOCK_SQL}) AS block_payload` : ''}
75
77
  FROM tasks t ${detailed ? '' : `INDEXED BY ${TASK_SUMMARY_INDEX}`} LEFT JOIN supercode_cards c ON c.task_id = t.id LEFT JOIN supercode_card_numbers n ON n.task_id = t.id
76
78
  LEFT JOIN task_runs r ON r.id = t.current_run_id
77
- WHERE t.id IN (SELECT value FROM json_each(?)) AND (? IS NULL OR t.status = ? OR c.stage = ?)
78
- AND (? IS NULL OR t.assignee = ?) AND (? IS NULL OR c.machine = ?) AND (? OR t.status != 'archived')
79
+ WHERE t.id IN (SELECT value FROM json_each(?)) AND (? IS NULL OR ${cardStateSql(options)} = ? OR (? NOT IN ('paused','blocked') AND c.stage = ?))
80
+ AND (? IS NULL OR t.assignee = ?) AND (? IS NULL OR c.machine = ?) AND ${CARD_VISIBILITY_SQL}
79
81
  AND (? IS NULL OR t.tenant = ?) AND (? IS NULL OR t.session_id = ?)
80
82
  AND (? IS NULL OR t.workflow_template_id = ?) AND (? IS NULL OR t.current_step_key = ?)
81
83
  ${Array.from({ length: tagCount }, () => `AND CASE WHEN json_valid(c.tags) THEN
@@ -92,10 +94,10 @@ export async function listCardsInTurns(db, options = {}) {
92
94
  throw Object.assign(new Error('board changed during both listing reads; run list again'), { code: 'board_listing_changed' });
93
95
  }
94
96
 
95
- async function readCardsInTurns(db, { detailed = false, status = null, assignee = null, machine = null, includeArchived = false,
96
- tenant = null, session = null, workflowTemplateId = null, stepKey = null, tags = [] }, revision) {
97
+ async function readCardsInTurns(db, { detailed = false, status = null, assignee = null, machine = null, includeArchived = false, includeSubtasks = false,
98
+ tenant = null, session = null, workflowTemplateId = null, stepKey = null, tags = [], nativeHolds = false }, revision) {
97
99
  const out = [];
98
- const page = prepared(db, listPageSql(detailed, tags.length)), candidates = prepared(db, LIST_CANDIDATES_SQL);
100
+ const page = prepared(db, listPageSql(detailed, tags.length, { nativeHolds })), candidates = prepared(db, LIST_CANDIDATES_SQL);
99
101
  const ceiling = measured('last candidate', () => prepared(db, "SELECT COALESCE(MAX(id), '') AS cursor FROM tasks").get().cursor);
100
102
  let cursor = '';
101
103
  while (cursor < ceiling) {
@@ -103,7 +105,7 @@ async function readCardsInTurns(db, { detailed = false, status = null, assignee
103
105
  const range = measured('candidate range', () => candidates.get(cursor, ceiling));
104
106
  if (range.last == null) break;
105
107
  const rows = measured(detailed ? 'JSON task rows and latest blocks' : 'human task rows', () => page.all(range.ids,
106
- status, status, status, assignee, assignee, machine, machine, includeArchived ? 1 : 0,
108
+ status, status, status, status, assignee, assignee, machine, machine, includeSubtasks ? 1 : 0, includeArchived ? 1 : 0,
107
109
  tenant, tenant, session, session, workflowTemplateId, workflowTemplateId, stepKey, stepKey, ...tags.flatMap(tag => [tag, tag, tag])));
108
110
  measured('row projection', () => {
109
111
  for (const { run_id, run_status, run_started_at, run_ended_at, run_metadata, parents_json, block_payload, ...task } of rows) {
@@ -117,7 +119,7 @@ async function readCardsInTurns(db, { detailed = false, status = null, assignee
117
119
  task.waiting_on = parse(block_payload, {})?.waiting_on ?? null;
118
120
  task.run = run ? { id: run.id, status: run.status, started_at: run.started_at, session: metadata.supercode?.address ?? metadata.supercode?.session_id ?? null, execution: task.execution } : null;
119
121
  }
120
- out.push(task);
122
+ out.push(cardState(task, { nativeHolds }));
121
123
  }
122
124
  });
123
125
  cursor = range.last;
@@ -82,12 +82,14 @@ export async function readMachineFleet(machines, local) {
82
82
  noteStep(`fleet reading (${names.length} machine${names.length === 1 ? '' : 's'}, held records)`, performance.now() - started);
83
83
  // `panes`: each answering machine's live pane ids, whether or not a session was read in them
84
84
  // `ended`: the panes each machine saw end (its tmux's exit log, or a pane its host keeps dead), with how each ended
85
- const online = new Set(), unknown = new Set(), alive = new Set(), native = new Map(), sessions = [], capabilities = new Map(), panes = new Map(), ended = new Map();
85
+ const online = new Set(), offline = new Set(), unknown = new Set(), errors = new Map(), alive = new Set(), native = new Map(), sessions = [], capabilities = new Map(), panes = new Map(), ended = new Map();
86
86
  let localError = null;
87
87
  for (const row of rows) {
88
88
  if (row.error) {
89
+ errors.set(row.machine, row.error);
89
90
  if (row.machine === local) localError = row.error;
90
- else if (row.code !== 'machine_offline') unknown.add(row.machine);
91
+ else if (row.code === 'machine_offline') offline.add(row.machine);
92
+ else unknown.add(row.machine);
91
93
  continue;
92
94
  }
93
95
  online.add(row.machine);
@@ -98,13 +100,13 @@ export async function readMachineFleet(machines, local) {
98
100
  ...(row.ended ?? []).filter((exit) => exit?.pane && !panes.get(row.machine).has(exit.pane)).map((exit) => [exit.pane, { failed: exit.failed ?? null, how: exit.how ?? null }]),
99
101
  ...(row.panes ?? []).filter((pane) => pane.dead).map((pane) => [pane.pane, { failed: null, how: 'its program ended (the pane is kept for its last screen)' }]),
100
102
  ]));
101
- for (const { pid, activity } of row.activities ?? []) {
103
+ for (const { pid, activity, locator } of row.activities ?? []) {
102
104
  const pane = live.find((item) => item.pid === pid);
103
105
  if (!pane || !activity?.session_id || !activity.harness) continue;
104
106
  const address = `sc:${row.machine}:${activity.harness}:${activity.session_id}`, running = activity.presence === 'running';
105
107
  native.set(address, { alive: running ? true : null, states: [running ? true : null] });
106
108
  if (running) alive.add(address);
107
- sessions.push({ address, machine: row.machine, harness: activity.harness, session_id: activity.session_id, pane: pane.pane, name: pane.title ?? pane.label ?? null,
109
+ sessions.push({ address, machine: row.machine, harness: activity.harness, session_id: activity.session_id, locator: locator ?? null, pane: pane.pane, name: pane.title ?? pane.label ?? null,
108
110
  starter_session: pane.starter_session ?? null,
109
111
  state: running ? 'running' : 'unknown', turn: activity.turn ?? null, native_state: activity.evidence?.native_state ?? null,
110
112
  // what a session that needs input says it waits for, what started its open turn (Codex: `goal` for a thread
@@ -114,5 +116,5 @@ export async function readMachineFleet(machines, local) {
114
116
  }
115
117
  }
116
118
  const ok = online.has(local);
117
- return { ok, presenceKnown: ok, online, unknown, alive, native, sessions, capabilities, panes, ended, error: ok ? null : `machine ${local}'s door did not answer: ${localError ?? 'no row'}` };
119
+ return { ok, presenceKnown: ok, online, offline, unknown, errors, alive, native, sessions, capabilities, panes, ended, error: ok ? null : `machine ${local}'s door did not answer: ${localError ?? 'no row'}` };
118
120
  }
package/board/notices.mjs CHANGED
@@ -1,5 +1,5 @@
1
- /** A notice's text or a mail's subject about a card, with the card's number first (`#42 …`; store.mjs layOutNumbers). */
2
- export const numbered = (number, text) => (number ? `#${number} ${text}` : text);
1
+ /** A notice's text or a mail's subject about a card, with the card's keyed number first (`VO-42 …`; store.mjs boardKey). */
2
+ export const numbered = (key, number, text) => (number ? `${key}-${number} ${text}` : text);
3
3
  /** The number of card `id` in the board's store, or null. */
4
4
  export const cardNumber = (db, id) => db.prepare('SELECT number FROM supercode_card_numbers WHERE task_id = ?').get(id)?.number ?? null;
5
5