@volter/supercode-orchestrator 0.5.205 → 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.
Files changed (52) hide show
  1. package/README.md +138 -4
  2. package/activation.mjs +93 -2
  3. package/adapters/rh2.mjs +7 -2
  4. package/agents.mjs +24 -2
  5. package/bin/agent-channel.mjs +41 -22
  6. package/bin/connection-runner.mjs +141 -0
  7. package/bin/orchestrator.mjs +11 -2
  8. package/bin/runtime.mjs +16 -9
  9. package/board/args.mjs +3 -2
  10. package/board/attachment-read.mjs +77 -0
  11. package/board/call.mjs +4 -2
  12. package/board/capacity.mjs +46 -9
  13. package/board/ci.mjs +157 -53
  14. package/board/cli.mjs +278 -97
  15. package/board/client.mjs +18 -2
  16. package/board/custody.mjs +109 -0
  17. package/board/dispatch.mjs +305 -80
  18. package/board/doors.mjs +47 -17
  19. package/board/engine.mjs +92 -27
  20. package/board/files.mjs +17 -3
  21. package/board/fleet-listeners.mjs +268 -0
  22. package/board/fleet-observer.mjs +79 -0
  23. package/board/fleet-targets.mjs +82 -0
  24. package/board/incidents.mjs +7 -0
  25. package/board/listing.mjs +8 -2
  26. package/board/machine-fleet.mjs +7 -0
  27. package/board/notice-receipts.mjs +67 -0
  28. package/board/notices.mjs +5 -0
  29. package/board/owner-door-worker.mjs +61 -0
  30. package/board/owner-door.mjs +46 -0
  31. package/board/owner-health.mjs +8 -2
  32. package/board/published.mjs +2 -2
  33. package/board/record-view.mjs +2 -0
  34. package/board/store.mjs +84 -3
  35. package/board/tools.mjs +11 -4
  36. package/board/workflow.mjs +96 -31
  37. package/board/ztrack.mjs +207 -32
  38. package/board-input.d.ts +2 -0
  39. package/board-input.mjs +16 -0
  40. package/connection.mjs +153 -0
  41. package/index.d.ts +7 -5
  42. package/index.mjs +1 -1
  43. package/installed-apps.mjs +31 -20
  44. package/loop.mjs +91 -36
  45. package/mcp/bridge.mjs +12 -0
  46. package/mcp/hermes-kanban-tools.json +22 -14
  47. package/mcp/mount.mjs +10 -9
  48. package/ontology.d.ts +37 -0
  49. package/operator.mjs +72 -4
  50. package/orchestration.mjs +7 -2
  51. package/package.json +9 -5
  52. package/reducer.mjs +74 -5
package/board/ztrack.mjs CHANGED
@@ -7,14 +7,18 @@
7
7
  // triggers name the rows it touched. Only the cards whose text changed are rendered, and each is
8
8
  // spliced into the document at its indexed span. A document edited by hand is re-read only in the
9
9
  // region the edit touched. So a write costs what it changes, not the size of the board.
10
+ //
11
+ // A listing answered on the owner's read door (owner-door.mjs) opens the board read-only on a connection of its own and
12
+ // reads it in one SQLite read transaction: WAL gives it the rows as one committed state, and no writer waits on it or
13
+ // it on a writer. The documents are taken with that state (readSnapshot), never locked.
10
14
  import { current } from './call.mjs';
11
15
  import { fenced } from './fence.mjs';
12
16
  import { createRequire } from 'node:module';
13
17
  import { createHash, randomBytes } from 'node:crypto';
14
- 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';
15
19
  import { basename, dirname, join, resolve } from 'node:path';
16
20
  import { DatabaseSync } from 'node:sqlite';
17
- import { actorOf, contention, event, json, now, principalOf, storageFault, STORAGE_TRIES } from './store.mjs';
21
+ import { actorOf, CARD_NUMBERS, contention, raiseCardNumbers, event, json, now, principalOf, storageFault, STORAGE_TRIES, unreadable } from './store.mjs';
18
22
  import { GitDocument } from './git-document.mjs';
19
23
  import { unimported } from './legacy-store.mjs';
20
24
  import { noteStep, recordSlow } from '@volter/supercode-harness-sdk/slow-log';
@@ -68,11 +72,13 @@ const timedStatement = (sql, fn) => {
68
72
  finally { noteStatement(sql, started, before); }
69
73
  };
70
74
  import { fire, workflowFor } from './workflow.mjs';
71
- import { LIST_REVISION, listRevision, TASK_SUMMARY_INDEX, TASK_SUMMARY_INDEX_SQL } from './listing.mjs';
75
+ import { byOwner, LIST_REVISION, listRevision, TASK_SUMMARY_INDEX, TASK_SUMMARY_INDEX_SQL } from './listing.mjs';
72
76
 
73
77
  const require = createRequire(import.meta.url);
74
78
  const hash = (text) => createHash('sha256').update(text).digest('hex');
75
79
  const read = (path) => existsSync(path) ? readFileSync(path, 'utf8') : '';
80
+ // What says a document changed: a write in place moves its size or times, a replacement its inode.
81
+ const stampOf = (path) => { try { const s = statSync(path); return `${s.ino}:${s.size}:${s.mtimeMs}:${s.ctimeMs}`; } catch { return 'missing'; } };
76
82
  const lf = (text) => text.replace(/\r\n?/g, '\n');
77
83
  const quote = (s) => `"${String(s).replaceAll('"', '""')}"`;
78
84
  const nativeId = (id) => /^t-[a-f0-9]+$/.test(id) ? id.replace('t-', 't_') : id;
@@ -94,7 +100,13 @@ const sqliteContention = (error) => contention(error); // resolved at call time:
94
100
  const RETRY = new Error('board changed while this operation read it; retry');
95
101
  // The current archive's size before it rotates, unless the board declares its own (`archive.max_lines`).
96
102
  const ARCHIVE_LINES = 2000;
103
+ // How many read transactions a listing begins before it leaves the board to its owner, each begun again only because a
104
+ // document changed while it was read (readSnapshot).
105
+ const SNAPSHOT_TRIES = 8;
97
106
  const isArchive = (role) => role === 'archived' || String(role ?? '').startsWith('archived:');
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;
98
110
 
99
111
  const BOARD_TABLES = `
100
112
  CREATE TABLE IF NOT EXISTS board_state (key TEXT PRIMARY KEY, value TEXT NOT NULL);
@@ -109,7 +121,7 @@ DROP TABLE IF EXISTS board_export;`;
109
121
  // written to the document). board_tasks: a subtask's key on its arc's Tasks list
110
122
  // (`<arc>:c3`) and its `source:` lines, which live only in the document.
111
123
 
112
- export function openZtrackBoard(marker, { create = false, document = null, declared = null, seed = null, init, root, slug, held = false }) {
124
+ export function openZtrackBoard(marker, { create = false, document = null, declared = null, seed = null, init, root, slug, held = false, readOnly = false }) {
113
125
  if (!existsSync(marker)) {
114
126
  if (!create) throw new Error(`no ztrack board ${marker}`);
115
127
  mkdirSync(dirname(marker), { recursive: true });
@@ -123,18 +135,20 @@ export function openZtrackBoard(marker, { create = false, document = null, decla
123
135
  atomic(marker, JSON.stringify({ format: 1, document: join(realpathSync(dirname(named)), basename(named)), ...said }) + '\n');
124
136
  }
125
137
  const config = JSON.parse(read(marker));
126
- const backing = new ZtrackBoard({ ...config, seed, marker, init, root, slug, held });
138
+ const backing = new ZtrackBoard({ ...config, seed, marker, init, root, slug, held, readOnly });
127
139
  return { db: backing.db, tx: (fn) => backing.tx(fn), publishPending: () => backing.git?.publishPending(), unimported: () => backing.unimported(), close: () => backing.close(), document: backing.path,
128
140
  documentsChanged: () => backing.documentsChanged(), documentsUnwatched: (unwatched) => { backing.unwatched = unwatched; }, saidOnOpen: () => backing.sayLate() };
129
141
  }
130
142
 
131
143
 
132
144
  class ZtrackBoard {
133
- constructor({ document, seed, marker, init, root, slug, commit = false, manager = null, archive = {}, held = false }) {
145
+ constructor({ document, seed, marker, init, root, slug, commit = false, manager = null, archive = {}, held = false, readOnly = false }) {
134
146
  // held by the board owner (store.mjs holdBoards): opened once, its documents' stamps read again only when the
135
147
  // owner's watch says they changed or may have lost events (documentsChanged), and on every read while it keeps no
136
148
  // watch on them (unwatched); git's lateness held and refreshed on its own commits
137
149
  this.held = held; this.docsChanged = true; this.importDue = true; this.knownStamps = null;
150
+ // a listing's read-only view (readSnapshot): it makes, migrates, recovers, pulls and commits nothing
151
+ this.readOnly = readOnly;
138
152
  this.path = resolve(dirname(marker), document);
139
153
  this.archive = `${this.path}.archive.md`;
140
154
  this.baseDocs = [{ role: 'open', path: this.path }, { role: 'archived', path: this.archive }];
@@ -147,7 +161,7 @@ class ZtrackBoard {
147
161
  // `"manager"`: the board's manager (its principal or session address), whose writes it commits itself as part of its
148
162
  // own work; the backing commits only the writes the manager does not make.
149
163
  this.manager = manager;
150
- this.git = commit ? new GitDocument(this.baseDocs.map((doc) => doc.path), (base, theirs, ours, path) => { try { return this.codec.mergeBoardDocument(base, theirs, ours, path)?.text ?? null; } catch { return null; } }) : null;
164
+ this.git = commit && !readOnly ? new GitDocument(this.baseDocs.map((doc) => doc.path), (base, theirs, ours, path) => { try { return this.codec.mergeBoardDocument(base, theirs, ours, path)?.text ?? null; } catch { return null; } }) : null;
151
165
  // what a fetch brought is written in the board's next transaction: one soon, on this loop, if none comes
152
166
  if (this.git) this.git.onTaken = () => setImmediate(() => { try { if (!this.closed) this.tx(() => {}); } catch (error) { current().err(`board: ${error.message}\n`); } });
153
167
  // A publish that is behind is said once per call, on whichever workflow verb opened the board: a refused push
@@ -158,18 +172,25 @@ class ZtrackBoard {
158
172
  else if (this.git) this.sayLate(timedStep('board open git behind', () => this.git.behind()));
159
173
  this.seed = seed; this.root = root; this.slug = slug; this.init = init;
160
174
  this.file = `${marker}.board.db`;
161
- mkdirSync(dirname(this.path), { recursive: true });
175
+ // the store is the owner's to make, import and lay out: a listing that finds none leaves the board to it
176
+ if (readOnly && !(existsSync(this.file) && statSync(this.file).size > 0)) throw byOwner(`${this.file} is not laid out yet`);
177
+ if (!readOnly) mkdirSync(dirname(this.path), { recursive: true });
162
178
  const legacy = `${marker}.runtime.db`;
163
179
  // A 0-byte file is no store: a read through the sqlite3 CLI creates one at a path that has none (on 2026-10-01 a
164
180
  // `sqlite3 <store> "select …"` made the empty file the next release then opened instead of importing).
165
- if (existsSync(this.file) && statSync(this.file).size === 0) unlinkSync(this.file);
181
+ if (!readOnly && existsSync(this.file) && statSync(this.file).size === 0) unlinkSync(this.file);
166
182
  if (!existsSync(this.file)) this.create(legacy);
167
- this.conn = timedStep('board open connection', () => new DatabaseSync(this.file));
168
- this.refuseUnimported(legacy);
183
+ this.conn = timedStep('board open connection', () => {
184
+ if (!readOnly) return new DatabaseSync(this.file);
185
+ try { return new DatabaseSync(this.file, { readOnly: true }); } catch (error) { throw unreadable(error) ? byOwner(error.message) : error; }
186
+ });
187
+ if (!readOnly) this.refuseUnimported(legacy);
169
188
  this.errors = new Map(); this.statements = new Map(); this.hashes = new Map();
170
189
  this.active = false; this.overlay = false; this.seen = null; this.refused = new Set();
171
- timedStep('board open schema', () => this.prepareConnection());
172
- timedStep('board open errors', () => this.loadErrors());
190
+ if (!readOnly) {
191
+ timedStep('board open schema', () => this.prepareConnection());
192
+ timedStep('board open errors', () => this.loadErrors());
193
+ }
173
194
  const self = this;
174
195
  this.db = {
175
196
  [LIST_REVISION]: () => self.read(() => listRevision(self.conn)),
@@ -185,10 +206,49 @@ class ZtrackBoard {
185
206
  exec(sql) { const run = () => timedStatement(sql, () => self.conn.exec(sql)); return self.active ? self.noting(run) : self.tx(run); },
186
207
  close() { self.close(); },
187
208
  };
209
+ if (readOnly) {
210
+ try { timedStep('board read: snapshot', () => this.readSnapshot()); }
211
+ catch (error) { this.conn.close(); throw error; }
212
+ return;
213
+ }
188
214
  // A board's first open in this process recovers what its store accepted and derives what git owes for it (tx), so
189
215
  // a board that is only read, or whose owner runs no round, still finishes an earlier process's commit.
190
216
  if (seed || this.git?.underived) this.tx(() => {});
191
217
  }
218
+
219
+ // A listing's view, taken once at its open and kept until its close: the store's rows as of one read transaction, and
220
+ // every document as it stood when that transaction began. A document is written only after the transaction that
221
+ // published it committed, so a document that stood unchanged from before the transaction began until after it was
222
+ // read is one the transaction already describes: its published text, the text a pending publication replaces (the
223
+ // rows stay authoritative), or a hand edit, which is shown through connection-local copies of the card tables as
224
+ // every reader shows one. A document that changed in between is read again with a new transaction. Nothing here waits:
225
+ // WAL serves the read transaction beside any writer, and no busy timeout is set. A store not in WAL or not laid out,
226
+ // one a read-only connection cannot begin reading (store.mjs unreadable) or documents that keep changing leave the
227
+ // listing to the owner.
228
+ readSnapshot() {
229
+ const contended = (fn) => { try { return fn(); } catch (error) { throw unreadable(error) ? byOwner(error.message) : error; } };
230
+ if (String(contended(() => this.conn.prepare('PRAGMA journal_mode').get())?.journal_mode).toLowerCase() !== 'wal') throw byOwner(`${this.file} is not in WAL mode`);
231
+ for (let attempt = 0; attempt < SNAPSHOT_TRIES; attempt++) {
232
+ const stamps = () => this.docs.map((doc) => `${doc.path}\0${stampOf(doc.path)}`).join('\n');
233
+ const before = contended(stamps);
234
+ this.conn.exec('BEGIN');
235
+ let stale;
236
+ try {
237
+ // the transaction's first read begins its snapshot
238
+ if (!contended(() => this.one("SELECT 1 FROM main.sqlite_master WHERE type = 'index' AND name = ?", TASK_SUMMARY_INDEX))) throw byOwner(`${this.file} is not laid out yet`);
239
+ this.loadErrors();
240
+ stale = this.staleDocuments({ pending: true });
241
+ if (stamps() !== before) { this.conn.exec('ROLLBACK'); this.hashes.clear(); continue; }
242
+ } catch (error) { try { this.conn.exec('ROLLBACK'); } catch { /* not begun */ } throw error; }
243
+ if (!stale.length) return;
244
+ this.tables = this.shapes();
245
+ let plan;
246
+ try { plan = this.observe(stale); } catch { return; } // the next write reports it
247
+ this.applyPlan(plan, { overlay: true });
248
+ return;
249
+ }
250
+ throw byOwner('its documents changed during every read');
251
+ }
192
252
  /** The publish's lateness, said once per call (a held board's from its held reading, never by asking git). */
193
253
  sayLate(late = this.git?.lateness ?? null) {
194
254
  const said = current().said;
@@ -246,14 +306,9 @@ class ZtrackBoard {
246
306
  // What a transaction touched, for the renderer (cards whose text may change: anything beyond run fields and scheduling clocks). Plain inserts: a trigger's
247
307
  // own conflict clause yields to the outer statement's, so duplicates are removed when read.
248
308
  this.conn.exec('CREATE TEMP TABLE IF NOT EXISTS board_dirty (name TEXT NOT NULL, key TEXT NOT NULL); CREATE TEMP TABLE IF NOT EXISTS board_touched (id TEXT)');
249
- this.tables = new Map();
250
- const names = this.conn.prepare("SELECT name FROM main.sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE 'board_%'").all().map((row) => row.name);
309
+ this.tables = this.shapes();
251
310
  const trigger = (name, when, body) => this.conn.exec(`CREATE TEMP TRIGGER IF NOT EXISTS ${quote(name)} ${when} BEGIN ${body} END`);
252
- for (const name of names) {
253
- const columns = this.conn.prepare(`PRAGMA main.table_info(${quote(name)})`).all();
254
- const keys = columns.filter((c) => c.pk).sort((a, b) => a.pk - b.pk).map((c) => c.name);
255
- if (!keys.length) keys.push('rowid');
256
- this.tables.set(name, { name, keys, columns: columns.map((c) => c.name) });
311
+ for (const { name, keys } of this.tables.values()) {
257
312
  const key = (row) => `json_array(${keys.map((k) => `${row}.${quote(k)}`).join(',')})`;
258
313
  const mark = (row) => `INSERT INTO board_dirty VALUES ('${name}', ${key(row)});`;
259
314
  trigger(`board_i_${name}`, `AFTER INSERT ON main.${quote(name)}`, mark('NEW'));
@@ -268,6 +323,18 @@ class ZtrackBoard {
268
323
  }
269
324
  }
270
325
 
326
+ // Each runtime table's columns and key, as the store has them.
327
+ shapes() {
328
+ const tables = new Map();
329
+ for (const { name } of this.conn.prepare("SELECT name FROM main.sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE 'board_%'").all()) {
330
+ const columns = this.conn.prepare(`PRAGMA main.table_info(${quote(name)})`).all();
331
+ const keys = columns.filter((c) => c.pk).sort((a, b) => a.pk - b.pk).map((c) => c.name);
332
+ if (!keys.length) keys.push('rowid');
333
+ tables.set(name, { name, keys, columns: columns.map((c) => c.name) });
334
+ }
335
+ return tables;
336
+ }
337
+
271
338
  // A store holding fewer runs than the earlier store beside it was made without the one-time import (on 2026-10-01
272
339
  // one such store, created by an opener nobody named, sent the dispatcher on to start a second session on every
273
340
  // running card). The board refuses it, so nothing runs on a board without its history.
@@ -376,6 +443,13 @@ class ZtrackBoard {
376
443
  put(`chunks:${role}`, entries);
377
444
  }
378
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`);
379
453
  db.exec('COMMIT');
380
454
  }
381
455
 
@@ -388,6 +462,8 @@ class ZtrackBoard {
388
462
  }
389
463
 
390
464
  read(fn) {
465
+ // a listing's view is its snapshot's (readSnapshot): its statements read that, never the documents again
466
+ if (this.readOnly) return fn();
391
467
  if (this.active) return this.noting(fn);
392
468
  this.observeForRead();
393
469
  for (let attempt = 0; ; attempt++) {
@@ -397,8 +473,7 @@ class ZtrackBoard {
397
473
  }
398
474
 
399
475
  fileHash(path) {
400
- let stamp;
401
- try { const s = statSync(path); stamp = `${s.ino}:${s.size}:${s.mtimeMs}:${s.ctimeMs}`; } catch { stamp = 'missing'; }
476
+ const stamp = stampOf(path);
402
477
  const known = this.hashes.get(path);
403
478
  if (known?.stamp === stamp) return known;
404
479
  const raw = read(path);
@@ -462,6 +537,7 @@ class ZtrackBoard {
462
537
  // ------------------------------------------------------------------ writes
463
538
 
464
539
  tx(fn) {
540
+ if (this.readOnly) throw new Error('a listing reads the board and writes nothing');
465
541
  // A write inside a write is part of it: the pull below happened once, for the outermost one.
466
542
  if (this.active) return fn(this.db);
467
543
  // A pull request merged on the remote (a page edited in Git Notes) is in the document before this write reads it.
@@ -490,6 +566,13 @@ class ZtrackBoard {
490
566
  timedStep('board tx snapshot', () => this.snapshotHeld());
491
567
  const result = timedStep('board tx body', () => fn(this.db));
492
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
+ }
493
576
  const files = timedStep('board tx render', () => this.render());
494
577
  const intent = files.length ? randomBytes(16).toString('hex') : null;
495
578
  if (intent) {
@@ -592,7 +675,7 @@ class ZtrackBoard {
592
675
  // connection-local copies by a read. Only the spans the edit touched are parsed.
593
676
  // `held` names cards read again from their indexed span with no edit (see render).
594
677
  observe(stale, held = []) {
595
- 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() };
596
679
  const changed = new Set();
597
680
  for (const doc of this.docs) {
598
681
  const entries = this.entries(doc.role);
@@ -633,11 +716,13 @@ class ZtrackBoard {
633
716
  catch (error) { plan.errors.set(id, error.message); continue; }
634
717
  entry.rec = hash(JSON.stringify(records[0]));
635
718
  plan.cards.set(id, card);
719
+ if (records[0].number) plan.numbers.set(id, records[0].number);
636
720
  }
637
721
  for (const [id, card] of plan.cards) {
638
722
  plan.rows.set(id, this.cardRows(id, card, null));
639
723
  const subtasks = [];
640
- 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);
641
726
  plan.rows.set(tid, this.cardRows(tid, taskCard, id));
642
727
  plan.tasks.set(tid, { key, sources });
643
728
  subtasks.push(tid);
@@ -786,6 +871,13 @@ class ZtrackBoard {
786
871
  this.statement(`DELETE FROM ${schema}.task_links WHERE child_id = ?`).run(id);
787
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' } }); }
788
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
+ }
789
881
  for (const [id, rows] of plan.rows) {
790
882
  const before = fingerprintOf(id);
791
883
  const oldTask = this.one(`SELECT * FROM ${schema}.tasks WHERE id = ?`, id) ?? {};
@@ -861,7 +953,7 @@ class ZtrackBoard {
861
953
  return { task, state };
862
954
  }
863
955
 
864
- // 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].
865
957
  taskCards(id, card) {
866
958
  return card.acceptanceCriteria.map((task) => {
867
959
  const key = `${id}:${task.id}`;
@@ -879,7 +971,7 @@ class ZtrackBoard {
879
971
  machine: task.metadata?.machine ?? storedState?.machine ?? card.machine, acceptanceCriteria: [], acceptance: task.lines ?? [],
880
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) })) };
881
973
  if (task.waitingOn) taskCard.relations.push({ type: 'blocked-by', issueId: task.waitingOn });
882
- return [tid, taskCard, key, task.sources];
974
+ return [tid, taskCard, key, task.sources, task.number];
883
975
  });
884
976
  }
885
977
 
@@ -899,7 +991,8 @@ class ZtrackBoard {
899
991
  return parents;
900
992
  };
901
993
  let parents = cardsTouched();
902
- if (!parents.size) return [];
994
+ const mark = this.numbersRendered ? this.numberMark() : null;
995
+ if (!parents.size && !mark) return [];
903
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);
904
997
  // A card held by a recorded refusal is not rendered, so the store's verbs move its tasks past its lines. When
905
998
  // the store moves it, its span is read again: what the store moved past is stale and takes the store's value
@@ -912,6 +1005,7 @@ class ZtrackBoard {
912
1005
  const where = new Map();
913
1006
  for (const role of roles) layout[role].forEach((entry, index) => { if (entry.id) where.set(nativeId(entry.id), { role, index, entry }); });
914
1007
  const edits = Object.fromEntries(roles.map((role) => [role, []]));
1008
+ if (mark) edits.open.push(mark);
915
1009
  for (const id of parents) {
916
1010
  if (heldBy(id)) continue;
917
1011
  const task = this.one('SELECT * FROM main.tasks WHERE id = ?', id);
@@ -975,7 +1069,8 @@ class ZtrackBoard {
975
1069
  dropped.push(old.cid);
976
1070
  if (edit.remove) entries.splice(edit.index, 1); else entries[edit.index] = edit.entry;
977
1071
  }
978
- 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)) {
979
1074
  if (text && !text.endsWith('\n\n')) {
980
1075
  // the blank line before a new section belongs to the span above it
981
1076
  const last = entries.at(-1), separator = text.endsWith('\n') ? '\n' : '\n\n';
@@ -1063,7 +1158,7 @@ class ZtrackBoard {
1063
1158
  const messages = blocks.filter((block) => block.kind === 'message');
1064
1159
  // A subtask is its line and its brief (its body, as Markdown under it); its id, status words, assignee and run
1065
1160
  // output stay in the store (company RFC 0025 decision 4).
1066
- 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,
1067
1162
  ...(task.body ? { brief: task.body } : {}), lines: task.acceptance,
1068
1163
  waitingOn: messages[0]?.id,
1069
1164
  blockedBy: blocks.filter((block) => block !== messages[0]).map((block) => {
@@ -1074,7 +1169,82 @@ class ZtrackBoard {
1074
1169
  }) });
1075
1170
  }
1076
1171
  const serialized = this.preset.serializeKanbanCard(card);
1077
- 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 } };
1078
1248
  }
1079
1249
 
1080
1250
  // An ended subtask's own record in the archive document: its title and brief, and the card it belongs to.
@@ -1084,7 +1254,7 @@ class ZtrackBoard {
1084
1254
  const metadata = { subtask_of: fileId(parentId), ...Object.fromEntries(Object.entries(task).filter(([k, v]) => declared(k, v))) };
1085
1255
  const card = { ...parsed, metadata, machine: state.machine || undefined, relations: [] };
1086
1256
  const serialized = this.preset.serializeKanbanCard(card);
1087
- 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 };
1088
1258
  }
1089
1259
 
1090
1260
  // ------------------------------------------------------------------ migration from a SQLite board
@@ -1110,10 +1280,15 @@ class ZtrackBoard {
1110
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));
1111
1281
  };
1112
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)}`);
1113
- 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) {
1114
1288
  if (!this.tables.has(table.name)) continue;
1115
1289
  for (const row of source.db.prepare(`SELECT * FROM ${quote(table.name)}`).all()) insert(table.name, row);
1116
1290
  }
1291
+ raiseCardNumbers(this.conn, Number(source.db.prepare('SELECT seq FROM sqlite_sequence WHERE name = ?').get(CARD_NUMBERS)?.seq ?? 0));
1117
1292
  for (const task of this.conn.prepare('SELECT * FROM main.tasks').all()) {
1118
1293
  const old = this.one('SELECT * FROM main.supercode_cards WHERE task_id = ?', task.id) ?? {};
1119
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}`); }
@@ -0,0 +1,2 @@
1
+ /** Read a remote card write's selected text file (or stdin) on the caller's machine. */
2
+ export function prepareBoardText(argv: string[]): { argv: string[]; stdin: string | null };
@@ -0,0 +1,16 @@
1
+ // A remote card write reads its text on the caller's machine, before the board door is called.
2
+ // The board's own argument parser decides which file is used; its last value wins.
3
+ import { readFileSync } from 'node:fs';
4
+ import { parse } from './board/args.mjs';
5
+
6
+ /** Carry a comment/create/specify text input as the owner's existing `stdin` field. */
7
+ export function prepareBoardText(argv) {
8
+ const args = parse(argv);
9
+ const verb = args._[0];
10
+ const key = verb === 'comment' ? 'file' : ['create', 'specify'].includes(verb) ? 'body-file' : null;
11
+ const file = key === null || args.help ? undefined : args[key];
12
+ if (file === undefined) return { argv, stdin: null };
13
+ // Appending the selected flag preserves every other argument and overrides earlier occurrences. The owner
14
+ // reads the carried text at `-`, never an unrelated file at the caller's original path on another machine.
15
+ return { argv: [...argv, `--${key}`, '-'], stdin: readFileSync(file === '-' ? 0 : file, 'utf8') };
16
+ }