@volter/supercode-orchestrator 0.5.206 → 0.5.207

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/board/cli.mjs CHANGED
@@ -93,6 +93,8 @@ const callerPath = (named) => notRuntime(resolve(current().cwd, named));
93
93
  const SAFE_SESSION = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/;
94
94
  const HELP = `supercode workflow <verb> [--root <home>] [--board <slug>] [--json]
95
95
 
96
+ A card is named by its id (t_4406c88a) or by its number (42 or #42): the short number the board gave it when it was made.
97
+
96
98
  The board's verbs, with their flags:
97
99
  init [--backing sqlite|files|ztrack] [--document <arcs.md>] [--commit] [--manager <address>] [--dispatching on|off] [--archive-max-lines N] [--archive-period month]
98
100
  migrate --backing ztrack --document <arcs.md> --status-map <json>
@@ -329,9 +331,9 @@ const ts = (t) => {
329
331
  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
330
332
  };
331
333
  // a card's status is the workflow's (triaging, reviewing, …); kanban.db stores the Hermes status it maps to
332
- const line = (t) => `${ICONS[t.status] ?? '?'} ${t.id} ${(t.stage ?? t.status).padEnd(8)}${t.detached ? ` [session belongs to ${t.detached.person}]` : ['running','review'].includes(t.status) ? ` [session ${t.execution?.state ?? 'unknown'}]` : ''} ${(t.assignee || '(unassigned)').padEnd(20)}${t.tenant ? ` [${t.tenant}]` : ''} ${t.title}${t.machine ? ` @${t.machine}` : ''}`;
334
+ const line = (t) => `${ICONS[t.status] ?? '?'} ${engine.cardLabel(t.number, t.id).padEnd(16)} ${(t.stage ?? t.status).padEnd(8)}${t.detached ? ` [session belongs to ${t.detached.person}]` : ['running','review'].includes(t.status) ? ` [session ${t.execution?.state ?? 'unknown'}]` : ''} ${(t.assignee || '(unassigned)').padEnd(20)}${t.tenant ? ` [${t.tenant}]` : ''} ${t.title}${t.machine ? ` @${t.machine}` : ''}`;
333
335
  const cardDict = (t) => ({
334
- id: t.id, title: t.title, body: t.body, assignee: t.assignee, status: t.status, priority: t.priority, tenant: t.tenant,
336
+ id: t.id, number: t.number ?? null, title: t.title, body: t.body, assignee: t.assignee, status: t.status, priority: t.priority, tenant: t.tenant,
335
337
  workspace_kind: t.workspace_kind, repository: t.workspace_anchor ?? null, workspace_path: t.workspace_path, branch_name: t.branch_name, project_id: t.project_id,
336
338
  created_by: t.created_by, created_at: t.created_at, started_at: t.started_at, completed_at: t.completed_at, result: t.result,
337
339
  skills: t.skills ?? [], max_retries: t.max_retries, model_override: t.model_override, provider_override: t.provider_override,
@@ -366,6 +368,36 @@ const metadataOf = (v) => {
366
368
  return got;
367
369
  };
368
370
 
371
+ // A card is named by its id or by its number (`42`, `#42`, store.mjs layOutNumbers) in every place a verb takes a card:
372
+ // its card positionals (none, the first, the first two, or all of them) and its card flags. A number is read as its
373
+ // card's id before anything else reads the verb (its authorization included); a number no card here has is refused.
374
+ const NO_CARD_WORDS = new Set(['create', 'new', 'swarm', 'list', 'ls', 'migrate', 'init', 'boards', 'usage', 'events', 'listeners', 'dispatch',
375
+ 'capacity', 'deliver', 'daemon', 'serve', 'gc', 'repair', 'dispatching', 'attach-rm', 'run-session', 'stats', 'assignees', 'diagnostics', 'diag',
376
+ 'watch', 'help', 'github-usage']);
377
+ // the verbs that act on every positional as a card (each loops over them all)
378
+ const ALL_CARD_WORDS = new Set(['complete', 'archive', 'request-state', 'request_state', 'set-goal', 'unblock', 'reopen', 'reopen-review']);
379
+ const CARD_FLAGS = ['ids', 'subtask-of', 'parent', 'on', 'for-card', 'task', 'rm'];
380
+ const CARD_NUMBER = /^#?([1-9]\d*)$/;
381
+ function resolveCardNumbers(verb, rest, args, withBoard) {
382
+ const words = NO_CARD_WORDS.has(verb) ? 0 : ALL_CARD_WORDS.has(verb) ? rest.length : ['link', 'unlink'].includes(verb) ? 2 : 1;
383
+ const places = [];
384
+ for (let i = 0; i < Math.min(words, rest.length); i++) places.push([rest, i]);
385
+ for (const flag of CARD_FLAGS) {
386
+ if (Array.isArray(args[flag])) args[flag].forEach((_, i) => places.push([args[flag], i]));
387
+ else if (typeof args[flag] === 'string') places.push([args, flag]);
388
+ }
389
+ const numbered = places.filter(([holder, key]) => CARD_NUMBER.test(String(holder[key])));
390
+ if (!numbered.length) return;
391
+ withBoard((b) => view(b, (db) => {
392
+ for (const [holder, key] of numbered) {
393
+ const n = Number(CARD_NUMBER.exec(String(holder[key]))[1]);
394
+ const id = engine.cardByNumber(db, n);
395
+ if (!id) throw new Error(`no card #${n} on this board`);
396
+ holder[key] = id;
397
+ }
398
+ }));
399
+ }
400
+
369
401
  /** One verb in full, in the call it runs in (call.mjs): a caller's in the owner, or the process's own here. */
370
402
  export async function runVerb(argv) {
371
403
  const args = parse(argv);
@@ -390,6 +422,7 @@ export async function runVerb(argv) {
390
422
  const open = (create = false) => { const b = openBoard(root, slug, { create, document: args.document ?? null, backing: create ? args.backing ?? null : null }); b.label = boardLabel(root, slug); return b; };
391
423
  const withBoard = (fn, create = false) => { const b = open(create); try { return fn(b); } finally { b.close(); } };
392
424
  const withBoardAsync = async (fn, create = false) => { const b = open(create); try { return await fn(b); } finally { b.close(); } };
425
+ resolveCardNumbers(verb, rest, args, withBoard);
393
426
  const id = rest[0];
394
427
  const need = (n, what) => { if (rest.length < n) throw new Error(`${verb} needs ${what}`); };
395
428
  const ids = (first = rest) => [...first, ...(args.ids ?? [])];
@@ -546,7 +579,7 @@ export async function runVerb(argv) {
546
579
  const recipients = withBoard(b => engine.subscriptions(b.db, created.id)).filter(s => s.platform === 'supercode').map(s => s.chat_id);
547
580
  if (args.json) { say(args, '', { ...cardDict(task), ...answer, notify: address ?? null, recipients }); return answer.failed ? 1 : 0; }
548
581
  const said = answer.adoption_requested ? `; held for ${answer.adoption_requested}, which the dispatcher binds on its next tick` : answer.started ? `; started session ${answer.session} (${answer.mode})` : answer.failed ? `; did not start: ${answer.failed}` : answer.waits ? `; waits: ${answer.waits}` : '';
549
- say(args, `${created.existing ? 'Existing' : 'Created'} ${created.id} (${task.status}, assignee=${task.assignee || '-'})${said}${recipients.length ? `; notices go to ${recipients.join(', ')}` : ''}`, null);
582
+ say(args, `${created.existing ? 'Existing' : 'Created'} ${engine.cardLabel(task.number, created.id)} (${task.status}, assignee=${task.assignee || '-'})${said}${recipients.length ? `; notices go to ${recipients.join(', ')}` : ''}`, null);
550
583
  return answer.failed ? 1 : 0;
551
584
  }
552
585
 
@@ -612,14 +645,16 @@ export async function runVerb(argv) {
612
645
  say(args, '', { task: cardDict(t), side_sessions: withBoard((b) => sideSessions(b.db, t.id)), latest_summary: latest, parents: card.parents.map((p) => p.id), children: card.children, subtasks: card.subtasks, comments: card.comments, events: card.events, runs: card.runs, time: card.time, subscriptions: card.subscriptions, diagnostics: card.diagnostics });
613
646
  return 0;
614
647
  }
615
- const out = [`Task ${t.id}: ${t.title}`, ` status: ${taskStatusOf(card.stage, t.completed_at)} (lane ${card.stage}${card.stage !== t.status ? `, ${t.status} in kanban.db` : ''})`, ` assignee: ${t.assignee || '-'}`];
648
+ const labels = withBoard((b) => view(b, (db) => engine.numbersOf(db, [t.subtask_of, ...card.parents.map((p) => p.id), ...card.children].filter(Boolean))));
649
+ const label = (cardId) => engine.cardLabel(labels.get(cardId), cardId);
650
+ const out = [`Task ${engine.cardLabel(t.number, t.id)}: ${t.title}`, ` status: ${taskStatusOf(card.stage, t.completed_at)} (lane ${card.stage}${card.stage !== t.status ? `, ${t.status} in kanban.db` : ''})`, ` assignee: ${t.assignee || '-'}`];
616
651
  if (t.machine) out.push(` machine: ${t.machine}`);
617
652
  if (t.reviewer) out.push(` reviewer: ${t.reviewer} (card override)`);
618
653
  if (t.detached) out.push(` session: ${t.detached.session.address} belongs to ${t.detached.person} (detached ${ts(t.detached.at)}; dispatcher does not own it)`);
619
654
  // where the card's time went: each role's runs, and the rest waiting
620
655
  const mins = (sec) => `${Math.round(sec / 60)}m`;
621
656
  out.push(` time: ${Object.entries(card.time).filter(([, v]) => v > 0).map(([k, v]) => `${k} ${mins(v)}`).join(', ') || '-'}`);
622
- if (t.subtask_of) out.push(` subtask of: ${t.subtask_of}`);
657
+ if (t.subtask_of) out.push(` subtask of: ${label(t.subtask_of)}`);
623
658
  if (t.tenant) out.push(` tenant: ${t.tenant}`);
624
659
  out.push(` workspace: ${t.workspace_kind}${t.workspace_anchor ? `:${t.workspace_anchor}` : ''}`);
625
660
  if (t.workspace_path) out.push(` resolved: ${t.workspace_path}`);
@@ -638,9 +673,9 @@ export async function runVerb(argv) {
638
673
  }
639
674
  if (t.started_at) out.push(` started: ${ts(t.started_at)}`);
640
675
  if (t.completed_at) out.push(` completed: ${ts(t.completed_at)}`);
641
- if (card.parents.length) out.push(` parents: ${card.parents.map((p) => p.id).join(', ')}`);
642
- if (card.children.length) out.push(` children: ${card.children.join(', ')}`);
643
- if (card.subtasks.length) out.push(` subtasks: ${card.subtasks.map((s) => `${s.id} (${s.status})`).join(', ')}`);
676
+ if (card.parents.length) out.push(` parents: ${card.parents.map((p) => label(p.id)).join(', ')}`);
677
+ if (card.children.length) out.push(` children: ${card.children.map(label).join(', ')}`);
678
+ if (card.subtasks.length) out.push(` subtasks: ${card.subtasks.map((s) => `${engine.cardLabel(s.number, s.id)} (${s.status})`).join(', ')}`);
644
679
  const side = withBoard((b) => sideSessions(b.db, t.id));
645
680
  if (side.length) out.push(` side sessions: ${side.map((s) => s.address).join(', ')}`);
646
681
  if (t.body) out.push('', 'Body:', t.body);
@@ -1063,16 +1098,24 @@ export async function runVerb(argv) {
1063
1098
  const reports = await plan({ root, lanes, settings });
1064
1099
  const out = { capacity: reports.capacity, would: reports.flatMap((r) => r.would.map((w) => w.id)), waiting: reports.flatMap((r) => r.queued.map(([id, why]) => ({ id, why }))) };
1065
1100
  if (args.json) { say(args, '', out); return 0; }
1101
+ // each card as a person reads it, by the number its own board gave it
1102
+ const shown = [...out.would, ...out.waiting.map((w) => w.id), ...out.capacity.flatMap((cap) => cap.groups.flatMap((g) => g.cards))];
1103
+ const labels = new Map();
1104
+ for (const board of listBoards(root)) {
1105
+ const b = openBoard(root, board);
1106
+ try { for (const [cardId, n] of view(b, (db) => engine.numbersOf(db, shown))) labels.set(cardId, engine.cardLabel(n, cardId)); } finally { b.close(); }
1107
+ }
1108
+ const label = (cardId) => labels.get(cardId) ?? cardId;
1066
1109
  const lines = [];
1067
1110
  for (const cap of out.capacity) {
1068
1111
  const filter = Object.entries(cap.where ?? {}).map(([k, v]) => `${k} ${[].concat(v).join('|')}`).join(', ');
1069
1112
  const except = Object.entries(cap.unless ?? {}).map(([k, v]) => `${k} ${[].concat(v).join('|')}`).join(', ');
1070
1113
  lines.push(`cap "${cap.name}": ${cap.counts} per ${cap.scope}${filter ? `, where ${filter}` : ''}${except ? `, unless ${except}` : ''}, max ${cap.max}`);
1071
1114
  if (!cap.groups.length) lines.push(' (nothing counted)');
1072
- for (const g of cap.groups) lines.push(` ${cap.scope === 'global' ? 'home' : g.group ?? '(none)'}: ${g.count} of ${g.max ?? 'no max'} ${[...new Set(g.cards)].join(' ')}`);
1115
+ for (const g of cap.groups) lines.push(` ${cap.scope === 'global' ? 'home' : g.group ?? '(none)'}: ${g.count} of ${g.max ?? 'no max'} ${[...new Set(g.cards)].map(label).join(', ')}`);
1073
1116
  }
1074
- lines.push(out.would.length ? `would start: ${out.would.join(' ')}` : 'would start: nothing');
1075
- for (const w of out.waiting) lines.push(`${w.id} ${w.why}`);
1117
+ lines.push(out.would.length ? `would start: ${out.would.map(label).join(', ')}` : 'would start: nothing');
1118
+ for (const w of out.waiting) lines.push(`${label(w.id)} ${w.why}`);
1076
1119
  current().out(`${lines.join('\n')}\n`);
1077
1120
  return 0;
1078
1121
  }
@@ -51,7 +51,7 @@ import { doors, fire, loadWorkflow, resumeOf, runSpec, runWorkingSeconds, reques
51
51
  import { checkWorkspace, assertWorkspaceOwner, notePresence, resolveWorkspace } from './workspace.mjs';
52
52
  import { capacity, dispatched, executionHost, harnessDefaults, holdings } from './capacity.mjs';
53
53
  import { evaluate, holds, render } from './expr.mjs';
54
- import { identifyNotice } from './notices.mjs';
54
+ import { identifyNotice, numbered } from './notices.mjs';
55
55
  import { noticeReceiptsInTurns, noticeKey, requireNoticeBasis, noticeWriterBasis } from './notice-receipts.mjs';
56
56
  import { pendingRequestMail } from './request-mail.mjs';
57
57
  import {
@@ -1800,7 +1800,9 @@ export async function sessionNotice(board, e) {
1800
1800
  }
1801
1801
  const address = e.address ?? addressOf(prior);
1802
1802
  if (!address) throw new Error('lifecycle notice awaits the owned launch session identity');
1803
- const filed = await fileMail(board, address, e.text, effectMessageKey(e));
1803
+ const card = view(board, (db) => getTask(db, e.id));
1804
+ const subject = numbered(card?.number, `${String(e.verb ?? 'run_changed').replaceAll('_', ' ')} · run ${e.runId} · event ${e.sourceEvent ?? 'none'} · ${card?.title || e.id}`);
1805
+ const filed = await fileMail(board, address, e.text, effectMessageKey(e), { subject });
1804
1806
  tx(board, (db) => {
1805
1807
  const session = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(e.runId)?.metadata, {})?.supercode;
1806
1808
  if (session?.lifecycle_notice_pending === e.durableId) setRunSession(db, e.runId, { lifecycle_notice_pending: null });
@@ -1819,7 +1821,8 @@ export async function sessionEffect(board, e) {
1819
1821
  const address = addressOf(session);
1820
1822
  if (!address) throw new Error('actor session identity unavailable');
1821
1823
  // filed in the actor's mailbox, which delivers it
1822
- const filed = await fileMail(board, address, e.text, effectMessageKey(e));
1824
+ const card = view(board, (db) => getTask(db, e.id));
1825
+ const filed = await fileMail(board, address, e.text, effectMessageKey(e), { subject: numbered(card?.number, `actor notice · run ${e.runId ?? 'none'} · ${card?.title || e.id}`) });
1823
1826
  if (filed.new) tx(board, db => event(db, e.id, 'actor_notice_filed', { notice: e.durableId, address, message_id: filed.message_id }, e.runId));
1824
1827
  return true;
1825
1828
  }
@@ -2862,7 +2865,7 @@ export async function fileBoardMail(board, report, taskId = null) {
2862
2865
  const first = mail.members[0];
2863
2866
  try {
2864
2867
  const task = view(board, db => getTask(db, first.task_id));
2865
- const subject = mail.requester ? `State requests · ${mail.members.length} request(s) · ${task?.title || first.task_id}` : `${task?.title || first.task_id} · ${mail.message_id ? 'Question' : 'Update'}`;
2868
+ const subject = numbered(task?.number, mail.requester ? `State requests · ${mail.members.length} request(s) · ${task?.title || first.task_id}` : `${task?.title || first.task_id} · ${mail.message_id ? 'Question' : 'Update'}`);
2866
2869
  const filed = await fileMail(board, mail.to, mail.text, mail.message_id ?? `board-message-${first.id}`, { ask: Boolean(mail.message_id), subject });
2867
2870
  tx(board, db => {
2868
2871
  if (filed.detached) {
@@ -2893,7 +2896,7 @@ export function noticeOf(entry, audience, kind, task, payload, comment = null) {
2893
2896
  if (template === undefined || template === null) return null;
2894
2897
  const cut = (text, n) => String(text ?? '').slice(0, Number(n));
2895
2898
  const text = render(template, { card: task, event: payload ?? {}, comment, params: entry.params, cut });
2896
- return text.trim() ? text : null;
2899
+ return text.trim() ? numbered(task?.number, text) : null;
2897
2900
  }
2898
2901
 
2899
2902
  /**
@@ -3067,7 +3070,7 @@ async function notify(board, fleet, report, entry) {
3067
3070
  if (Number(currentSub.last_event_id) >= ev.id) { cursor = Math.max(cursor, Number(currentSub.last_event_id)); continue; }
3068
3071
  const body = ['worker', 'dependency'].includes(role) ? identifyNotice(ev, text) : text;
3069
3072
  // filed under its fixed key: a second pass filing the same notice files nothing new
3070
- const filed = await fileMail(board, sub.chat_id, body, `board-${sub.task_id}-${ev.id}-${sub.chat_id.slice(-12)}`, { wake, subject: `${ev.kind.replaceAll('_', ' ')} · run ${ev.run_id ?? 'none'} · event ${ev.id} · ${task?.title || sub.task_id}`,
3073
+ const filed = await fileMail(board, sub.chat_id, body, `board-${sub.task_id}-${ev.id}-${sub.chat_id.slice(-12)}`, { wake, subject: numbered(task?.number, `${ev.kind.replaceAll('_', ' ')} · run ${ev.run_id ?? 'none'} · event ${ev.id} · ${task?.title || sub.task_id}`),
3071
3074
  eligible: () => view(board, db => {
3072
3075
  const currentSub = db.prepare(`SELECT last_event_id FROM kanban_notify_subs ${where}`).get(...identity);
3073
3076
  return Boolean(currentSub) && Number(currentSub.last_event_id) < ev.id;
package/board/engine.mjs CHANGED
@@ -15,7 +15,7 @@ import { existsSync, rmSync } from 'node:fs';
15
15
  import { join, resolve } from 'node:path';
16
16
  import { checkWorkspace, assertWorkspaceOwner, declareWorkspace, isLocalWorkspace, plainWorkspace } from './workspace.mjs';
17
17
  import { DatabaseSync } from 'node:sqlite';
18
- import { view, actorOf, principalOf, claimer, contention, event, json, newTaskId, now, retryContention, tx } from './store.mjs';
18
+ import { view, actorOf, principalOf, CARD_NUMBERS, claimer, contention, event, json, newTaskId, now, retryContention, tx } from './store.mjs';
19
19
  import { assertSourceReceipt, completionPhase, inspectCompletionEvidence, observeCompletionEvidence, withCompletionEvidence } from './ci.mjs';
20
20
  import { addDependency, retireSupersededRuns, refreshCardInputs, requestSessionClose, reviewerOf, eventTo, fire, validateEvent, loadWorkflow, setStatus, settle, startNew, statusOf, runEvent, workflowFor } from './workflow.mjs';
21
21
  import { refuseCredentialDump, withheldIfDump } from './credential-guard.mjs';
@@ -38,11 +38,22 @@ const one = (db, sql, ...args) => db.prepare(sql).get(...args);
38
38
  const all = (db, sql, ...args) => db.prepare(sql).all(...args);
39
39
  const run = (db, sql, ...args) => db.prepare(sql).run(...args);
40
40
 
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.
43
+ /** Card `n`'s id on this board, or null when no card here has that number. */
44
+ 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
+ /** The number of card `id`, or null. */
46
+ export const numberOf = (db, id) => one(db, `SELECT number FROM ${CARD_NUMBERS} WHERE task_id = ?`, id)?.number ?? null;
47
+ /** The numbers of cards `ids`: id → number. */
48
+ 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));
51
+
41
52
  export function getTask(db, id) {
42
53
  const row = one(db, 'SELECT * FROM tasks WHERE id = ?', id);
43
54
  if (!row) return null;
44
55
  const ours = one(db, 'SELECT * FROM supercode_cards WHERE task_id = ?', id);
45
- return { ...row, 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,
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,
46
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) };
47
58
  }
48
59
 
@@ -880,7 +891,7 @@ export const subscriptions = (db, id = null) => (id ? all(db, 'SELECT * FROM kan
880
891
  // ------------------------------------------------------------------ reading
881
892
 
882
893
  export function listCards(db, { status = null, assignee = null, machine = null, includeArchived = false } = {}) {
883
- const rows = all(db, `SELECT t.*, 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
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
884
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')
885
896
  ORDER BY t.priority DESC, t.created_at ASC`, status, status, status, assignee, assignee, machine, machine, includeArchived ? 1 : 0);
886
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) }));
@@ -892,7 +903,7 @@ export function showCard(db, id) {
892
903
  task,
893
904
  parents: parentsOf(db, id),
894
905
  children: childrenOf(db, id),
895
- subtasks: all(db, 'SELECT t.id, t.status, t.title FROM tasks t JOIN supercode_cards c ON c.task_id = t.id WHERE c.subtask_of = ? ORDER BY t.created_at', 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),
896
907
  comments: all(db, 'SELECT author, body, created_at FROM task_comments WHERE task_id = ? ORDER BY id', id),
897
908
  runs: all(db, 'SELECT * FROM task_runs WHERE task_id = ? ORDER BY id', id).map((r) => ({ ...r, metadata: json(r.metadata, null) })),
898
909
  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) })),
package/board/files.mjs CHANGED
@@ -11,6 +11,7 @@
11
11
  import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
12
12
  import { fenced } from './fence.mjs';
13
13
  import { markRemovedHeads } from './published.mjs';
14
+ import { CARD_NUMBERS } from './store.mjs';
14
15
  import { createHash } from 'node:crypto';
15
16
  import { join } from 'node:path';
16
17
  import { DatabaseSync } from 'node:sqlite';
@@ -98,6 +99,9 @@ class FilesBoard {
98
99
  const state = this.stateText === null ? null : JSON.parse(this.stateText);
99
100
  for (const sql of state?.schema ?? []) mem.exec(sql);
100
101
  this.init(mem, { fresh: !state?.schema?.length });
102
+ // the schema as the directory keeps it: a layout this load added (a table, a column, the card numbers) is written to
103
+ // every card's file by the next write (persist)
104
+ this.keptSchema = state?.schema ?? [];
101
105
  const columns = (table) => new Set(mem.prepare(`PRAGMA table_info(${quote(table)})`).all().map((c) => c.name));
102
106
  const insert = (table, rows) => {
103
107
  const have = columns(table);
@@ -108,17 +112,26 @@ class FilesBoard {
108
112
  };
109
113
  const previous = this.texts;
110
114
  this.texts = new Map();
115
+ const cards = [];
111
116
  for (const name of readdirSync(this.dir)) {
112
117
  if (!name.endsWith('.json') || name.startsWith('_')) continue;
113
118
  const text = readFileSync(join(this.dir, name), 'utf8');
114
119
  const card = JSON.parse(text);
115
120
  this.texts.set(card.id, text);
116
- for (const [table, rows] of Object.entries(card.tables ?? {})) insert(table, rows);
121
+ cards.push(card);
117
122
  }
118
- for (const [table, rows] of Object.entries(state?.tables ?? {})) insert(table, rows);
123
+ // The id counters first: a number handed out before stays handed out.
119
124
  for (const { name, seq } of state?.sequence ?? []) {
120
125
  if (mem.prepare('UPDATE sqlite_sequence SET seq = MAX(seq, ?) WHERE name = ?').run(seq, name).changes === 0) mem.prepare('INSERT INTO sqlite_sequence (name, seq) VALUES (?, ?)').run(name, seq);
121
126
  }
127
+ // Inserting a card's row numbers it when it has no number yet (store.mjs layOutNumbers): every number a card file
128
+ // holds loads first, so each card keeps its own and a new one is past them all, and then the cards load in the
129
+ // order they were made.
130
+ for (const card of cards) if (card.tables?.[CARD_NUMBERS]) insert(CARD_NUMBERS, card.tables[CARD_NUMBERS]);
131
+ const madeAt = (card) => Number(card.tables?.tasks?.[0]?.created_at ?? 0);
132
+ cards.sort((a, b) => madeAt(a) - madeAt(b) || (String(a.id) < String(b.id) ? -1 : String(a.id) > String(b.id) ? 1 : 0));
133
+ for (const card of cards) for (const [table, rows] of Object.entries(card.tables ?? {})) if (table !== CARD_NUMBERS) insert(table, rows);
134
+ for (const [table, rows] of Object.entries(state?.tables ?? {})) insert(table, rows);
122
135
  // Loading inserts every card, which marks every card changed (published.mjs). The first load of this projection keeps
123
136
  // those marks: its next write compares each card with its published head once (a named limit: each projection opened
124
137
  // compares every card once, as each load already reads every card's file). A reload keeps the marks not yet
@@ -172,7 +185,8 @@ class FilesBoard {
172
185
  /** Write the files of the cards the transaction touched, then `_board.json`; writes compare the content hash. */
173
186
  persist(schemaBefore) {
174
187
  const schema = this.schema();
175
- const reshaped = schema.join('\n') !== schemaBefore.join('\n');
188
+ // reshaped by this transaction, or by the load it runs on (a layout the directory does not keep yet)
189
+ const reshaped = schema.join('\n') !== schemaBefore.join('\n') || schema.join('\n') !== (this.keptSchema ?? []).join('\n');
176
190
  const touched = new Set(this.mem.prepare('SELECT DISTINCT id FROM _touched').all().map((r) => r.id).filter((id) => id !== null));
177
191
  if (reshaped) this.watch(); // a table made in the transaction gets its triggers; every card is written again
178
192
  const keys = this.keys;
package/board/listing.mjs CHANGED
@@ -68,11 +68,12 @@ export const LAST_BLOCK_SQL = `SELECT CASE WHEN kind = 'blocked' THEN payload EN
68
68
 
69
69
  export function listPageSql(detailed, tagCount = 0) {
70
70
  return `SELECT t.rowid AS list_order,${(detailed ? JSON_FIELDS : HUMAN_FIELDS).split(',').map(name => `t.${name}`).join(',')},
71
- c.machine,c.stage${detailed ? ',c.subtask_of' : ''},
71
+ n.number,c.machine,c.stage${detailed ? ',c.subtask_of' : ''},
72
72
  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
73
  ${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
74
  (${LAST_BLOCK_SQL}) AS block_payload` : ''}
75
- FROM tasks t ${detailed ? '' : `INDEXED BY ${TASK_SUMMARY_INDEX}`} LEFT JOIN supercode_cards c ON c.task_id = t.id LEFT JOIN task_runs r ON r.id = t.current_run_id
75
+ 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
+ LEFT JOIN task_runs r ON r.id = t.current_run_id
76
77
  WHERE t.id IN (SELECT value FROM json_each(?)) AND (? IS NULL OR t.status = ? OR c.stage = ?)
77
78
  AND (? IS NULL OR t.assignee = ?) AND (? IS NULL OR c.machine = ?) AND (? OR t.status != 'archived')
78
79
  AND (? IS NULL OR t.tenant = ?) AND (? IS NULL OR t.session_id = ?)
package/board/notices.mjs CHANGED
@@ -1,3 +1,8 @@
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);
3
+ /** The number of card `id` in the board's store, or null. */
4
+ export const cardNumber = (db, id) => db.prepare('SELECT number FROM supercode_card_numbers WHERE task_id = ?').get(id)?.number ?? null;
5
+
1
6
  export function identifyNotice(event, text) {
2
7
  const time = new Date(Number(event.created_at) * 1000).toISOString();
3
8
  const kind = String(event.kind).replaceAll('_', ' ');
package/board/store.mjs CHANGED
@@ -276,6 +276,34 @@ function layOutHistoryIndexes(db) {
276
276
  }
277
277
  }
278
278
 
279
+ // A card's number (#42): short and sequential, given once when the card is made, never changed and never given to
280
+ // another card, as an issue tracker numbers its issues. The store gives it, whoever makes the card (a verb, a document
281
+ // import, Hermes's own writer): a trigger on `tasks` numbers each new card id. AUTOINCREMENT never hands a number out
282
+ // twice, and a number stays with its card id while the card's row is replaced or removed. The trigger inserts only
283
+ // where the id has no number yet, so no conflict arises in it (a writing statement's own conflict clause would govern
284
+ // the trigger's insert: published.mjs). Its first installation numbers the cards already there, in the order they were
285
+ // made (created_at, then id), in the same savepoint.
286
+ export const CARD_NUMBERS = 'supercode_card_numbers';
287
+ function layOutNumbers(db) {
288
+ if (db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'trigger' AND name = 'supercode_number_cards'").get()) return;
289
+ db.exec('SAVEPOINT supercode_card_numbering');
290
+ try {
291
+ db.exec(`CREATE TABLE IF NOT EXISTS ${CARD_NUMBERS} (number INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL UNIQUE);
292
+ INSERT INTO ${CARD_NUMBERS} (task_id) SELECT id FROM tasks WHERE id NOT IN (SELECT task_id FROM ${CARD_NUMBERS}) ORDER BY created_at, id;
293
+ CREATE TRIGGER IF NOT EXISTS supercode_number_cards AFTER INSERT ON tasks BEGIN
294
+ INSERT INTO ${CARD_NUMBERS} (task_id) SELECT NEW.id WHERE NOT EXISTS (SELECT 1 FROM ${CARD_NUMBERS} WHERE task_id = NEW.id);
295
+ END;`);
296
+ db.exec('RELEASE supercode_card_numbering');
297
+ } catch (error) { db.exec('ROLLBACK TO supercode_card_numbering; RELEASE supercode_card_numbering'); throw error; }
298
+ }
299
+
300
+ /** Raise the numbers' high-water mark to the highest number held, and to `floor`: AUTOINCREMENT hands out only numbers
301
+ * above it. For a store whose numbers were given or copied explicitly. */
302
+ export function raiseCardNumbers(db, floor = 0) {
303
+ db.prepare(`INSERT INTO sqlite_sequence (name, seq) SELECT ?, 0 WHERE NOT EXISTS (SELECT 1 FROM sqlite_sequence WHERE name = ?)`).run(CARD_NUMBERS, CARD_NUMBERS);
304
+ db.prepare(`UPDATE sqlite_sequence SET seq = MAX(seq, ?, (SELECT COALESCE(MAX(number), 0) FROM ${CARD_NUMBERS})) WHERE name = ?`).run(Number(floor) || 0, CARD_NUMBERS);
305
+ }
306
+
279
307
  /** Hermes's tables on a fresh board, supercode's own table and columns on every one. */
280
308
  export function layOut(db, { fresh }) {
281
309
  if (fresh) db.exec(SCHEMA);
@@ -287,6 +315,7 @@ export function layOut(db, { fresh }) {
287
315
  // each read walked every card's row. Laid out here, after the column it names exists on a store of any age and before
288
316
  // any statement is prepared; no statement names it, so a store without it is only slower.
289
317
  db.exec('CREATE INDEX IF NOT EXISTS idx_supercode_cards_subtask_of ON supercode_cards(subtask_of)');
318
+ layOutNumbers(db);
290
319
  layOutEventIndex(db);
291
320
  layOutHistoryIndexes(db);
292
321
  // A covering summary index keeps human listings out of task records' body overflow pages.
@@ -18,7 +18,7 @@ import { releaseAllSideSessions } from './side-sessions.mjs';
18
18
  import { evaluate, holds, render, needsSources } from './expr.mjs';
19
19
  import { completionEvidence, declaredWorkBranches, namedWorkBranch, reviewCandidate } from './ci.mjs';
20
20
  import { cardCommitsOn, isLocalWorkspace, isWorkspacePath } from './workspace.mjs';
21
- import { identifyNotice } from './notices.mjs';
21
+ import { cardNumber, identifyNotice, numbered } from './notices.mjs';
22
22
 
23
23
  const loaded = new Map();
24
24
  // the owner's read door holds the workflow the owner reads (owner-door.mjs), told when it is read again
@@ -593,7 +593,7 @@ function queueTransitionNotice(db, board, entry, task, from, name, payload, befo
593
593
  const defaultNotice = noticeSession
594
594
  ? '{{card.id}}: verified adoption started run {{run.id}} ({{event.from}} → {{event.to}}). Follow the board’s current state before continuing.'
595
595
  : '{{card.id}}: {{event.verb}} changed run {{run.id}} ({{event.from}} → {{event.to}}). Stop work under the previous run; follow the board’s current state. {{event.reason ?? ""}}';
596
- const body = render(entry.workflow.notify?.worker?.run_changed ?? defaultNotice, scope);
596
+ const body = numbered(cardNumber(db, task.id), render(entry.workflow.notify?.worker?.run_changed ?? defaultNotice, scope));
597
597
  const source = one(db, 'SELECT MAX(id) AS id FROM task_events WHERE task_id = ?', task.id)?.id;
598
598
  const text = identifyNotice({ id: source, kind: name, run_id: targetRun.id, created_at: now() }, body);
599
599
  const effect = { kind: 'session_notice', id: task.id, runId: targetRun.id, address, text, sourceEvent: source, verb: name, messageKey: `board-run-notice-${randomBytes(16).toString('hex')}` };
@@ -825,12 +825,14 @@ function dueAfter(db, workflow, task, status, scope) {
825
825
 
826
826
  /**
827
827
  * Where a new card starts: the first `initial` transition that holds (`event` is the create's own words), its status
828
- * set and its actions run (a goal card by the workflow's default, for one). Answers the status.
828
+ * set and its actions run (a goal card by the workflow's default, for one). Answers the status. A validator that fails
829
+ * refuses the create, as it refuses any other event (a home that keeps its root unassigned says so here).
829
830
  */
830
831
  export function startNew(db, board, entry, task, event) {
831
832
  const scope = scopeOf(db, board, task, { params: entry.params, event, extra: { lanes: lanesIn(entry) } });
832
833
  const t = entry.workflow.initial.find((x) => holds(x.guard ?? null, scope));
833
834
  if (!t?.target) throw new Error('the workflow says nowhere for a new card to start');
835
+ for (const v of t.validate ?? []) if (!holds(v.expr, scope)) throw new Error(render(v.message, scope));
834
836
  const status = String(templateValue(t.target, scope));
835
837
  if (!entry.workflow.statuses[status]) throw new Error(`the workflow starts a new card in ${status}, which it does not declare`);
836
838
  setStatus(db, entry.workflow, task.id, status);
@@ -1186,7 +1188,7 @@ const ACTIONS = {
1186
1188
  send_to_actor(ctx, text) {
1187
1189
  const effect = { kind: 'send_to_actor', id: ctx.id, runId: ctx.runId, text, messageKey: `board-actor-${randomBytes(16).toString('hex')}` };
1188
1190
  const inserted = run(ctx.db, 'INSERT INTO supercode_effects (effect) VALUES (?)', JSON.stringify(effect));
1189
- effect.text = identifyNotice({ id: `notice-${inserted.lastInsertRowid}`, kind: 'actor_notice', run_id: ctx.runId, created_at: now() }, text);
1191
+ effect.text = identifyNotice({ id: `notice-${inserted.lastInsertRowid}`, kind: 'actor_notice', run_id: ctx.runId, created_at: now() }, numbered(cardNumber(ctx.db, ctx.id), text));
1190
1192
  run(ctx.db, 'UPDATE supercode_effects SET effect=? WHERE id=?', JSON.stringify(effect), Number(inserted.lastInsertRowid));
1191
1193
  ctx.effects.push({ ...effect, durableId: Number(inserted.lastInsertRowid) });
1192
1194
  },
package/board/ztrack.mjs CHANGED
@@ -15,10 +15,10 @@ import { current } from './call.mjs';
15
15
  import { fenced } from './fence.mjs';
16
16
  import { createRequire } from 'node:module';
17
17
  import { createHash, randomBytes } from 'node:crypto';
18
- import { existsSync, linkSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
18
+ import { existsSync, linkSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
19
19
  import { basename, dirname, join, resolve } from 'node:path';
20
20
  import { DatabaseSync } from 'node:sqlite';
21
- import { actorOf, contention, event, json, now, principalOf, storageFault, STORAGE_TRIES, unreadable } from './store.mjs';
21
+ import { actorOf, CARD_NUMBERS, contention, raiseCardNumbers, event, json, now, principalOf, storageFault, STORAGE_TRIES, unreadable } from './store.mjs';
22
22
  import { GitDocument } from './git-document.mjs';
23
23
  import { unimported } from './legacy-store.mjs';
24
24
  import { noteStep, recordSlow } from '@volter/supercode-harness-sdk/slow-log';
@@ -104,6 +104,9 @@ const ARCHIVE_LINES = 2000;
104
104
  // document changed while it was read (readSnapshot).
105
105
  const SNAPSHOT_TRIES = 8;
106
106
  const isArchive = (role) => role === 'archived' || String(role ?? '').startsWith('archived:');
107
+ // The open document's record of the numbers the board has given (numberMark), and a task line's number (`- [ ] #2836 c3`).
108
+ const NUMBER_MARK = /^The board has given card numbers up to #(\d+)\.[ \t]*$/m;
109
+ const TASK_NUMBER = /^\s*[-*] \[[ xX]\]\s+#(\d+)\s+[a-z]+\d+\s/gm;
107
110
 
108
111
  const BOARD_TABLES = `
109
112
  CREATE TABLE IF NOT EXISTS board_state (key TEXT PRIMARY KEY, value TEXT NOT NULL);
@@ -440,6 +443,13 @@ class ZtrackBoard {
440
443
  put(`chunks:${role}`, entries);
441
444
  }
442
445
  put('published', published);
446
+ // The earlier store held no numbers: its cards take the numbers their documents' headings give them, and the rest
447
+ // are numbered in the order they were made, after every number the documents name (documentNumbers).
448
+ db.exec(`DELETE FROM ${CARD_NUMBERS}; DELETE FROM sqlite_sequence WHERE name = '${CARD_NUMBERS}'`);
449
+ const named = this.documentNumbers();
450
+ this.numberFromHeadings([...named.headings].filter(([id]) => db.prepare('SELECT 1 FROM tasks WHERE id = ?').get(id)), db);
451
+ raiseCardNumbers(db, named.highest);
452
+ db.exec(`INSERT INTO ${CARD_NUMBERS} (task_id) SELECT id FROM tasks WHERE id NOT IN (SELECT task_id FROM ${CARD_NUMBERS}) ORDER BY created_at, id`);
443
453
  db.exec('COMMIT');
444
454
  }
445
455
 
@@ -556,6 +566,13 @@ class ZtrackBoard {
556
566
  timedStep('board tx snapshot', () => this.snapshotHeld());
557
567
  const result = timedStep('board tx body', () => fn(this.db));
558
568
  if (this.contended) throw RETRY;
569
+ // The first write made with a codec that renders numbers (numbersRendered) after the store numbered its cards
570
+ // (store.mjs layOutNumbers) writes every card of the open document again, with its number in its heading and each
571
+ // open subtask's on its task line; a card made since is written with its number when it is made.
572
+ if (this.numbersRendered && !this.stateText('numbers_written')) {
573
+ this.statement('INSERT INTO temp.board_touched SELECT value FROM json_each(?)').run(JSON.stringify(this.entries('open').filter((e) => e.id).map((e) => nativeId(e.id))));
574
+ this.put('numbers_written', true);
575
+ }
559
576
  const files = timedStep('board tx render', () => this.render());
560
577
  const intent = files.length ? randomBytes(16).toString('hex') : null;
561
578
  if (intent) {
@@ -658,7 +675,7 @@ class ZtrackBoard {
658
675
  // connection-local copies by a read. Only the spans the edit touched are parsed.
659
676
  // `held` names cards read again from their indexed span with no edit (see render).
660
677
  observe(stale, held = []) {
661
- const plan = { layout: new Map(), dropped: [], published: {}, cards: new Map(), rows: new Map(), subtasks: new Map(), tasks: new Map(), remove: new Set(), errors: new Map(), checked: new Set(), before: new Map() };
678
+ const plan = { layout: new Map(), dropped: [], published: {}, cards: new Map(), numbers: new Map(), rows: new Map(), subtasks: new Map(), tasks: new Map(), remove: new Set(), errors: new Map(), checked: new Set(), before: new Map() };
662
679
  const changed = new Set();
663
680
  for (const doc of this.docs) {
664
681
  const entries = this.entries(doc.role);
@@ -699,11 +716,13 @@ class ZtrackBoard {
699
716
  catch (error) { plan.errors.set(id, error.message); continue; }
700
717
  entry.rec = hash(JSON.stringify(records[0]));
701
718
  plan.cards.set(id, card);
719
+ if (records[0].number) plan.numbers.set(id, records[0].number);
702
720
  }
703
721
  for (const [id, card] of plan.cards) {
704
722
  plan.rows.set(id, this.cardRows(id, card, null));
705
723
  const subtasks = [];
706
- for (const [tid, taskCard, key, sources] of this.taskCards(id, card)) {
724
+ for (const [tid, taskCard, key, sources, number] of this.taskCards(id, card)) {
725
+ if (number) plan.numbers.set(tid, number);
707
726
  plan.rows.set(tid, this.cardRows(tid, taskCard, id));
708
727
  plan.tasks.set(tid, { key, sources });
709
728
  subtasks.push(tid);
@@ -852,6 +871,13 @@ class ZtrackBoard {
852
871
  this.statement(`DELETE FROM ${schema}.task_links WHERE child_id = ?`).run(id);
853
872
  if (!overlay) { this.statement('DELETE FROM main.board_tasks WHERE id = ?').run(id); if (!initial) events.push({ id, kind: 'removed', payload: { by, source: 'document' } }); }
854
873
  }
874
+ // A store made again from its documents (an empty one reading them whole) gives each card and subtask the number its
875
+ // heading or task line says, and hands out none at or below any number the documents name. Any other import keeps
876
+ // the store's numbers: a number typed into a heading or pasted with a section changes nothing.
877
+ if (initial) {
878
+ this.numberFromHeadings([...plan.numbers].filter(([id]) => plan.rows.has(id)));
879
+ raiseCardNumbers(this.conn, this.documentNumbers().highest);
880
+ }
855
881
  for (const [id, rows] of plan.rows) {
856
882
  const before = fingerprintOf(id);
857
883
  const oldTask = this.one(`SELECT * FROM ${schema}.tasks WHERE id = ?`, id) ?? {};
@@ -927,7 +953,7 @@ class ZtrackBoard {
927
953
  return { task, state };
928
954
  }
929
955
 
930
- // A card's Tasks as subtask cards: [id, card, key, sources].
956
+ // A card's Tasks as subtask cards: [id, card, key, sources, the number its line gives].
931
957
  taskCards(id, card) {
932
958
  return card.acceptanceCriteria.map((task) => {
933
959
  const key = `${id}:${task.id}`;
@@ -945,7 +971,7 @@ class ZtrackBoard {
945
971
  machine: task.metadata?.machine ?? storedState?.machine ?? card.machine, acceptanceCriteria: [], acceptance: task.lines ?? [],
946
972
  relations: (task.blockedBy ?? []).map((b) => ({ type: 'blocked-by', issueId: b.ac && /^(?:[ma]-[0-9a-f]{6,}|t_[0-9a-f]+)$/.test(b.ac) ? b.ac : b.ac ? `${nativeId(b.issue)}:${b.ac}` : nativeId(b.issue) })) };
947
973
  if (task.waitingOn) taskCard.relations.push({ type: 'blocked-by', issueId: task.waitingOn });
948
- return [tid, taskCard, key, task.sources];
974
+ return [tid, taskCard, key, task.sources, task.number];
949
975
  });
950
976
  }
951
977
 
@@ -965,7 +991,8 @@ class ZtrackBoard {
965
991
  return parents;
966
992
  };
967
993
  let parents = cardsTouched();
968
- if (!parents.size) return [];
994
+ const mark = this.numbersRendered ? this.numberMark() : null;
995
+ if (!parents.size && !mark) return [];
969
996
  const heldBy = (id) => this.errors.has(id) || this.one('SELECT 1 FROM main.supercode_cards WHERE subtask_of = ? AND task_id IN (SELECT id FROM main.board_errors)', id);
970
997
  // A card held by a recorded refusal is not rendered, so the store's verbs move its tasks past its lines. When
971
998
  // the store moves it, its span is read again: what the store moved past is stale and takes the store's value
@@ -978,6 +1005,7 @@ class ZtrackBoard {
978
1005
  const where = new Map();
979
1006
  for (const role of roles) layout[role].forEach((entry, index) => { if (entry.id) where.set(nativeId(entry.id), { role, index, entry }); });
980
1007
  const edits = Object.fromEntries(roles.map((role) => [role, []]));
1008
+ if (mark) edits.open.push(mark);
981
1009
  for (const id of parents) {
982
1010
  if (heldBy(id)) continue;
983
1011
  const task = this.one('SELECT * FROM main.tasks WHERE id = ?', id);
@@ -1041,7 +1069,8 @@ class ZtrackBoard {
1041
1069
  dropped.push(old.cid);
1042
1070
  if (edit.remove) entries.splice(edit.index, 1); else entries[edit.index] = edit.entry;
1043
1071
  }
1044
- for (const edit of edits[role].filter((e) => e.index === undefined)) {
1072
+ for (const edit of edits[role].filter((e) => e.prepend)) { text = edit.entry.text + text; entries.unshift(edit.entry); }
1073
+ for (const edit of edits[role].filter((e) => e.index === undefined && !e.prepend)) {
1045
1074
  if (text && !text.endsWith('\n\n')) {
1046
1075
  // the blank line before a new section belongs to the span above it
1047
1076
  const last = entries.at(-1), separator = text.endsWith('\n') ? '\n' : '\n\n';
@@ -1129,7 +1158,7 @@ class ZtrackBoard {
1129
1158
  const messages = blocks.filter((block) => block.kind === 'message');
1130
1159
  // A subtask is its line and its brief (its body, as Markdown under it); its id, status words, assignee and run
1131
1160
  // output stay in the store (company RFC 0025 decision 4).
1132
- card.acceptanceCriteria.push({ sources: json(stored?.sources, undefined) ?? undefined, id: key, status: ENDED.has(status) ? 'passed' : 'pending', evidence: [], text: match?.[2] ?? r.title,
1161
+ card.acceptanceCriteria.push({ sources: json(stored?.sources, undefined) ?? undefined, id: key, ...this.numberOf(r.id), status: ENDED.has(status) ? 'passed' : 'pending', evidence: [], text: match?.[2] ?? r.title,
1133
1162
  ...(task.body ? { brief: task.body } : {}), lines: task.acceptance,
1134
1163
  waitingOn: messages[0]?.id,
1135
1164
  blockedBy: blocks.filter((block) => block !== messages[0]).map((block) => {
@@ -1140,7 +1169,82 @@ class ZtrackBoard {
1140
1169
  }) });
1141
1170
  }
1142
1171
  const serialized = this.preset.serializeKanbanCard(card);
1143
- return { id: card.id, title: card.title, status: card.status, ...(card.assignee ? { assignee: card.assignee } : {}), body: serialized.body };
1172
+ return { id: card.id, ...this.numberOf(id), title: card.title, status: card.status, ...(card.assignee ? { assignee: card.assignee } : {}), body: serialized.body };
1173
+ }
1174
+
1175
+ // A card's number, shown first in its heading (`## #42 t_… — …`): the store's (store.mjs layOutNumbers). An edit to a
1176
+ // heading's number changes nothing; a store made again from its documents takes it back (numberFromHeadings). A card's
1177
+ // record keeps the key where the document codec reads it, after the id.
1178
+ numberOf(id) {
1179
+ if (!this.numbersRendered) return {};
1180
+ const number = this.one(`SELECT number FROM main.${CARD_NUMBERS} WHERE task_id = ?`, id)?.number;
1181
+ return number ? { number } : {};
1182
+ }
1183
+
1184
+ // Whether the document codec (@volter/ztrack) writes a card's number in its heading and a subtask's on its task
1185
+ // line. One that does not renders, reads and indexes cards as it always did; the open document is written with the
1186
+ // numbers by the first write made with one that does (tx), whichever was installed first.
1187
+ get numbersRendered() {
1188
+ if (this._numbersRendered === undefined) {
1189
+ try {
1190
+ const task = this.preset.serializeKanbanCard({ id: 't-00000000', title: 'number', summary: '', status: 'todo', body: '', acceptanceCriteria: [{ id: 'c1', number: 2, status: 'pending', evidence: [], text: 'number' }] }).body;
1191
+ this._numbersRendered = this.codec.renderBoardCard({ id: 't_00000000', number: 1, title: 'number', status: 'todo', body: '' }, 2).startsWith('## #1 t_00000000 ') && task.includes('- [ ] #2 c1 number');
1192
+ } catch { this._numbersRendered = false; }
1193
+ }
1194
+ return this._numbersRendered;
1195
+ }
1196
+
1197
+ // Give each card the number its document gives it (its heading, or its task line), where no other card holds that
1198
+ // number and the card has none yet; the numbers' high-water mark rises with them, so none is handed out again. Only
1199
+ // for a store made again from its documents (applyPlan, importRuntime).
1200
+ numberFromHeadings(numbers, db = this.conn) {
1201
+ if (!numbers.length) return;
1202
+ const give = db.prepare(`INSERT INTO ${CARD_NUMBERS} (number, task_id) SELECT ?, ? WHERE NOT EXISTS (SELECT 1 FROM ${CARD_NUMBERS} WHERE task_id = ? OR number = ?)`);
1203
+ for (const [id, number] of numbers) give.run(number, id, id, number);
1204
+ raiseCardNumbers(db);
1205
+ }
1206
+
1207
+ // What the board's documents (the open one, the current archive and each rotated one beside it) say of numbers:
1208
+ // `headings`, card id → the number its heading gives, and `highest`, the highest number any of them names (a
1209
+ // heading, a task line, or the open document's record of the numbers given, numberMark).
1210
+ documentNumbers() {
1211
+ const headings = new Map();
1212
+ let highest = 0;
1213
+ const dir = dirname(this.path), named = basename(this.path);
1214
+ let rotated = [];
1215
+ try { rotated = readdirSync(dir).filter((name) => name.startsWith(`${named}.archive-`) && name.endsWith('.md')).map((name) => join(dir, name)); } catch { /* none */ }
1216
+ for (const path of [this.path, this.archive, ...rotated]) {
1217
+ const text = lf(read(path));
1218
+ for (const line of text.matchAll(TASK_NUMBER)) highest = Math.max(highest, Number(line[1]));
1219
+ if (path === this.path) highest = Math.max(highest, Number(NUMBER_MARK.exec(text)?.[1] ?? 0));
1220
+ let records = [];
1221
+ try { ({ records } = this.codec.inspectBoardDocument(text, path)); } catch { continue; }
1222
+ for (const record of records) {
1223
+ if (!record.number) continue;
1224
+ highest = Math.max(highest, record.number);
1225
+ if (!headings.has(nativeId(record.id))) headings.set(nativeId(record.id), record.number);
1226
+ }
1227
+ }
1228
+ return { headings, highest };
1229
+ }
1230
+
1231
+ // The open document records the highest number the store has given (a line of its preamble, NUMBER_MARK), so a store
1232
+ // made again from the documents hands out none at or below it, whatever card held it: a subtask filed and gone, or
1233
+ // a card the documents no longer show. The edit that brings the line up to the store's count, or null.
1234
+ numberMark() {
1235
+ const given = Number(this.one('SELECT seq FROM main.sqlite_sequence WHERE name = ?', CARD_NUMBERS)?.seq ?? 0);
1236
+ if (!given) return null;
1237
+ const line = `The board has given card numbers up to #${given}.`;
1238
+ const first = this.entries('open')[0];
1239
+ if (first && first.id === null) {
1240
+ const text = this.texts([first])[0].text;
1241
+ const said = NUMBER_MARK.exec(text);
1242
+ if (said && Number(said[1]) >= given) return null;
1243
+ const next = said ? text.replace(NUMBER_MARK, line) : `${text.replace(/\n*$/, '')}\n\n${line}\n\n`;
1244
+ return { index: 0, entry: { cid: null, id: null, level: 0, len: next.length, rec: null, text: next } };
1245
+ }
1246
+ const text = `${line}\n\n`;
1247
+ return { prepend: true, entry: { cid: null, id: null, level: 0, len: text.length, rec: null, text } };
1144
1248
  }
1145
1249
 
1146
1250
  // An ended subtask's own record in the archive document: its title and brief, and the card it belongs to.
@@ -1150,7 +1254,7 @@ class ZtrackBoard {
1150
1254
  const metadata = { subtask_of: fileId(parentId), ...Object.fromEntries(Object.entries(task).filter(([k, v]) => declared(k, v))) };
1151
1255
  const card = { ...parsed, metadata, machine: state.machine || undefined, relations: [] };
1152
1256
  const serialized = this.preset.serializeKanbanCard(card);
1153
- return { id: card.id, title: card.title, status: card.status, ...(card.assignee ? { assignee: card.assignee } : {}), body: serialized.body };
1257
+ return { id: card.id, ...this.numberOf(id), title: card.title, status: card.status, ...(card.assignee ? { assignee: card.assignee } : {}), body: serialized.body };
1154
1258
  }
1155
1259
 
1156
1260
  // ------------------------------------------------------------------ migration from a SQLite board
@@ -1176,10 +1280,15 @@ class ZtrackBoard {
1176
1280
  if (keys.length) this.statement(`INSERT OR REPLACE INTO main.${quote(table)} (${keys.map(quote).join(',')}) VALUES (${keys.map(() => '?').join(',')})`).run(...keys.map((k) => row[k] ?? null));
1177
1281
  };
1178
1282
  for (const table of this.conn.prepare("SELECT name FROM main.sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE 'board_%'").all()) this.conn.exec(`DELETE FROM main.${quote(table.name)}`);
1179
- for (const table of source.db.prepare("SELECT name, sql FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all()) {
1283
+ // a card's number is copied before its row, which keeps it; the numbers' high-water mark comes with them, so no
1284
+ // number the old board handed out is handed out again
1285
+ const sourceTables = source.db.prepare("SELECT name, sql FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all()
1286
+ .sort((a, b) => Number(b.name === CARD_NUMBERS) - Number(a.name === CARD_NUMBERS));
1287
+ for (const table of sourceTables) {
1180
1288
  if (!this.tables.has(table.name)) continue;
1181
1289
  for (const row of source.db.prepare(`SELECT * FROM ${quote(table.name)}`).all()) insert(table.name, row);
1182
1290
  }
1291
+ raiseCardNumbers(this.conn, Number(source.db.prepare('SELECT seq FROM sqlite_sequence WHERE name = ?').get(CARD_NUMBERS)?.seq ?? 0));
1183
1292
  for (const task of this.conn.prepare('SELECT * FROM main.tasks').all()) {
1184
1293
  const old = this.one('SELECT * FROM main.supercode_cards WHERE task_id = ?', task.id) ?? {};
1185
1294
  if (old.subtask_of) { const key = /^([a-z]+\d+)\s/.exec(task.title)?.[1]; if (key) this.statement('INSERT OR REPLACE INTO main.board_tasks (id, key) VALUES (?, ?)').run(task.id, `${old.subtask_of}:${key}`); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-orchestrator",
3
- "version": "0.5.206",
3
+ "version": "0.5.207",
4
4
  "type": "module",
5
5
  "description": "The orchestrator runtime over the Volter Harness ontology: one typed operational model whose folder is its serialization, read and written through the harness orchestration doors (docs/ORCHESTRATOR-IR.md)",
6
6
  "exports": {
@@ -32,10 +32,10 @@
32
32
  "check-types": "node scripts/generate-types.mjs --check"
33
33
  },
34
34
  "dependencies": {
35
- "@volter/supercode-teams": "^0.3.343",
36
- "@volter/supercode-terminal": "^0.3.9",
37
- "@volter/supercode-harness-sdk": "^0.3.103",
38
- "@volter/teams": "0.2.95",
35
+ "@volter/supercode-teams": "^0.3.344",
36
+ "@volter/supercode-terminal": "^0.3.10",
37
+ "@volter/supercode-harness-sdk": "^0.3.104",
38
+ "@volter/teams": "0.2.96",
39
39
  "@volter/ztrack": "^2.4.23",
40
40
  "yaml": "^2.9.0"
41
41
  },