@volter/supercode-orchestrator 0.5.226 → 0.5.227

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -97,19 +97,85 @@ export async function checkoutOf(machine, repository, branch) {
97
97
  return workspaceDoor(machine, 'resolve', repositoryId(repository), { branch });
98
98
  }
99
99
  // A card's solo worktree folder under the repository's `.worktrees`: the one it was made in, while its path is recorded
100
- // (a card running keeps its folder), else its handle (`vo-856`, store.mjs cardHandle; `task.handle`), as the board names
101
- // a card's worktree, else its id. Its branch is its own, else `wt/<id>`, as the folder named for its id had.
100
+ // (a card running keeps its folder); else, for the branch the board last named for it (`task.named_branch`, its latest
101
+ // `branch_named`: `voice-calls`, store.mjs cardBranch), that branch's last part, whatever the card is called now; else its
102
+ // handle (`vo-856`, store.mjs cardHandle; `task.handle`), for a branch named by its number; else its id. Its branch is its
103
+ // own, else `wt/<id>`, as the folder named for its id had.
102
104
  const worktreeFolder = (task) => {
103
105
  if (task.workspace_path && basename(dirname(task.workspace_path)) === '.worktrees') return basename(task.workspace_path);
104
106
  const branch = task.branch_name?.split('/').at(-1);
107
+ if (task.named_branch && task.branch_name === task.named_branch) return branch;
105
108
  return task.handle && (branch === task.handle || branch?.startsWith(`${task.handle}-`)) ? task.handle : task.id;
106
109
  };
107
110
  const worktreeBranch = (task) => task.branch_name ?? `wt/${task.id}`;
108
- export async function resolveWorkspace(task, create = false) {
111
+ /** The card's latest `branch_named` event (the branch the board named for it, and its repository), else null. */
112
+ const lastNamed = (db, id) => {
113
+ const row = db.prepare("SELECT id,json_extract(payload,'$.branch') AS branch,json_extract(payload,'$.repository') AS repository FROM task_events WHERE task_id=? AND kind='branch_named' ORDER BY id DESC LIMIT 1").get(id);
114
+ return row?.branch ? row : null;
115
+ };
116
+ /** The card's latest naming (lastNamed) while it names `branch` and no `worktree_made` follows it, else null. */
117
+ const unmadeNaming = (db, id, branch) => {
118
+ const named = lastNamed(db, id);
119
+ if (!branch || named?.branch !== branch) return null;
120
+ return db.prepare("SELECT 1 FROM task_events WHERE task_id=? AND kind='worktree_made' AND id>? LIMIT 1").get(id, named.id) ? null : named;
121
+ };
122
+ /** Card `task`'s claim on its branch (its native id) while the branch the board last named for it (`branch_named`) is
123
+ * its branch and no worktree of it has been made since (`worktree_made`, a later event, or a recorded worktree path:
124
+ * unrecordedMaking): its machine's resolve then takes no branch or folder of that name that already exists (teams
125
+ * workspaces.mjs). A retarget to another repository names the branch again, and claims it again there. Null otherwise. */
126
+ export function branchClaim(db, id, branch) {
127
+ if (!id || !unmadeNaming(db, id, branch)) return null;
128
+ return db.prepare('SELECT workspace_path FROM tasks WHERE id=?').get(id)?.workspace_path ? null : id;
129
+ }
130
+ /** The making of card `id`'s named branch that no `worktree_made` records: the worktree path the card has recorded while
131
+ * the branch the board last named for it is its branch and no making follows the naming. Every naming (createCard, a
132
+ * retarget) leaves the card no path, and a launch records one only once its machine made the worktree, so a recorded
133
+ * path is a making. A generation whose format this one's READABLE_BY names (storage-contract.mjs) records only the path
134
+ * when it makes a worktree the board named here. { branch, repository, path }, else null. */
135
+ export function unrecordedMaking(db, id) {
136
+ const task = db.prepare('SELECT branch_name,workspace_kind,workspace_path FROM tasks WHERE id=?').get(id);
137
+ if (task?.workspace_kind !== 'worktree' || !task.workspace_path) return null;
138
+ const named = unmadeNaming(db, id, task.branch_name);
139
+ return named ? { branch: named.branch, repository: named.repository ?? null, path: task.workspace_path } : null;
140
+ }
141
+ /** Card `task` as its workspace is resolved: its handle, the branch the board last named for it and, when `claims`, its
142
+ * claim on that branch (branchClaim). */
143
+ export const workspaceTask = (board, task, { claims = false } = {}) => ({ ...task, handle: task.handle ?? cardHandleOf(board, task.id),
144
+ named_branch: task.id ? lastNamed(board.db, task.id)?.branch ?? null : null,
145
+ ...(claims ? { branch_claim: task.workspace_kind === 'worktree' ? branchClaim(board.db, task.id, task.branch_name) : null } : {}) });
146
+ /** A `dir` workspace declared as a folder its machine answers is no git checkout at all (git's "not a git repository (or
147
+ * any of the parent directories)"; t_d424b9a3's volter folder on volter-desktop holds game-std and helldiver-lowpoly)
148
+ * is that folder: its own root, with no repository or branch (`folder`). Every resolution takes it from here: a run's
149
+ * claim (checkWorkspace) and its launch directory (dispatch resolveLocalWorkspace) are the folder, and its review reads
150
+ * the arc's recorded repositories (ci repositoryWorkspace). Any other failure still throws. */
151
+ const plainFolder = (declared, error) => {
152
+ if (!/not a git repository \(or any of the parent directories\)/.test(error?.message ?? '')) throw error;
153
+ const path = declared.replace(/[\\/]+$/, '');
154
+ return { path, root: path, linked: false, repository: null, branch: null, folder: true };
155
+ };
156
+ /** Its machine's git answered that the folder is not there (git's "cannot change to '<path>': No such file or directory"). */
157
+ const folderGone = (error) => /cannot change to .*: No such file or directory/.test(error?.message ?? '');
158
+ /** A card's worktree folder deleted from disk (removed through git, or by hand) is made again from the card's branch by
159
+ * its machine's resolve door (teams workspaces.mjs resolve: its stale registration forgotten, the branch's pushed head
160
+ * fetched), at the folder it had: a card whose recorded worktree is gone is re-prepared at launch, never guarded. A
161
+ * `dir` declaration naming such a folder (`<checkout>/.worktrees/<card>`, with the card's branch) is re-prepared the
162
+ * same way from the repository its checkout declares; any folder elsewhere, or a checkout that resolves to another
163
+ * folder, still throws the machine's own answer. */
164
+ async function remadeDirWorktree(task, declared, error, create) {
165
+ const path = declared.replace(/[\\/]+$/, '');
166
+ if (!folderGone(error) || !task.branch_name || basename(dirname(path)) !== '.worktrees') throw error;
167
+ const checkout = await workspaceDoor(task.machine, 'inspect', dirname(dirname(path))).catch(() => null);
168
+ if (!checkout?.repository || checkout.linked) throw error;
169
+ const made = await workspaceDoor(task.machine, 'resolve', checkout.repository, { branch: task.branch_name, solo: true, card: basename(path), create });
170
+ if (made.path !== path) throw error;
171
+ return { ...made, root: made.path };
172
+ }
173
+ export async function resolveWorkspace(task, create = false, { move = false } = {}) {
109
174
  const declared = task.workspace_anchor ?? task.workspace_path;
110
175
  if (isWorkspacePath(declared)) {
176
+ if (task.workspace_kind === 'dir') return workspaceDoor(task.machine, 'inspect', declared)
177
+ .catch((error) => folderGone(error) ? remadeDirWorktree(task, declared, error, create) : plainFolder(declared, error));
111
178
  const info = await workspaceDoor(task.machine, 'inspect', declared);
112
- if (task.workspace_kind === 'dir') return info;
113
179
  if (info.linked) throw new Error('worktree declaration is already a linked worktree; name its repository instead');
114
180
  throw new Error('legacy worktree path needs registration and a repository declaration before launch');
115
181
  }
@@ -127,29 +193,49 @@ export async function resolveWorkspace(task, create = false) {
127
193
  if (task.workspace_kind === 'worktree' && task.workspace_path &&
128
194
  basename(dirname(task.workspace_path)) === '.worktrees' &&
129
195
  (!isLocalWorkspace(task) || existsSync(task.workspace_path))) {
130
- const cached = await workspaceDoor(task.machine, 'inspect', task.workspace_path);
196
+ // Another machine's folder is read there, and one its git answers is gone is made again through that machine's
197
+ // resolve door at the same folder, as a missing folder on this machine is by the solo resolve after this branch.
198
+ const cached = await workspaceDoor(task.machine, 'inspect', task.workspace_path).catch((error) => { if (folderGone(error)) return null; throw error; });
199
+ if (!cached) return workspaceDoor(task.machine, 'resolve', declared, { branch: worktreeBranch(task), solo: true, card: worktreeFolder(task), create });
131
200
  if (cached.linked && basename(cached.path) === worktreeFolder(task) && basename(dirname(cached.path)) === '.worktrees' &&
132
- cached.repository === repositoryId(declared) && cached.branch === worktreeBranch(task)) {
133
- if (create) await workspaceDoor(task.machine, 'register', cached.root);
134
- return { ...cached, root: dirname(dirname(cached.path)) };
201
+ cached.repository === repositoryId(declared)) {
202
+ if (cached.branch === worktreeBranch(task)) {
203
+ if (create) await workspaceDoor(task.machine, 'register', cached.root);
204
+ return { ...cached, root: dirname(dirname(cached.path)) };
205
+ }
206
+ // Off its branch, it is put on it only for a start of this card that no other current run shares (`move`), by its
207
+ // machine's resolve door when that loses nothing, which otherwise answers why not. Every other read refuses as before.
208
+ if (move) {
209
+ const made = await workspaceDoor(task.machine, 'resolve', declared, { branch: worktreeBranch(task), solo: true, card: worktreeFolder(task), create, move });
210
+ if (made.path === cached.path) return { ...made, root: dirname(dirname(made.path)) };
211
+ }
135
212
  }
136
213
  throw new Error(`card worktree ${task.workspace_path} does not match ${declared}, branch ${worktreeBranch(task)}; retarget its workspace explicitly`);
137
214
  }
138
215
  const solo = task.workspace_kind === 'worktree';
139
- return workspaceDoor(task.machine, 'resolve', declared, { branch: solo ? worktreeBranch(task) : task.branch_name, solo, card: solo ? worktreeFolder(task) : task.id, create });
216
+ return workspaceDoor(task.machine, 'resolve', declared, { branch: solo ? worktreeBranch(task) : task.branch_name, solo, card: solo ? worktreeFolder(task) : task.id, create, ...(solo && task.branch_claim ? { claim: task.branch_claim } : {}) });
217
+ }
218
+ /** Whether another card's current run holds `task`'s recorded worktree folder on the same machine. */
219
+ function heldByAnotherRun(db, task) {
220
+ if (task.workspace_kind !== 'worktree' || !task.workspace_path) return false;
221
+ const local = isLocalWorkspace(task), mine = workspacePath(task.workspace_path, local);
222
+ return db.prepare(`SELECT t.id,t.workspace_path,c.machine FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.current_run_id IS NOT NULL AND t.workspace_kind='worktree' AND t.id!=?`).all(task.id)
223
+ .some(row => normalMachine(row.machine ?? localMachine()) === normalMachine(task.machine ?? localMachine()) && workspacePath(row.workspace_path, local) === mine);
140
224
  }
141
225
  export async function checkWorkspace(board, task) {
142
226
  if (task.workspace_kind === 'scratch') return null;
143
- task = { ...task, handle: task.handle ?? cardHandleOf(board, task.id) };
144
- const facts = await resolveWorkspace(task), peers = [];
227
+ task = workspaceTask(board, task, { claims: true });
228
+ // Every caller starts this card (a claim, its launch attempt, a person's start), so its own worktree may be put on its
229
+ // branch, unless another card's current run holds the same folder on this machine; a peer is only ever read.
230
+ const facts = await resolveWorkspace(task, false, { move: !heldByAnotherRun(board.db, task) }), peers = [];
145
231
  const workspaceRoot = task.workspace_kind === 'worktree' ? facts.path : facts.root;
146
232
  // New claims retain their exact checkout receipt before launch. Legacy claims without a receipt are resolved
147
233
  // once for this check, including subdirectories and remote aliases that their retained path alone cannot identify.
148
234
  {
149
- for (const row of board.db.prepare(`SELECT t.id,t.workspace_kind,t.workspace_path,t.branch_name,c.workspace_anchor,c.machine FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.current_run_id IS NOT NULL AND t.workspace_kind!='scratch' AND c.workspace_root IS NULL`).all()) {
235
+ for (const row of board.db.prepare(`SELECT t.id,t.readable_id,t.workspace_kind,t.workspace_path,t.branch_name,c.workspace_anchor,c.machine FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.current_run_id IS NOT NULL AND t.workspace_kind!='scratch' AND c.workspace_root IS NULL`).all()) {
150
236
  if (row.id === task.id || normalMachine(row.machine ?? localMachine()) !== normalMachine(task.machine ?? localMachine())) continue;
151
237
  let other;
152
- try { other = await resolveWorkspace({ ...row, handle: cardHandleOf(board, row.id) }); } catch { continue; } // Its retained path/reservation is still checked below.
238
+ try { other = await resolveWorkspace(workspaceTask(board, row)); } catch { continue; } // Its retained path/reservation is still checked below.
153
239
  if ((row.workspace_kind === 'worktree' ? other.path : other.root) === workspaceRoot) peers.push(row.id);
154
240
  }
155
241
  }
package/board/ztrack.mjs CHANGED
@@ -19,7 +19,7 @@ import { createHash, randomBytes } from 'node:crypto';
19
19
  import { existsSync, linkSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
20
20
  import { basename, dirname, join, resolve } from 'node:path';
21
21
  import { DatabaseSync } from 'node:sqlite';
22
- import { actorOf, boardKey, CARD_NUMBERS, contention, raiseCardNumbers, event, json, now, principalOf, storageFault, STORAGE_TRIES, unreadable } from './store.mjs';
22
+ import { actorOf, boardKey, CARD_NUMBERS, contention, raiseCardNumbers, unnumberTasks, event, json, now, principalOf, storageFault, STORAGE_TRIES, unreadable } from './store.mjs';
23
23
  import { GitDocument } from './git-document.mjs';
24
24
  import { unimported } from './legacy-store.mjs';
25
25
  import { noteStep, recordSlow } from '@volter/supercode-harness-sdk/slow-log';
@@ -108,6 +108,11 @@ const isArchive = (role) => role === 'archived' || String(role ?? '').startsWith
108
108
  // The open document's record of the numbers the board has given (numberMark: its key, its number), and a task line's
109
109
  // number (`- [ ] VO-2836 c3`); either written keyed, or with `#` as before a board read its numbers with a key.
110
110
  const NUMBER_MARK = /^The board has given card numbers up to (?:#|([A-Z][A-Z0-9]+)-)(\d+)\.[ \t]*$/m;
111
+ // The open document's record of every card's number (VO-3578: a store made again from its documents keeps every card's
112
+ // number). A heading carries no number (a 2.x codec reads `## VO-154 t_… — title` as a card whose id is VO-154), so the
113
+ // numbers live in the preamble, which every reader of the documents, of any release, reads as text: `<id> <KEY-N>, …`.
114
+ const NUMBERS_KEPT = /^The board keeps its card numbers: (.*)\.[ \t]*$/m;
115
+ const NUMBER_KEPT = /([^\s,]+) (?:#|[A-Z][A-Z0-9]+-)(\d+)/g;
111
116
  const TASK_NUMBER = /^\s*[-*] \[[ xX]\]\s+(?:#|[A-Z][A-Z0-9]+-)(\d+)\s+[a-z]+\d+\s/gm;
112
117
 
113
118
  const BOARD_TABLES = `
@@ -138,7 +143,7 @@ export function openZtrackBoard(marker, { create = false, document = null, decla
138
143
  }
139
144
  const config = JSON.parse(read(marker));
140
145
  const backing = new ZtrackBoard({ ...config, seed, marker, init, root, slug, held, readOnly });
141
- return { db: backing.db, snapshot: (path) => backing.snapshot(path), tx: (fn) => backing.tx(fn), publishPending: () => backing.git?.publishPending(), unimported: () => backing.unimported(), close: () => backing.close(), document: backing.path,
146
+ return { db: backing.db, snapshot: (path) => backing.snapshot(path), vacuum: (sql) => backing.vacuum(sql), tx: (fn) => backing.tx(fn), publishPending: () => backing.git?.publishPending(), unimported: () => backing.unimported(), close: () => backing.close(), document: backing.path,
142
147
  documentsChanged: () => backing.documentsChanged(), documentsUnwatched: (unwatched) => { backing.unwatched = unwatched; }, saidOnOpen: () => backing.sayLate() };
143
148
  }
144
149
 
@@ -286,6 +291,13 @@ class ZtrackBoard {
286
291
  this.conn.prepare('VACUUM INTO ?').run(path);
287
292
  }
288
293
 
294
+ // Run SQL on the store's own connection outside its write wrapper (a rebuild: SQLite forbids VACUUM inside a tx).
295
+ vacuum(sql) {
296
+ if (this.active || this.readOnly) throw new Error('rebuilding the board needs a writable backing outside a transaction');
297
+ this.tx(() => {});
298
+ this.conn.exec(sql);
299
+ }
300
+
289
301
  statement(sql) {
290
302
  let statement = this.statements.get(sql);
291
303
  if (!statement) {
@@ -462,13 +474,14 @@ class ZtrackBoard {
462
474
  put(`chunks:${role}`, entries);
463
475
  }
464
476
  put('published', published);
465
- // The earlier store held no numbers: its cards take the numbers their documents' headings give them, and the rest
466
- // are numbered in the order they were made, after every number the documents name (documentNumbers).
477
+ // The earlier store held no numbers: its cards take the numbers their documents' headings give them, and the other
478
+ // cards are numbered in the order they were made, after every number the documents name (documentNumbers); a task is not.
467
479
  db.exec(`DELETE FROM ${CARD_NUMBERS}; DELETE FROM sqlite_sequence WHERE name = '${CARD_NUMBERS}'`);
468
480
  const named = this.documentNumbers();
469
481
  this.numberFromHeadings([...named.headings].filter(([id]) => db.prepare('SELECT 1 FROM tasks WHERE id = ?').get(id)), db);
470
482
  raiseCardNumbers(db, named.highest);
471
- 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`);
483
+ db.exec(`INSERT INTO ${CARD_NUMBERS} (task_id) SELECT id FROM tasks WHERE id NOT IN (SELECT task_id FROM ${CARD_NUMBERS})
484
+ AND id NOT IN (SELECT task_id FROM supercode_cards WHERE subtask_of IS NOT NULL) ORDER BY created_at, id`);
472
485
  db.exec('COMMIT');
473
486
  }
474
487
 
@@ -919,8 +932,14 @@ class ZtrackBoard {
919
932
  if (initial) {
920
933
  if (rebuilding) this.conn.exec(`DELETE FROM ${CARD_NUMBERS}`);
921
934
  this.numberFromHeadings([...plan.numbers].filter(([id]) => plan.rows.has(id)));
922
- raiseCardNumbers(this.conn, this.documentNumbers().highest);
935
+ const named = this.documentNumbers();
936
+ this.numberFromHeadings([...named.kept].filter(([id]) => plan.rows.has(id) || this.one('SELECT 1 FROM main.tasks WHERE id = ?', id)));
937
+ raiseCardNumbers(this.conn, named.highest);
923
938
  }
939
+ // What is numbered before the cards load: a task the document or the store had numbered keeps it, and a task row the
940
+ // load inserts gives back the number the insert trigger hands it (only cards are numbered).
941
+ const numbered = overlay ? null : new Set(this.statement(`SELECT task_id FROM main.${CARD_NUMBERS}`).all().map((row) => row.task_id));
942
+ const handedOut = overlay ? 0 : this.one(`SELECT seq FROM main.sqlite_sequence WHERE name = '${CARD_NUMBERS}'`)?.seq ?? 0;
924
943
  for (const [id, rows] of plan.rows) {
925
944
  const before = fingerprintOf(id);
926
945
  const oldTask = this.one(`SELECT * FROM ${schema}.tasks WHERE id = ?`, id) ?? {};
@@ -939,6 +958,7 @@ class ZtrackBoard {
939
958
  events.push({ id, kind, payload: { by, source: 'document', status: after.status, assignee: after.assignee, author: by }, before, after });
940
959
  }
941
960
  }
961
+ if (!overlay) unnumberTasks(this.conn, numbered, handedOut);
942
962
  for (const id of plan.checked) this.errors.delete(id);
943
963
  for (const [id, reason] of plan.errors) this.errors.set(id, reason);
944
964
  // A read overlays new document cards before the owner commits them. Give that
@@ -1043,7 +1063,7 @@ class ZtrackBoard {
1043
1063
  return parents;
1044
1064
  };
1045
1065
  let parents = cardsTouched();
1046
- const mark = this.numbersRendered ? this.numberMark() : null;
1066
+ const mark = this.numberMark();
1047
1067
  if (!parents.size && !mark) return [];
1048
1068
  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);
1049
1069
  // A card held by a recorded refusal is not rendered, so the store's verbs move its tasks past its lines. When
@@ -1278,7 +1298,7 @@ class ZtrackBoard {
1278
1298
  // `headings`, card id → the number its heading gives, and `highest`, the highest number any of them names (a
1279
1299
  // heading, a task line, or the open document's record of the numbers given, numberMark).
1280
1300
  documentNumbers() {
1281
- const headings = new Map();
1301
+ const headings = new Map(), kept = new Map();
1282
1302
  let highest = 0;
1283
1303
  const dir = dirname(this.path), named = basename(this.path);
1284
1304
  let rotated = [];
@@ -1286,7 +1306,10 @@ class ZtrackBoard {
1286
1306
  for (const path of [this.path, this.archive, ...rotated]) {
1287
1307
  const text = lf(read(path));
1288
1308
  for (const line of text.matchAll(TASK_NUMBER)) highest = Math.max(highest, Number(line[1]));
1289
- if (path === this.path) highest = Math.max(highest, Number(NUMBER_MARK.exec(text)?.[2] ?? 0));
1309
+ if (path === this.path) {
1310
+ highest = Math.max(highest, Number(NUMBER_MARK.exec(text)?.[2] ?? 0));
1311
+ for (const [, id, number] of (NUMBERS_KEPT.exec(text)?.[1] ?? '').matchAll(NUMBER_KEPT)) { kept.set(nativeId(id), Number(number)); highest = Math.max(highest, Number(number)); }
1312
+ }
1290
1313
  let records = [];
1291
1314
  try { ({ records } = this.codec.inspectBoardDocument(text, path)); } catch { continue; }
1292
1315
  for (const record of records) {
@@ -1295,26 +1318,31 @@ class ZtrackBoard {
1295
1318
  if (!headings.has(nativeId(record.id))) headings.set(nativeId(record.id), record.number);
1296
1319
  }
1297
1320
  }
1298
- return { headings, highest };
1321
+ for (const [id, number] of kept) if (!headings.has(id)) headings.set(id, number);
1322
+ return { headings, kept, highest };
1299
1323
  }
1300
1324
 
1301
1325
  // The open document records the highest number the store has given (a line of its preamble, NUMBER_MARK), so a store
1302
1326
  // made again from the documents hands out none at or below it, whatever card held it: a subtask filed and gone, or
1303
- // a card the documents no longer show. The edit that brings the line up to the store's count, or null.
1327
+ // a card the documents no longer show; and every card's number (NUMBERS_KEPT), which that store gives back. The edit
1328
+ // that brings the two lines up to the store, or null. Only the preamble is written: no card is written again for it.
1304
1329
  numberMark() {
1305
1330
  const given = Number(this.one('SELECT seq FROM main.sqlite_sequence WHERE name = ?', CARD_NUMBERS)?.seq ?? 0);
1306
1331
  if (!given) return null;
1307
1332
  const keyed = this.numberForm === 'keyed';
1308
1333
  const line = (top) => `The board has given card numbers up to ${keyed ? `${this.cardKey}-` : '#'}${top}.`;
1334
+ const cards = this.statement(`SELECT n.task_id, n.number FROM main.${CARD_NUMBERS} n JOIN main.supercode_cards c ON c.task_id = n.task_id WHERE c.subtask_of IS NULL ORDER BY n.number`).all();
1335
+ const kept = `The board keeps its card numbers: ${cards.map((row) => `${row.task_id} ${this.cardKey}-${row.number}`).join(', ')}.`;
1309
1336
  const first = this.entries('open')[0];
1310
1337
  if (first && first.id === null) {
1311
1338
  const text = this.texts([first])[0].text;
1312
1339
  const said = NUMBER_MARK.exec(text);
1313
- if (said && (keyed ? said[1] === this.cardKey : said[1] === undefined) && Number(said[2]) >= given) return null;
1314
- const next = said ? text.replace(NUMBER_MARK, line(Math.max(given, Number(said[2])))) : `${text.replace(/\n*$/, '')}\n\n${line(given)}\n\n`;
1340
+ let next = said ? text.replace(NUMBER_MARK, line(Math.max(given, Number(said[2])))) : `${text.replace(/\n*$/, '')}\n\n${line(given)}\n\n`;
1341
+ next = NUMBERS_KEPT.test(next) ? next.replace(NUMBERS_KEPT, () => kept) : next.replace(NUMBER_MARK, (mark) => `${mark}\n${kept}`);
1342
+ if (next === text) return null;
1315
1343
  return { index: 0, entry: { cid: null, id: null, level: 0, len: next.length, rec: null, text: next } };
1316
1344
  }
1317
- const text = `${line(given)}\n\n`;
1345
+ const text = `${line(given)}\n${kept}\n\n`;
1318
1346
  return { prepend: true, entry: { cid: null, id: null, level: 0, len: text.length, rec: null, text } };
1319
1347
  }
1320
1348