@volter/supercode-orchestrator 0.5.228 → 0.5.230

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 (80) hide show
  1. package/README.md +18 -2
  2. package/activation.mjs +1 -1
  3. package/adapters/agent-mailbox.mjs +10 -0
  4. package/adapters/api-server.mjs +6 -0
  5. package/agents.mjs +149 -40
  6. package/apply/doors.mjs +3 -1
  7. package/bin/agent-channel.mjs +7 -2
  8. package/bin/connection-runner.mjs +60 -19
  9. package/bin/hermes-worker.mjs +2 -1
  10. package/bin/orchestrator.mjs +3 -3
  11. package/bin/runtime.mjs +3 -2
  12. package/board/agent-current.mjs +82 -0
  13. package/board/agent-declarations.mjs +10 -3
  14. package/board/args.mjs +137 -6
  15. package/board/call.mjs +16 -1
  16. package/board/caller-session.mjs +3 -1
  17. package/board/capacity.mjs +59 -45
  18. package/board/card-state.mjs +14 -5
  19. package/board/ci.mjs +521 -178
  20. package/board/cli.mjs +614 -209
  21. package/board/client.mjs +64 -21
  22. package/board/close-current.mjs +36 -0
  23. package/board/current-records.mjs +44 -0
  24. package/board/custody.mjs +2 -1
  25. package/board/dispatch.mjs +1973 -846
  26. package/board/doors.mjs +2 -2
  27. package/board/engine.mjs +623 -85
  28. package/board/event-stream.mjs +10 -5
  29. package/board/fence.mjs +2 -0
  30. package/board/files.mjs +93 -62
  31. package/board/generation-owner.mjs +188 -53
  32. package/board/git-document.mjs +3 -1
  33. package/board/github-rest.mjs +29 -7
  34. package/board/incidents.mjs +5 -17
  35. package/board/listing.mjs +14 -8
  36. package/board/local-admission.mjs +59 -0
  37. package/board/lock-holder.mjs +18 -6
  38. package/board/machine-fleet.mjs +66 -13
  39. package/board/owner-door-worker.mjs +3 -3
  40. package/board/owner-door.mjs +17 -7
  41. package/board/owner-health.mjs +12 -15
  42. package/board/owner-interface.mjs +6 -0
  43. package/board/publication-reader.mjs +50 -0
  44. package/board/published.mjs +40 -12
  45. package/board/queries.mjs +24 -4
  46. package/board/record-view.mjs +30 -3
  47. package/board/request-mail.mjs +37 -6
  48. package/board/resting-admission.mjs +318 -0
  49. package/board/resting-codec-qualifications.json +33 -0
  50. package/board/resting-protocol.mjs +71 -0
  51. package/board/resting-state-protocol.json +120 -0
  52. package/board/resting-state.mjs +113 -0
  53. package/board/runtime-door.mjs +2 -2
  54. package/board/session-continuity.mjs +127 -0
  55. package/board/session-work.mjs +25 -13
  56. package/board/standalone-declaration.mjs +24 -0
  57. package/board/storage-contract.mjs +129 -7
  58. package/board/store.mjs +73 -11
  59. package/board/tools.mjs +56 -13
  60. package/board/wake.mjs +18 -5
  61. package/board/workflow.mjs +284 -82
  62. package/board/workspace.mjs +86 -9
  63. package/board/ztrack.mjs +344 -91
  64. package/connection.mjs +72 -17
  65. package/gates.mjs +2 -1
  66. package/hand-off.mjs +4 -3
  67. package/hermes-cli.mjs +5 -3
  68. package/hermes-locks.mjs +5 -2
  69. package/hosted.mjs +2 -1
  70. package/index.d.ts +2 -0
  71. package/index.mjs +1 -1
  72. package/kanban-worker.mjs +63 -13
  73. package/loop.mjs +33 -0
  74. package/mcp/hermes-kanban-tools.json +109 -50
  75. package/mcp/server.mjs +7 -1
  76. package/ontology.d.ts +26 -0
  77. package/orchestration.mjs +15 -4
  78. package/package.json +19 -12
  79. package/session-recovery.mjs +2 -1
  80. package/worker-route.mjs +37 -19
package/board/ztrack.mjs CHANGED
@@ -1,4 +1,8 @@
1
+ import { registerWorkOperations, measureWork } from "@volter/supercode-harness-sdk/work-ledger";
2
+ registerWorkOperations([{"kind":"pass","operation":"board.transaction"}]);
3
+ import { assertBoardRestingDocument } from './resting-admission.mjs';
1
4
  import { fillReadableIds, validateReadableId } from './readable-id.mjs';
5
+ import { assertBacklogQualified, assertPauseRecord, restingReaderBound, restingSerializationOptions } from './resting-state.mjs';
2
6
  // The document is card state. One SQLite file beside it keeps the runtime tables the engine speaks
3
7
  // and an index of the document's card spans. ADR 0001 and 0003.
4
8
  // All markdown interpretation belongs to ztrack's public codec and kanban preset.
@@ -73,7 +77,7 @@ const timedStatement = (sql, fn) => {
73
77
  finally { noteStatement(sql, started, before); }
74
78
  };
75
79
  import { fire, workflowFor } from './workflow.mjs';
76
- import { byOwner, LIST_REVISION, listRevision, TASK_SUMMARY_INDEX, TASK_SUMMARY_INDEX_SQL } from './listing.mjs';
80
+ import { byOwner, LIST_CONNECTION, LIST_REVISION, READ_BOUNDARY, listRevision, TASK_SUMMARY_INDEX, TASK_SUMMARY_INDEX_SQL } from './listing.mjs';
77
81
 
78
82
  const require = createRequire(import.meta.url);
79
83
  const hash = (text) => createHash('sha256').update(text).digest('hex');
@@ -144,7 +148,7 @@ export function openZtrackBoard(marker, { create = false, document = null, decla
144
148
  const config = JSON.parse(read(marker));
145
149
  const backing = new ZtrackBoard({ ...config, seed, marker, init, root, slug, held, readOnly });
146
150
  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,
147
- documentsChanged: () => backing.documentsChanged(), documentsUnwatched: (unwatched) => { backing.unwatched = unwatched; }, saidOnOpen: () => backing.sayLate() };
151
+ documentsChanged: () => backing.documentsChanged(), ownDocument: (path) => backing.ownDocument(path), documentsUnwatched: (unwatched) => { backing.unwatched = unwatched; }, saidOnOpen: () => backing.sayLate() };
148
152
  }
149
153
 
150
154
 
@@ -155,6 +159,7 @@ class ZtrackBoard {
155
159
  // watch on them (unwatched); git's lateness held and refreshed on its own commits
156
160
  this.held = held; this.docsChanged = true; this.importDue = true; this.knownStamps = null;
157
161
  // a listing's read-only view (readSnapshot): it makes, migrates, recovers, pulls and commits nothing
162
+ if (readOnly && !restingReaderBound()) throw byOwner('a document listing needs its owner’s source-bound resting-state reader');
158
163
  this.readOnly = readOnly;
159
164
  this.path = resolve(dirname(marker), document);
160
165
  this.archive = `${this.path}.archive.md`;
@@ -200,6 +205,8 @@ class ZtrackBoard {
200
205
  }
201
206
  const self = this;
202
207
  this.db = {
208
+ get [LIST_CONNECTION]() { return self.conn; },
209
+ [READ_BOUNDARY]: fn => self.read(fn),
203
210
  [LIST_REVISION]: () => self.read(() => listRevision(self.conn)),
204
211
  boardErrors: self.errors,
205
212
  prepare(sql) {
@@ -270,6 +277,14 @@ class ZtrackBoard {
270
277
  }
271
278
  /** The owner's watch saw this board's documents change: their stamps are read on the next read, and only then. */
272
279
  documentsChanged() { this.docsChanged = true; this.importDue = true; }
280
+ /** Whether document `path` holds the text this board published itself, and no hand edit was taken in since the
281
+ * owner's watch last asked: a change the owner's own write made (store.mjs ownBoardWrite). */
282
+ ownDocument(path) {
283
+ if (this.externalTaken) { this.externalTaken = false; return false; }
284
+ const doc = this.docs.find((d) => resolve(d.path) === resolve(path));
285
+ if (!doc) return false;
286
+ try { return this.fileHash(doc.path).hash === this.get('published', {})[doc.role]; } catch { return false; }
287
+ }
273
288
  // The board's documents: the open one, the current archive, and each rotated archive (recorded in the board's own
274
289
  // state, so a rotation committed but not yet written is still a document of the board).
275
290
  get docs() {
@@ -278,8 +293,26 @@ class ZtrackBoard {
278
293
  // recorded by file name, beside the document, so a board moved with its documents still finds them
279
294
  return [...this.baseDocs, ...rotated.map(({ role, name }) => ({ role, path: resolve(dirname(this.path), name) }))];
280
295
  }
281
- get codec() { return this._codec ??= require('@volter/ztrack/board-document'); }
282
- get preset() { return this._preset ??= require('@volter/ztrack/kanban-preset'); }
296
+ get codec() {
297
+ if (this._codec) return this._codec;
298
+ const codec = require('@volter/ztrack/board-document');
299
+ // A generation's actual predecessor chooses one immutable read/write interpretation. The options are never
300
+ // accepted from a CLI caller or environment; a legacy incumbent keeps the exact frozen predecessor grammar.
301
+ const positions = { parseBoardDocument: 2, inspectBoardDocument: 2, chunkBoardDocument: 2,
302
+ renderBoardCard: 3, renderBoardDocument: 4, mergeBoardDocument: 5, rechunkBoardDocument: 3 };
303
+ this._codec = { ...codec };
304
+ for (const [name, index] of Object.entries(positions)) if (typeof codec[name] === 'function') this._codec[name] = (...args) => {
305
+ args[index] = restingSerializationOptions();
306
+ return codec[name](...args);
307
+ };
308
+ return this._codec;
309
+ }
310
+ get preset() {
311
+ if (this._preset) return this._preset;
312
+ const preset = require('@volter/ztrack/kanban-preset');
313
+ return this._preset = { ...preset, parseKanban: records => preset.parseKanban(records, restingSerializationOptions()),
314
+ serializeKanbanCard: card => preset.serializeKanbanCard(card, restingSerializationOptions()) };
315
+ }
283
316
  board() { return { root: this.root, slug: this.slug, label: this.slug, db: this.db }; }
284
317
  close() { this.closed = true; this.git?.release(); this.dropOverlay(); this.conn.close(); }
285
318
 
@@ -327,7 +360,7 @@ class ZtrackBoard {
327
360
  }
328
361
  // What a transaction touched, for the renderer (cards whose text may change: anything beyond run fields and scheduling clocks). Plain inserts: a trigger's
329
362
  // own conflict clause yields to the outer statement's, so duplicates are removed when read.
330
- 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)');
363
+ 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); CREATE TEMP TABLE IF NOT EXISTS board_body_before (id TEXT, body TEXT, present INTEGER NOT NULL); CREATE INDEX IF NOT EXISTS temp.board_body_before_id ON board_body_before(id)');
331
364
  this.tables = this.shapes();
332
365
  const trigger = (name, when, body) => this.conn.exec(`CREATE TEMP TRIGGER IF NOT EXISTS ${quote(name)} ${when} BEGIN ${body} END`);
333
366
  for (const { name, keys } of this.tables.values()) {
@@ -343,6 +376,15 @@ class ZtrackBoard {
343
376
  trigger(`board_tu_${name}`, `AFTER UPDATE ON main.${quote(name)} WHEN ${changed}`, `INSERT INTO board_touched VALUES (NEW.${id}); INSERT INTO board_touched VALUES (OLD.${id});`);
344
377
  trigger(`board_td_${name}`, `AFTER DELETE ON main.${quote(name)}`, `INSERT INTO board_touched VALUES (OLD.${id});`);
345
378
  }
379
+ // Remember the first body seen in this transaction, including delete/insert replacements. An inherited row
380
+ // keeps its predecessor's interpretation; only newly supplied bytes need the stricter legacy admission.
381
+ // Plain inserts avoid an outer statement's conflict clause replacing the original snapshot.
382
+ const before = (id, body, present) => `INSERT INTO board_body_before SELECT ${id}, ${body}, ${present} WHERE NOT EXISTS (SELECT 1 FROM board_body_before WHERE id = ${id});`;
383
+ trigger('board_body_i', 'BEFORE INSERT ON main.tasks',
384
+ `INSERT INTO board_body_before SELECT id, body, 1 FROM main.tasks WHERE id = NEW.id AND NOT EXISTS (SELECT 1 FROM board_body_before WHERE id = NEW.id);` + before('NEW.id', 'NULL', '0'));
385
+ trigger('board_body_u', 'AFTER UPDATE ON main.tasks WHEN OLD.body IS NOT NEW.body OR OLD.id IS NOT NEW.id',
386
+ before('NEW.id', 'CASE WHEN OLD.id IS NEW.id THEN OLD.body ELSE NULL END', 'OLD.id IS NEW.id'));
387
+ trigger('board_body_d', 'AFTER DELETE ON main.tasks', before('OLD.id', 'OLD.body', '1'));
346
388
  }
347
389
 
348
390
  // Each runtime table's columns and key, as the store has them.
@@ -497,11 +539,15 @@ class ZtrackBoard {
497
539
  // a listing's view is its snapshot's (readSnapshot): its statements read that, never the documents again
498
540
  if (this.readOnly) return fn();
499
541
  if (this.active) return this.noting(fn);
500
- this.observeForRead();
501
- for (let attempt = 0; ; attempt++) {
502
- // each sleep between tries is a step: its count on a slow line is how many times the read was tried again
503
- try { return fn(); } catch (error) { if (!sqliteContention(error)) throw error; timedStep('board read: contention backoff', () => backoff(attempt)); }
504
- }
542
+ if (this.readDepth) return fn();
543
+ this.readDepth = 1;
544
+ try {
545
+ this.observeForRead();
546
+ for (let attempt = 0; ; attempt++) {
547
+ // each sleep between tries is a step: its count on a slow line is how many times the read was tried again
548
+ try { return fn(); } catch (error) { if (!sqliteContention(error)) throw error; timedStep('board read: contention backoff', () => backoff(attempt)); }
549
+ }
550
+ } finally { this.readDepth = 0; }
505
551
  }
506
552
 
507
553
  fileHash(path) {
@@ -509,6 +555,7 @@ class ZtrackBoard {
509
555
  const known = this.hashes.get(path);
510
556
  if (known?.stamp === stamp) return known;
511
557
  const raw = read(path);
558
+ assertBoardRestingDocument(raw, path);
512
559
  const value = { stamp, raw, hash: hash(raw) };
513
560
  this.hashes.set(path, value);
514
561
  return value;
@@ -533,20 +580,42 @@ class ZtrackBoard {
533
580
 
534
581
  // A reader sees a hand edit at once, through connection-local copies of the card tables, and
535
582
  // writes nothing: the next write records the edit.
583
+ observationRevision() {
584
+ return { connection: this.conn, ...this.statement(`SELECT total_changes() AS changes,
585
+ (SELECT data_version FROM pragma_data_version) AS data,
586
+ (SELECT schema_version FROM pragma_schema_version) AS mainSchema`).get(),
587
+ tempSchema: this.statement('PRAGMA temp.schema_version').get().schema_version };
588
+ }
589
+
536
590
  observeForRead() {
591
+ // The owner's document watch and its own writes invalidate explicitly. A foreign commit
592
+ // invalidates through SQLite. Include this connection's writes and both schemas: a raw
593
+ // write or replaced TEMP overlay must not keep an older observation. SQLite's schema_version
594
+ // table function reads main even when qualified with temp, so sample TEMP with its own PRAGMA.
595
+ // Nested reads share this boundary observation instead of rereading publication state.
596
+ const revision = this.observationRevision(), previous = this.observedRevision;
597
+ const unchanged = previous && Object.keys(revision).every(key => previous[key] === revision[key]);
598
+ if (this.held && !this.docsChanged && !this.unwatched && unchanged && this.seen !== null) return;
537
599
  // a held board knows its documents' stamps until the owner's watch says they changed; one opened per use reads them
538
600
  const stamps = this.held && !this.docsChanged && !this.unwatched && this.knownStamps ? this.knownStamps
539
601
  : timedStep('board read: document stamps', () => this.docs.map((doc) => this.fileHash(doc.path).stamp));
540
602
  if (this.held) { this.knownStamps = stamps; this.docsChanged = false; }
541
603
  const stamp = [...stamps, this.stateText('published'), this.stateText('pending_hashes')].join('|');
542
- if (stamp === this.seen) return;
543
- this.seen = stamp;
604
+ if (stamp === this.seen && unchanged) return;
605
+ this.seen = null; this.observedRevision = null;
606
+ if (previous && previous.mainSchema !== revision.mainSchema) this.tables = this.shapes();
544
607
  this.loadErrors();
545
608
  const stale = timedStep('board read: stale check', () => this.staleDocuments({ pending: true }));
546
- if (!stale.length) { this.dropOverlay(); return; }
547
- let plan;
548
- try { plan = timedStep('board read: observe', () => this.observe(stale)); } catch { this.dropOverlay(); return; } // the next write reports it
549
- timedStep('board read: overlay', () => this.applyPlan(plan, { overlay: true }));
609
+ if (!stale.length) this.dropOverlay();
610
+ else {
611
+ let plan;
612
+ try { plan = timedStep('board read: observe', () => this.observe(stale)); } catch { this.dropOverlay(); } // the next write reports it
613
+ if (plan) timedStep('board read: overlay', () => this.applyPlan(plan, { overlay: true }));
614
+ }
615
+ this.seen = stamp;
616
+ // Building/removing an overlay changes TEMP schema and total_changes itself. Retain its
617
+ // completed revision so the next unchanged read does not rebuild it again.
618
+ this.observedRevision = this.observationRevision();
550
619
  }
551
620
 
552
621
  // Whether a document holds an edit its tables have not imported (a hand edit, a git update): the publication owner
@@ -562,6 +631,7 @@ class ZtrackBoard {
562
631
 
563
632
  dropOverlay() {
564
633
  if (!this.overlay) return;
634
+ this.seen = null; this.observedRevision = null;
565
635
  this.conn.exec('DROP TABLE IF EXISTS temp.tasks; DROP TABLE IF EXISTS temp.supercode_cards; DROP TABLE IF EXISTS temp.task_links');
566
636
  this.overlay = false; this.statements.clear();
567
637
  }
@@ -569,9 +639,11 @@ class ZtrackBoard {
569
639
  // ------------------------------------------------------------------ writes
570
640
 
571
641
  tx(fn) {
642
+ return measureWork("pass", "board.transaction", () => {
572
643
  if (this.readOnly) throw new Error('a listing reads the board and writes nothing');
573
644
  // A write inside a write is part of it: the pull below happened once, for the outermost one.
574
645
  if (this.active) return fn(this.db);
646
+ this.seen = null;
575
647
  // A pull request merged on the remote (a page edited in Git Notes) is in the document before this write reads it.
576
648
  if (this.git) this.git.paths = this.docs.map((doc) => doc.path);
577
649
  try { timedStep('board git pull', () => this.git?.pull()); } catch (error) { current().err(`board: ${error.message}\n`); }
@@ -583,7 +655,7 @@ class ZtrackBoard {
583
655
  this.dropOverlay();
584
656
  timedStep('board tx begin', () => { this.conn.exec('BEGIN'); fenced(this.conn); });
585
657
  this.active = true; this.contended = false;
586
- this.conn.exec('DELETE FROM temp.board_dirty; DELETE FROM temp.board_touched');
658
+ this.conn.exec('DELETE FROM temp.board_dirty; DELETE FROM temp.board_touched; DELETE FROM temp.board_body_before');
587
659
  this.refused = new Set();
588
660
  timedStep('board tx errors', () => this.loadErrors());
589
661
  const recovered = timedStep('board tx recover', () => this.recover());
@@ -592,9 +664,15 @@ class ZtrackBoard {
592
664
  if (this.seed) this.indexForSeed();
593
665
  else {
594
666
  const stale = timedStep('board tx stale check', () => this.staleDocuments({ pending: false }));
595
- if (stale.length) timedStep('board tx reread', () => this.applyPlan(this.observe(stale), { overlay: false }));
667
+ if (stale.length) { this.externalTaken = true; timedStep('board tx reread', () => this.applyPlan(this.observe(stale), { overlay: false })); }
668
+ }
669
+ if (this.seed) {
670
+ this.seedRows();
671
+ // The explicit SQLite migration copies existing predecessor rows, not newly authored bodies.
672
+ this.conn.exec('DELETE FROM temp.board_body_before');
596
673
  }
597
- if (this.seed) this.seedRows();
674
+ this.letGo = new Set();
675
+ if (!this.rendersRetried) timedStep('board tx retry renders', () => this.retryRenders());
598
676
  timedStep('board tx snapshot', () => this.snapshotHeld());
599
677
  fillReadableIds(this.db);
600
678
  const result = timedStep('board tx body', () => fn(this.db));
@@ -617,12 +695,16 @@ class ZtrackBoard {
617
695
  this.put('task_identities_written', true);
618
696
  }
619
697
  const files = timedStep('board tx render', () => this.render());
698
+ // Rendering may reread refused spans and run document hooks. Admit the final supplied bytes outside every
699
+ // recoverable render catch, before commit or publication, including bodies hidden by presentation holds.
700
+ timedStep('board tx body admission', () => this.validateBodyChanges());
620
701
  const intent = files.length ? randomBytes(16).toString('hex') : null;
621
702
  if (intent) {
622
703
  this.put('pending', { intent, files });
623
704
  this.put('pending_hashes', Object.fromEntries(files.map((f) => [f.role, { before: f.before, after: f.after }])));
624
705
  }
625
706
  timedStep('board tx commit', () => this.conn.exec('COMMIT'));
707
+ this.rendersRetried = true;
626
708
  // A long transaction holds every other writer of this board (the dispatcher's tick included), so it is a line
627
709
  // of its own in the slow log, from whichever process ran it.
628
710
  const held = performance.now() - txStarted;
@@ -658,6 +740,9 @@ class ZtrackBoard {
658
740
  }
659
741
  return outcome.result;
660
742
  }
743
+
744
+
745
+ });
661
746
  }
662
747
 
663
748
  // Replace each document with its committed text. A document edited in the meantime is merged
@@ -691,6 +776,8 @@ class ZtrackBoard {
691
776
  const pending = this.get('pending');
692
777
  if (!pending) return false;
693
778
  for (const file of pending.files) {
779
+ assertBoardRestingDocument(file.base, `${file.path} (pending base)`);
780
+ assertBoardRestingDocument(file.text, `${file.path} (pending publication)`);
694
781
  const current = this.fileHash(file.path);
695
782
  if (current.hash === file.after) continue;
696
783
  if (current.hash === file.before) { atomic(file.path, file.text); continue; }
@@ -780,7 +867,29 @@ class ZtrackBoard {
780
867
  const keyed = new Map([...plan.tasks].map(([tid, { key }]) => [key, tid]));
781
868
  const resolveRef = (ref) => keyed.get(ref) ?? this.one('SELECT id FROM main.board_tasks WHERE key = ?', ref)?.id ?? ref;
782
869
  for (const rows of plan.rows.values()) rows.state.blocked_by = JSON.stringify(json(rows.state.blocked_by, []).map((b) => ({ ...b, id: resolveRef(b.id) })));
783
- if (held.length) this.keepMoved(plan);
870
+ // A subtask's task line carries only its blockers' ids. A blocker the store already holds keeps the store's entry
871
+ // whole (answered, aged_at, reason, by, since): a hand edit of the card's text is no new block, and an answer
872
+ // already handed to the workflow is not handed to it again (workflow.mjs settleRelations).
873
+ for (const [id, rows] of plan.rows) {
874
+ if (!rows.state.subtask_of) continue;
875
+ const stored = json(this.one('SELECT blocked_by FROM main.supercode_cards WHERE task_id = ?', id)?.blocked_by, []);
876
+ if (stored.length) rows.state.blocked_by = JSON.stringify(json(rows.state.blocked_by, []).map((b) => stored.find((s) => s.kind === b.kind && s.id === b.id) ?? b));
877
+ }
878
+ // A held card read again keeps what the store moved since this write began (snapshotHeld). A hand edit of a card
879
+ // held by a refused render (heldBy) is made over the board's last render, older than the store:
880
+ // it keeps what the store moved past that render, and a task line that render never showed is not removed by it.
881
+ if (held.length) this.keepMoved(plan, new Set(held), (id) => this.heldRows?.get(id));
882
+ const reads = this.refusedReads();
883
+ const unrendered = new Set([...plan.cards.keys()].filter((id) => !held.includes(id) && plan.before.has(id) && this.heldBy(id) && !reads.has(id)));
884
+ if (unrendered.size) {
885
+ const cardOf = (id) => plan.rows.get(id)?.state.subtask_of ?? this.one('SELECT subtask_of FROM main.supercode_cards WHERE task_id = ?', id)?.subtask_of ?? id;
886
+ this.keepMoved(plan, unrendered, (id) => this.spanRows(plan, cardOf(id), resolveRef).get(id), { span: true });
887
+ for (const id of plan.remove) {
888
+ const card = cardOf(id);
889
+ const prior = card !== id && unrendered.has(card) && !plan.remove.has(card) ? this.spanRows(plan, card, resolveRef) : null;
890
+ if (prior?.size && !prior.has(id)) { plan.remove.delete(id); plan.checked.add(id); }
891
+ }
892
+ }
784
893
  this.checkControls(plan, resolveRef);
785
894
  // A refused task line refuses its card's edit: the hand text stays in the document with the reason, never
786
895
  // re-rendered without the line.
@@ -797,6 +906,81 @@ class ZtrackBoard {
797
906
  return plan;
798
907
  }
799
908
 
909
+ // A card is held while it or one of its subtasks has a recorded refusal (board_errors). A hold is one of two kinds:
910
+ // - a refused read: the card's span holds document text the store refused. The card is listed in `refused_reads`,
911
+ // and its span is read again when a write touches it (render, keepMoved).
912
+ // - a refused render: the store moved past the card's span and the codec could not render it. Its span is still the
913
+ // board's own last render (any hand edit since is read, and either taken or listed as a refused read), so the
914
+ // store is authoritative: the card renders again from the store, and is held again if that render fails.
915
+ heldBy(card) { return this.errors.has(card) || Boolean(this.one('SELECT 1 FROM main.supercode_cards WHERE subtask_of = ? AND task_id IN (SELECT id FROM main.board_errors)', card)); }
916
+ refusedReads() { return new Set(this.get('refused_reads', []).filter((card) => this.heldBy(card))); }
917
+
918
+ // A card held by a refused render is let go, its subtasks' refusals with it, to render from the store.
919
+ release(card) {
920
+ for (const id of [card, ...this.statement('SELECT task_id FROM main.supercode_cards WHERE subtask_of = ?').all(card).map((row) => row.task_id)]) {
921
+ this.errors.delete(id);
922
+ this.statement('DELETE FROM main.board_errors WHERE id = ?').run(id);
923
+ }
924
+ }
925
+
926
+ // A refused render is tried again on the first write of each open of the board, before that write's verb runs: a
927
+ // card the store renders now is let go and written by this write (render), so no verb is refused (validateEvent)
928
+ // for a hold the codec no longer makes. A card that still fails, its record or its subtasks' included, stays held
929
+ // with its recorded reason, and the write goes on. Contention and a storage fault are the write's own failure, never
930
+ // the card's: they end the trial and are thrown to tx(), which rolls the write back whole and tries it again.
931
+ retryRenders() {
932
+ if (!this.errors.size) return;
933
+ const reads = this.refusedReads();
934
+ const cards = new Set([...this.errors.keys()].map((id) => this.one('SELECT subtask_of FROM main.supercode_cards WHERE task_id = ?', id)?.subtask_of ?? id));
935
+ const where = this.placed();
936
+ for (const card of cards) {
937
+ if (reads.has(card) || !this.one('SELECT 1 FROM main.tasks WHERE id = ?', card)) continue;
938
+ // a trial: what building the records writes (a subtask's key) is undone, and written by render() if let go
939
+ this.conn.exec('SAVEPOINT retry_render');
940
+ let renders = false;
941
+ try {
942
+ const task = this.one('SELECT * FROM main.tasks WHERE id = ?', card);
943
+ const state = this.one('SELECT * FROM main.supercode_cards WHERE task_id = ?', card) ?? {};
944
+ const { role, level } = this.placeOf(task, state, where.get(card));
945
+ // as render() writes it: each ended subtask's own entry while the card is open, then the card
946
+ for (const sub of this.statement('SELECT t.* FROM main.tasks t JOIN main.supercode_cards s ON s.task_id = t.id WHERE s.subtask_of = ?').all(card)) {
947
+ const subState = this.one('SELECT * FROM main.supercode_cards WHERE task_id = ?', sub.id) ?? {};
948
+ if (role !== 'open' || !ENDED.has(subState.stage ?? sub.status)) continue;
949
+ const subAt = where.get(sub.id), kept = isArchive(subAt?.role);
950
+ this.codec.renderBoardCard(this.subtaskRecord(sub.id, sub, subState, card, where), kept ? subAt.entry.level : 2, this.docOf(kept ? subAt.role : 'archived').path);
951
+ }
952
+ this.codec.renderBoardCard(this.recordOf(card, task, state, where), level, this.docOf(role).path);
953
+ renders = true;
954
+ } catch (error) {
955
+ // SQLite may have rolled the whole transaction back (IOERR, FULL, BUSY): the savepoint is gone with it, and
956
+ // nothing after this may run outside the write's transaction (a Node before 22.16 has no isTransaction, and
957
+ // says nothing of it)
958
+ if (contention(error) || storageFault(error) || this.conn.isTransaction === false) throw error;
959
+ /* otherwise still unrenderable: held as it was */
960
+ }
961
+ this.conn.exec('ROLLBACK TO retry_render; RELEASE retry_render');
962
+ if (!renders) continue;
963
+ this.release(card);
964
+ this.letGo.add(card);
965
+ this.statement('INSERT INTO temp.board_touched VALUES (?)').run(card);
966
+ }
967
+ }
968
+
969
+ // Every entry of every document, by card id: { role, index, entry }.
970
+ placed() {
971
+ const where = new Map();
972
+ for (const { role } of this.docs) this.entries(role).forEach((entry, index) => { if (entry.id) where.set(nativeId(entry.id), { role, index, entry }); });
973
+ return where;
974
+ }
975
+
976
+ // The document a card renders into, and its heading level there: an ended card stays in the archive that holds it (a
977
+ // rotated one included); one that newly ends goes to the current.
978
+ placeOf(task, state, at) {
979
+ const ended = state.archived || ENDED.has(state.stage ?? task.status);
980
+ const role = !ended ? 'open' : isArchive(at?.role) ? at.role : 'archived';
981
+ return { role, level: at?.role === role ? at.entry.level : 2 };
982
+ }
983
+
800
984
  // The rows of every card held by a recorded refusal (with its subtasks) before this write's verb runs, so a held
801
985
  // card read again after it keeps what the verb moved (keepMoved).
802
986
  snapshotHeld() {
@@ -810,22 +994,58 @@ class ZtrackBoard {
810
994
  }
811
995
  }
812
996
 
813
- // A held card's span is older than this write: every column the write's verb moved keeps the store's value
814
- // (title, body, assignee, status, ...); the span's text lands only where the store did not move.
815
- keepMoved(plan) {
816
- const same = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
997
+ // A held card's span is older than the store: every column the store moved past `base` (the rows the span stands
998
+ // for) keeps the store's value (title, body, assignee, status, ...); the span's text lands only where the store did
999
+ // not move. `cards` are the cards whose rows this applies to; `span` says `base` is the rows parsed from the span.
1000
+ keepMoved(plan, cards, base, { span = false } = {}) {
1001
+ // A span's parse and the store's row write one value differently, and a difference of form alone moves nothing
1002
+ // (a person's edit is kept): blocked_by's entries are compared whole (the store's own fields included: answered,
1003
+ // aged_at, reason, by, since), each with its keys in one order and the entries in kind/id order, so a store change
1004
+ // to any field moves it; a body without its trailing newlines (a span's parse ends its body with one, a stored
1005
+ // body need not). Against a span, a subtask's line differs from its store row in two more ways: its blocker ids
1006
+ // are all the line carries, so its blocked_by is compared by kind and id; and its title carries its task key (the
1007
+ // line reads `c1 title`, a subtask made by a verb stores `title`; both render the same line). Against a snapshot of
1008
+ // the store both sides are the store's, and are compared whole.
1009
+ const ordered = (v) => Array.isArray(v) ? v.map(ordered)
1010
+ : v && typeof v === 'object' ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, ordered(v[k])])) : v;
1011
+ const blocks = (v, ids) => (json(v, []) ?? []).map((b) => ids ? { id: b?.id, kind: b?.kind } : ordered(b))
1012
+ .sort((a, b) => String(a.kind).localeCompare(String(b.kind)) || String(a.id).localeCompare(String(b.id)));
1013
+ const value = (key, v, line) => key === 'blocked_by' ? blocks(v, line)
1014
+ : ['paused', 'backlog'].includes(key) ? json(v, null) : key === 'archived' ? Boolean(v)
1015
+ : key === 'body' ? String(v ?? '').replace(/\n+$/, '')
1016
+ : key === 'title' && line ? String(v ?? '').replace(/^[a-z]+\d+\s+(?=\S)/, '') : v ?? null;
1017
+ const same = (key, a, b, line) => JSON.stringify(value(key, a, line)) === JSON.stringify(value(key, b, line));
817
1018
  for (const [id, rows] of plan.rows) {
1019
+ if (!cards.has(rows.state.subtask_of ?? id)) continue;
818
1020
  const task = this.one('SELECT * FROM main.tasks WHERE id = ?', id), state = this.one('SELECT * FROM main.supercode_cards WHERE task_id = ?', id);
819
1021
  if (!task) continue;
820
- const was = this.heldRows?.get(id);
1022
+ const was = base(id);
821
1023
  for (const [side, now, then] of [['task', task, was?.task], ['state', state, was?.state]]) {
822
1024
  if (!now) continue;
823
- // A row the write created has nothing held to keep: all of it is the store's.
824
- for (const key of Object.keys(now)) if (!then || !same(now[key], then[key])) rows[side][key] = now[key];
1025
+ // A row `base` does not hold (one the store made since) has nothing held to keep: all of it is the store's.
1026
+ for (const key of Object.keys(now)) if (!then || !same(key, now[key], then[key], span && Boolean(rows.state.subtask_of))) rows[side][key] = now[key];
825
1027
  }
826
1028
  }
827
1029
  }
828
1030
 
1031
+ // The rows a card's span read as before this edit (plan.before), with its subtasks', parsed once per plan.
1032
+ spanRows(plan, card, resolveRef) {
1033
+ plan.spans ??= new Map();
1034
+ if (plan.spans.has(card)) return plan.spans.get(card);
1035
+ const rows = new Map(); plan.spans.set(card, rows);
1036
+ const at = plan.before.get(card);
1037
+ if (!at) return rows;
1038
+ try {
1039
+ const { records, errors } = this.codec.inspectBoardDocument(this.texts([at.entry])[0].text, this.docOf(at.role).path);
1040
+ if (errors.length || records.length !== 1 || nativeId(records[0].id) !== card) return rows;
1041
+ const parsed = this.preset.KanbanRootSchema.parse(this.preset.parseKanban([records[0]])).issues[0];
1042
+ rows.set(card, this.cardRows(card, parsed, null));
1043
+ for (const [tid, taskCard] of this.taskCards(card, parsed)) rows.set(tid, this.cardRows(tid, taskCard, card));
1044
+ for (const r of rows.values()) r.state.blocked_by = JSON.stringify(json(r.state.blocked_by, []).map((b) => ({ ...b, id: resolveRef(b.id) })));
1045
+ } catch { rows.clear(); } // an unreadable span says nothing of what the lines said
1046
+ return rows;
1047
+ }
1048
+
829
1049
  planRemoval(plan, id) {
830
1050
  const task = this.one('SELECT current_run_id FROM main.tasks WHERE id = ?', id);
831
1051
  if (!task) return;
@@ -845,30 +1065,14 @@ class ZtrackBoard {
845
1065
  try { fields = workflowFor(this.root).params.document_control_fields ?? []; } catch { /* no home policy */ }
846
1066
  const value = (task, state, field) => field === 'status' ? state?.stage ?? task?.status ?? 'todo'
847
1067
  : field === 'blocked_by' ? json(state?.blocked_by, []).map((b) => [b.kind, b.id]).sort()
848
- : field === 'paused' ? json(state?.paused, null)
1068
+ : ['paused', 'backlog'].includes(field) ? json(state?.[field], null)
849
1069
  : field === 'archived' ? Boolean(state?.archived) : task?.[field] ?? null;
850
1070
  const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
851
- // The rows a card's span read as before this edit, with its subtasks', parsed only when a field differs.
852
- const before = new Map();
853
- const rowsBefore = (card) => {
854
- if (before.has(card)) return before.get(card);
855
- const rows = new Map(); before.set(card, rows);
856
- const at = plan.before.get(card);
857
- if (!at) return rows;
858
- try {
859
- const { records, errors } = this.codec.inspectBoardDocument(this.texts([at.entry])[0].text, this.docOf(at.role).path);
860
- if (errors.length || records.length !== 1 || nativeId(records[0].id) !== card) return rows;
861
- const parsed = this.preset.KanbanRootSchema.parse(this.preset.parseKanban([records[0]])).issues[0];
862
- rows.set(card, this.cardRows(card, parsed, null));
863
- for (const [tid, taskCard] of this.taskCards(card, parsed)) rows.set(tid, this.cardRows(tid, taskCard, card));
864
- for (const r of rows.values()) r.state.blocked_by = JSON.stringify(json(r.state.blocked_by, []).map((b) => ({ ...b, id: resolveRef(b.id) })));
865
- } catch { rows.clear(); } // an unreadable span says nothing of what the lines said
866
- return rows;
867
- };
1071
+ const rowsBefore = (card) => this.spanRows(plan, card, resolveRef);
868
1072
  const adopt = (rows, task, state, field) => {
869
1073
  if (field === 'status') { rows.task.status = task.status; rows.state.stage = state?.stage ?? null; }
870
1074
  else if (field === 'blocked_by') rows.state.blocked_by = state?.blocked_by ?? '[]';
871
- else if (field === 'paused') rows.state.paused = state?.paused ?? null;
1075
+ else if (['paused', 'backlog'].includes(field)) rows.state[field] = state?.[field] ?? null;
872
1076
  else if (field === 'archived') rows.state.archived = state?.archived ?? 0;
873
1077
  else rows.task[field] = task[field] ?? null;
874
1078
  };
@@ -901,6 +1105,7 @@ class ZtrackBoard {
901
1105
  const schema = overlay ? 'temp' : 'main';
902
1106
  if (overlay) {
903
1107
  this.dropOverlay();
1108
+ this.seen = null; this.observedRevision = null;
904
1109
  this.conn.exec('CREATE TEMP TABLE tasks AS SELECT * FROM main.tasks; CREATE TEMP TABLE supercode_cards AS SELECT * FROM main.supercode_cards; CREATE TEMP TABLE task_links AS SELECT * FROM main.task_links');
905
1110
  this.conn.exec(TASK_SUMMARY_INDEX_SQL.replace(TASK_SUMMARY_INDEX, `temp.${TASK_SUMMARY_INDEX}`));
906
1111
  this.conn.exec("CREATE UNIQUE INDEX temp.supercode_open_readable_ids ON tasks(readable_id) WHERE status NOT IN ('done','cancelled','archived')");
@@ -946,6 +1151,12 @@ class ZtrackBoard {
946
1151
  const oldState = this.one(`SELECT * FROM ${schema}.supercode_cards WHERE task_id = ?`, id) ?? {};
947
1152
  this.replace(schema, 'tasks', { ...oldTask, ...rows.task }, 'id');
948
1153
  this.replace(schema, 'supercode_cards', { ...oldState, ...rows.state }, 'task_id');
1154
+ // Imports and rebuilt caches must satisfy the same release boundary as the manager's
1155
+ // backlog verb. A read overlay is discarded on refusal so it cannot hide execution holdings.
1156
+ try {
1157
+ assertPauseRecord(json(rows.state.paused, null), { id, source: 'document' });
1158
+ assertBacklogQualified(this.conn, id, { backlog: json(rows.state.backlog, null), source: 'document' });
1159
+ } catch (error) { if (overlay) this.dropOverlay(); throw error; }
949
1160
  if (rebuilding && ENDED.has(rows.state.stage ?? rows.task.status) && oldTask.current_run_id) {
950
1161
  this.statement("UPDATE main.task_runs SET ended_at = COALESCE(ended_at, ?), status = 'reclaimed', outcome = COALESCE(outcome, 'reclaimed') WHERE id = ? AND ended_at IS NULL").run(now(), oldTask.current_run_id);
951
1162
  this.statement('UPDATE main.tasks SET current_run_id = NULL, claim_lock = NULL, claim_expires = NULL, worker_pid = NULL WHERE id = ?').run(id);
@@ -970,6 +1181,8 @@ class ZtrackBoard {
970
1181
  for (const [tid, { key, sources }] of plan.tasks) this.statement('INSERT OR REPLACE INTO main.board_tasks (id, key, sources) VALUES (?, ?, ?)').run(tid, key, sources?.length ? JSON.stringify(sources) : null);
971
1182
  for (const id of [...plan.checked, ...plan.rows.keys()]) if (!plan.errors.has(id)) this.statement('DELETE FROM main.board_errors WHERE id = ?').run(id);
972
1183
  for (const [id, reason] of plan.errors) this.statement('INSERT OR REPLACE INTO main.board_errors VALUES (?, ?)').run(id, reason);
1184
+ // A card this read refused holds the refused text in its span (a refused read); one it took or let go holds none.
1185
+ this.put('refused_reads', [...new Set([...this.get('refused_reads', []), ...this.refused])].filter((card) => this.heldBy(card)));
973
1186
  for (const [role, entries] of plan.layout) {
974
1187
  if (!(role in plan.published)) continue;
975
1188
  this.put(`chunks:${role}`, entries.map((entry) => {
@@ -1000,7 +1213,7 @@ class ZtrackBoard {
1000
1213
 
1001
1214
  fingerprint(t, c) {
1002
1215
  const status = c.stage ?? t.status;
1003
- return { hash: hash(JSON.stringify([t.title, t.body, status, t.assignee, c.machine, c.blocked_by, c.paused, c.every])), status, assignee: t.assignee, parent: c.subtask_of };
1216
+ return { hash: hash(JSON.stringify([t.title, t.body, status, t.assignee, c.machine, c.blocked_by, c.paused, c.backlog, c.every])), status, assignee: t.assignee, parent: c.subtask_of };
1004
1217
  }
1005
1218
 
1006
1219
  // A card's rows from its parsed document form. Run fields and clocks stay the table's.
@@ -1008,7 +1221,7 @@ class ZtrackBoard {
1008
1221
  // An ended subtask kept in the archive document names its card (`subtask_of`); it is not a column of the task.
1009
1222
  const { subtask_of: declaredParent, ...meta } = card.metadata ?? {};
1010
1223
  parent ??= declaredParent ? nativeId(declaredParent) : null;
1011
- const stored = this.preset.serializeKanbanCard({ ...card, acceptanceCriteria: [], relations: [], metadata: undefined, paused: undefined, every: undefined, workspace: undefined, branch: undefined, machine: undefined, priority: undefined });
1224
+ const stored = this.preset.serializeKanbanCard({ ...card, acceptanceCriteria: [], relations: [], metadata: undefined, paused: undefined, backlog: undefined, every: undefined, workspace: undefined, branch: undefined, machine: undefined, priority: undefined });
1012
1225
  const workspace = /^(scratch|dir|worktree)(?::(.*))?$/.exec(card.workspace ?? 'scratch');
1013
1226
  const old = this.one('SELECT * FROM main.tasks WHERE id = ?', id) ?? {};
1014
1227
  const runtime = Object.fromEntries(Object.entries(old).filter(([k]) => RUNTIME.has(k) || RUN_OUTPUT.has(k)));
@@ -1019,9 +1232,9 @@ class ZtrackBoard {
1019
1232
  blocked_by: JSON.stringify((card.relations ?? []).map((r) => ({ ...(meta.blocked_by ?? []).find((b) => b.id === nativeId(r.issueId)), id: nativeId(r.issueId), kind: /^[ma]-/.test(r.issueId) ? 'message' : 'card' }))),
1020
1233
  // A card whose text says nothing of archiving keeps the store's flag: an entry rendered before subtask lines
1021
1234
  // carried it (or edited without it) is not an unarchive. Saying `archived: false` of an archived card is.
1022
- paused: card.paused ? JSON.stringify(card.paused) : null, every: card.every ?? null,
1235
+ paused: card.paused ? JSON.stringify(card.paused) : null, backlog: card.backlog ? JSON.stringify(card.backlog) : null, every: card.every ?? null,
1023
1236
  archived: (meta.archived ?? card.archived) === undefined ? Number(this.one('SELECT archived FROM main.supercode_cards WHERE task_id = ?', id)?.archived ?? 0) : ((meta.archived ?? card.archived) ? 1 : 0) };
1024
- if (!state.archived && !ENDED.has(card.status)) task.status = card.paused ? 'paused' : card.relations?.length ? 'blocked' : card.status;
1237
+ if (!state.archived && !ENDED.has(card.status)) task.status = card.paused ? 'paused' : card.relations?.length ? 'blocked' : card.backlog ? 'todo' : card.status;
1025
1238
  return { task, state };
1026
1239
  }
1027
1240
 
@@ -1035,7 +1248,7 @@ class ZtrackBoard {
1035
1248
  const stored = this.one('SELECT * FROM main.tasks WHERE id = ?', tid);
1036
1249
  const storedState = this.one('SELECT * FROM main.supercode_cards WHERE task_id = ?', tid);
1037
1250
  const was = task.metadata?.status ?? storedState?.stage ?? stored?.status;
1038
- const taskCard = { ...(storedState ? { paused: json(storedState.paused, null) ?? undefined, every: storedState.every || undefined } : {}), ...task.metadata?.card,
1251
+ const taskCard = { ...(storedState ? { paused: json(storedState.paused, null) ?? undefined, backlog: json(storedState.backlog, null) ?? undefined, every: storedState.every || undefined } : {}), ...task.metadata?.card,
1039
1252
  id: tid, title: `${task.id} ${task.text}`, body: task.metadata?.body ?? task.brief ?? '',
1040
1253
  status: task.status === 'passed' ? (was === 'cancelled' ? 'cancelled' : 'done') : (ENDED.has(was) ? 'todo' : was ?? 'todo'),
1041
1254
  metadata: task.metadata?.properties ?? Object.fromEntries(Object.entries(stored ?? {}).filter(([k, v]) => declared(k, v))),
@@ -1065,19 +1278,32 @@ class ZtrackBoard {
1065
1278
  let parents = cardsTouched();
1066
1279
  const mark = this.numberMark();
1067
1280
  if (!parents.size && !mark) return [];
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);
1281
+ const heldBy = (id) => this.heldBy(id);
1069
1282
  // A card held by a recorded refusal is not rendered, so the store's verbs move its tasks past its lines. When
1070
- // the store moves it, its span is read again: what the store moved past is stale and takes the store's value
1071
- // (checkControls), and the card renders. A card refused by this write's own edit stays as it was written.
1283
+ // the store moves it, a refused read's span is read again: what the store moved past is stale and takes the
1284
+ // store's value (keepMoved, checkControls), and the card renders. A refused render's span is the board's own and
1285
+ // older than the store, so it is never read back: the card renders from the store (heldBy). A card refused by this
1286
+ // write's own edit stays as it was written.
1072
1287
  const held = [...parents].filter((id) => !this.refused.has(id) && heldBy(id));
1073
- if (held.length) { this.applyPlan(this.observe([], held), { overlay: false }); parents = cardsTouched(); }
1288
+ const reads = this.refusedReads();
1289
+ // the refused renders let go for this write (this one's and retryRenders'): one that still cannot render is held again
1290
+ const released = new Set(this.letGo ?? []);
1291
+ for (const id of held) if (!reads.has(id)) { this.release(id); released.add(id); }
1292
+ const reread = held.filter((id) => reads.has(id));
1293
+ if (reread.length) { this.applyPlan(this.observe([], reread), { overlay: false }); parents = cardsTouched(); }
1074
1294
  if (this.seedIds) for (const id of this.seedIds) parents.add(id);
1075
1295
  const roles = this.docs.map((doc) => doc.role);
1076
1296
  const layout = Object.fromEntries(roles.map((role) => [role, this.entries(role)]));
1077
- const where = new Map();
1078
- for (const role of roles) layout[role].forEach((entry, index) => { if (entry.id) where.set(nativeId(entry.id), { role, index, entry }); });
1297
+ const where = this.placed();
1079
1298
  const edits = Object.fromEntries(roles.map((role) => [role, []]));
1080
1299
  if (mark) edits.open.push(mark);
1300
+ // a refused render: the span stays the board's last render, and the store stays authoritative
1301
+ const hold = (id, reason) => {
1302
+ this.errors.set(id, reason);
1303
+ this.statement('INSERT OR REPLACE INTO main.board_errors VALUES (?, ?)').run(id, reason);
1304
+ const listed = this.get('refused_reads', []);
1305
+ if (listed.includes(id)) this.put('refused_reads', listed.filter((card) => card !== id));
1306
+ };
1081
1307
  for (const id of parents) {
1082
1308
  if (heldBy(id)) continue;
1083
1309
  const task = this.one('SELECT * FROM main.tasks WHERE id = ?', id);
@@ -1085,37 +1311,42 @@ class ZtrackBoard {
1085
1311
  if (task && state.subtask_of) continue;
1086
1312
  const at = where.get(id);
1087
1313
  if (!task) { if (at) edits[at.role].push({ index: at.index, remove: true }); continue; }
1088
- // An ended card stays in the archive that holds it (a rotated one included); one that newly ends goes to the current.
1089
- const ended = state.archived || ['done', 'cancelled'].includes(state.stage ?? task.status);
1090
- const role = !ended ? 'open' : isArchive(at?.role) ? at.role : 'archived';
1091
- // Its ended subtasks: each its own entry in the archive document while the card is open (the open document lists
1092
- // only what is still to do); back in the card's own text once the card has ended.
1093
- for (const sub of this.statement('SELECT t.* FROM main.tasks t JOIN main.supercode_cards s ON s.task_id = t.id WHERE s.subtask_of = ?').all(id)) {
1094
- const subState = this.one('SELECT * FROM main.supercode_cards WHERE task_id = ?', sub.id) ?? {};
1095
- const subAt = where.get(sub.id);
1096
- if (role !== 'open' || !ENDED.has(subState.stage ?? sub.status)) { if (subAt) edits[subAt.role].push({ index: subAt.index, remove: true }); continue; }
1097
- const subRecord = this.subtaskRecord(sub.id, sub, subState, id, where);
1098
- const subRec = hash(JSON.stringify(subRecord));
1099
- const kept = isArchive(subAt?.role);
1100
- if (kept && subAt.entry.rec === subRec) continue;
1101
- let subText;
1102
- try { subText = this.codec.renderBoardCard(subRecord, kept ? subAt.entry.level : 2, this.docOf(kept ? subAt.role : 'archived').path); }
1103
- catch (error) { this.errors.set(sub.id, error.message); continue; }
1104
- const subEntry = { cid: null, id: subRecord.id, level: kept ? subAt.entry.level : 2, len: subText.length, rec: subRec, text: subText };
1105
- if (kept) edits[subAt.role].push({ index: subAt.index, entry: subEntry });
1106
- else { if (subAt) edits[subAt.role].push({ index: subAt.index, remove: true }); edits.archived.push({ entry: subEntry }); }
1314
+ const { role, level } = this.placeOf(task, state, at);
1315
+ // This card's edits, made once its record is built: a released card whose record cannot be built is held again
1316
+ // whole, its subtasks' entries untouched with it.
1317
+ const own = [];
1318
+ let record;
1319
+ try {
1320
+ // Its ended subtasks: each its own entry in the archive document while the card is open (the open document
1321
+ // lists only what is still to do); back in the card's own text once the card has ended.
1322
+ for (const sub of this.statement('SELECT t.* FROM main.tasks t JOIN main.supercode_cards s ON s.task_id = t.id WHERE s.subtask_of = ?').all(id)) {
1323
+ const subState = this.one('SELECT * FROM main.supercode_cards WHERE task_id = ?', sub.id) ?? {};
1324
+ const subAt = where.get(sub.id);
1325
+ if (role !== 'open' || !ENDED.has(subState.stage ?? sub.status)) { if (subAt) own.push([subAt.role, { index: subAt.index, remove: true }]); continue; }
1326
+ const subRecord = this.subtaskRecord(sub.id, sub, subState, id, where);
1327
+ const subRec = hash(JSON.stringify(subRecord));
1328
+ const kept = isArchive(subAt?.role);
1329
+ if (kept && subAt.entry.rec === subRec) continue;
1330
+ let subText;
1331
+ try { subText = this.codec.renderBoardCard(subRecord, kept ? subAt.entry.level : 2, this.docOf(kept ? subAt.role : 'archived').path); }
1332
+ catch (error) { this.errors.set(sub.id, error.message); continue; }
1333
+ const subEntry = { cid: null, id: subRecord.id, level: kept ? subAt.entry.level : 2, len: subText.length, rec: subRec, text: subText };
1334
+ if (kept) own.push([subAt.role, { index: subAt.index, entry: subEntry }]);
1335
+ else { if (subAt) own.push([subAt.role, { index: subAt.index, remove: true }]); own.push(['archived', { entry: subEntry }]); }
1336
+ }
1337
+ record = this.recordOf(id, task, state, where);
1338
+ } catch (error) {
1339
+ // contention and a storage fault (or a transaction SQLite already rolled back) fail the write, never the card
1340
+ if (!released.has(id) || contention(error) || storageFault(error) || this.conn.isTransaction === false) throw error;
1341
+ hold(id, error.message);
1342
+ continue;
1107
1343
  }
1108
- const record = this.recordOf(id, task, state, where);
1344
+ for (const [r, edit] of own) edits[r].push(edit);
1109
1345
  const rec = hash(JSON.stringify(record));
1110
1346
  if (at?.role === role && at.entry.rec === rec) continue;
1111
- const level = at?.role === role ? at.entry.level : 2;
1112
1347
  let text;
1113
1348
  try { text = this.codec.renderBoardCard(record, level, this.docOf(role).path); }
1114
- catch (error) {
1115
- this.errors.set(id, error.message);
1116
- this.statement('INSERT OR REPLACE INTO main.board_errors VALUES (?, ?)').run(id, error.message);
1117
- continue;
1118
- }
1349
+ catch (error) { hold(id, error.message); continue; }
1119
1350
  const entry = { cid: null, id: record.id, level, len: text.length, rec, text };
1120
1351
  if (at?.role === role) edits[role].push({ index: at.index, entry });
1121
1352
  else { if (at) edits[at.role].push({ index: at.index, remove: true }); edits[role].push({ entry }); }
@@ -1191,17 +1422,38 @@ class ZtrackBoard {
1191
1422
  published.archived = hash('');
1192
1423
  }
1193
1424
 
1425
+ validateBodyChanges() {
1426
+ if (restingSerializationOptions().resting !== 'legacy') return;
1427
+ const rows = this.statement(`SELECT t.*, COALESCE(s.stage,t.status) AS stage FROM temp.board_body_before b
1428
+ JOIN main.tasks t ON t.id = b.id LEFT JOIN main.supercode_cards s ON s.task_id = t.id
1429
+ WHERE b.present = 0 OR t.body IS NOT b.body`).all();
1430
+ for (const row of rows) this.parseStoredCard({ id: row.id, title: row.title, status: row.stage, body: row.body ?? '' }, row.id);
1431
+ }
1432
+
1433
+ // Validate newly supplied body bytes before the frozen parser can move Acceptance annotations or remove literal
1434
+ // escapes. Existing bytes retain the exact predecessor meaning on metadata edits and SQLite migration; incoming
1435
+ // documents have their own raw admission before parsing. Use the native row id even when its file id differs.
1436
+ parseStoredCard(record, id) {
1437
+ const before = this.one('SELECT body, present FROM temp.board_body_before WHERE id = ?', id);
1438
+ if (restingSerializationOptions().resting === 'legacy' && before && (!before.present || before.body !== record.body)) {
1439
+ const preset = require('@volter/ztrack/kanban-preset');
1440
+ const meaning = preset.KanbanRootSchema.parse(preset.parseKanban([record], { resting: 'preserve' })).issues[0];
1441
+ preset.serializeKanbanCard(meaning, { resting: 'legacy' });
1442
+ this.codec.renderBoardCard(record, 2, this.path);
1443
+ }
1444
+ return this.preset.KanbanRootSchema.parse(this.preset.parseKanban([record])).issues[0];
1445
+ }
1446
+
1194
1447
  // The document record of card `id`, from its rows and its subtasks' rows.
1195
1448
  recordOf(id, row, c, where) {
1196
1449
  const fileId = (x) => where.get(x)?.entry.id ?? x;
1197
1450
  const keyOf = (x) => this.one('SELECT key FROM main.board_tasks WHERE id = ?', x)?.key ?? null;
1198
- const parse = (record) => this.preset.KanbanRootSchema.parse(this.preset.parseKanban([record])).issues[0];
1199
- const parsed = parse({ id: fileId(id), title: row.title, status: c.stage ?? row.status, assignee: row.assignee || undefined, body: row.body ?? '' });
1451
+ const parsed = this.parseStoredCard({ id: fileId(id), title: row.title, status: c.stage ?? row.status, assignee: row.assignee || undefined, body: row.body ?? '' }, id);
1200
1452
  const metadata = Object.fromEntries(Object.entries(row).filter(([k, v]) => declared(k, v)));
1201
1453
  if (c.archived) metadata.archived = true;
1202
1454
  if (json(c.blocked_by, []).length) metadata.blocked_by = json(c.blocked_by, []);
1203
1455
  const card = { ...parsed, metadata, priority: row.priority ?? 0, workspace: row.workspace_kind + (row.workspace_kind !== 'scratch' && (c.workspace_anchor ?? row.workspace_path) ? `:${c.workspace_anchor ?? row.workspace_path}` : ''),
1204
- branch: row.branch_name || undefined, machine: c.machine || undefined, paused: json(c.paused, null) ?? undefined, every: c.every || undefined,
1456
+ branch: row.branch_name || undefined, machine: c.machine || undefined, paused: json(c.paused, null) ?? undefined, backlog: json(c.backlog, null) ?? undefined, every: c.every || undefined,
1205
1457
  // Task lines left in a stored body (create and specify file them as subtasks) stay in its Tasks, before its subtasks.
1206
1458
  relations: json(c.blocked_by, []).map((b) => ({ type: 'blocked-by', issueId: fileId(b.id) })), acceptanceCriteria: [...(parsed.acceptanceCriteria ?? [])] };
1207
1459
  // An open card lists its open subtasks; its ended ones are kept in the archive document (subtaskRecord), so the open
@@ -1225,7 +1477,7 @@ class ZtrackBoard {
1225
1477
  const stored = this.one('SELECT key, sources FROM main.board_tasks WHERE id = ?', r.id);
1226
1478
  if (stored?.key !== `${id}:${key}`) this.statement('INSERT INTO main.board_tasks (id, key, sources) VALUES (?, ?, ?) ON CONFLICT(id) DO UPDATE SET key = excluded.key').run(r.id, `${id}:${key}`, stored?.sources ?? null);
1227
1479
  const match = /^([a-z]+\d+)\s+(.+)$/.exec(r.title);
1228
- const task = parse({ id: r.id, title: r.title, status, body: r.body ?? '' });
1480
+ const task = this.parseStoredCard({ id: r.id, title: r.title, status, body: r.body ?? '' }, r.id);
1229
1481
  const blocks = json(s.blocked_by, []);
1230
1482
  const messages = blocks.filter((block) => block.kind === 'message');
1231
1483
  // Keep the identity-to-number binding and declared state durable with its line; a stale runtime cache cannot
@@ -1234,7 +1486,8 @@ class ZtrackBoard {
1234
1486
  metadata: { id: r.id, status, assignee: r.assignee, machine: s.machine,
1235
1487
  properties: Object.fromEntries(Object.entries(r).filter(([k, v]) => declared(k, v))),
1236
1488
  card: { priority: r.priority ?? 0, workspace: r.workspace_kind + (r.workspace_kind !== 'scratch' && (s.workspace_anchor ?? r.workspace_path) ? `:${s.workspace_anchor ?? r.workspace_path}` : ''),
1237
- branch: r.branch_name || undefined, paused: json(s.paused, null) ?? undefined, every: s.every || undefined } },
1489
+ branch: r.branch_name || undefined, paused: json(s.paused, null) ?? undefined, backlog: json(s.backlog, null) ?? undefined, every: s.every || undefined,
1490
+ acceptancePreamble: task.acceptancePreamble?.length ? task.acceptancePreamble : undefined } },
1238
1491
  ...(task.body ? { brief: task.body } : {}), lines: task.acceptance,
1239
1492
  waitingOn: messages[0]?.id,
1240
1493
  blockedBy: blocks.filter((block) => block !== messages[0]).map((block) => {
@@ -1244,7 +1497,7 @@ class ZtrackBoard {
1244
1497
  return { issue: fileId(target.slice(0, at)), ac: target.slice(at + 1) };
1245
1498
  }) });
1246
1499
  }
1247
- const serialized = this.preset.serializeKanbanCard(card);
1500
+ const serialized = this.preset.serializeKanbanCard(card, restingSerializationOptions());
1248
1501
  return { id: card.id, ...this.numberOf(id), title: card.title, status: card.status, ...(card.assignee ? { assignee: card.assignee } : {}), body: serialized.body };
1249
1502
  }
1250
1503
 
@@ -1349,10 +1602,10 @@ class ZtrackBoard {
1349
1602
  // An ended subtask's own record in the archive document: its title and brief, and the card it belongs to.
1350
1603
  subtaskRecord(id, task, state, parentId, where) {
1351
1604
  const fileId = (x) => where.get(x)?.entry.id ?? x;
1352
- const parsed = this.preset.KanbanRootSchema.parse(this.preset.parseKanban([{ id: fileId(id), title: task.title, status: state.stage ?? task.status, assignee: task.assignee || undefined, body: task.body ?? '' }])).issues[0];
1605
+ const parsed = this.parseStoredCard({ id: fileId(id), title: task.title, status: state.stage ?? task.status, assignee: task.assignee || undefined, body: task.body ?? '' }, id);
1353
1606
  const metadata = { subtask_of: fileId(parentId), ...Object.fromEntries(Object.entries(task).filter(([k, v]) => declared(k, v))) };
1354
1607
  const card = { ...parsed, metadata, machine: state.machine || undefined, relations: [] };
1355
- const serialized = this.preset.serializeKanbanCard(card);
1608
+ const serialized = this.preset.serializeKanbanCard(card, restingSerializationOptions());
1356
1609
  return { id: card.id, ...this.numberOf(id), title: card.title, status: card.status, ...(card.assignee ? { assignee: card.assignee } : {}), body: serialized.body };
1357
1610
  }
1358
1611