ultra-builder-pro-cli 0.23.0 → 0.24.0

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 (91) hide show
  1. package/.ultra-template/docs/research/README.md +4 -4
  2. package/CHANGELOG.md +45 -0
  3. package/README.md +21 -18
  4. package/adapters/_shared/codex-assets.cjs +121 -65
  5. package/adapters/_shared/interaction-contract.cjs +7 -1
  6. package/adapters/_shared/provenance.cjs +7 -2
  7. package/adapters/_shared/runtime-assets.cjs +3 -2
  8. package/adapters/_shared/runtime-native.cjs +132 -0
  9. package/adapters/claude.js +60 -30
  10. package/adapters/codex.js +178 -81
  11. package/adapters/grok.js +372 -0
  12. package/adapters/kimi.js +7 -3
  13. package/adapters/opencode.js +210 -32
  14. package/agents/code-reviewer.md +4 -2
  15. package/agents/debugger.md +4 -1
  16. package/agents/review-code.md +7 -3
  17. package/agents/review-comments.md +7 -4
  18. package/agents/review-coordinator.md +11 -7
  19. package/agents/review-design.md +8 -4
  20. package/agents/review-errors.md +8 -4
  21. package/agents/review-spec.md +8 -5
  22. package/agents/review-tests.md +7 -4
  23. package/agents/tdd-runner.md +5 -2
  24. package/bin/install.js +9 -4
  25. package/bin/orchestrator.js +4 -13
  26. package/docs/AGENT-CONTEXT.md +141 -229
  27. package/docs/ARCHITECTURE.md +203 -494
  28. package/docs/ARTIFACT-AUTHORITY.md +26 -22
  29. package/docs/PLUGIN-ISOLATION-CONTRACT.md +12 -9
  30. package/docs/ROADMAP.md +85 -110
  31. package/docs/RUNTIME-COMPAT-MATRIX.md +139 -175
  32. package/docs/SKILL-AUTHORING.md +3 -3
  33. package/docs/STATE-DB-ACCESS-POLICY.md +13 -8
  34. package/docs/WORKFLOW-LIFECYCLE.md +174 -448
  35. package/hooks/README.md +11 -8
  36. package/hooks/active_task_context.py +16 -16
  37. package/hooks/adapters/grok.py +134 -0
  38. package/hooks/{context_spine.py → context_envelope.py} +26 -22
  39. package/hooks/health_check.py +19 -29
  40. package/hooks/pre_stop_check.py +22 -12
  41. package/hooks/subagent_tracker.py +1 -1
  42. package/hooks/workflow_checkpoint.py +14 -13
  43. package/hooks/workflow_context.py +11 -12
  44. package/hooks/workflow_resume.py +14 -13
  45. package/mcp-server/{breadcrumb.cjs → hook-context.cjs} +7 -9
  46. package/mcp-server/lib/baseline-workflow.cjs +36 -38
  47. package/mcp-server/lib/canonical-json.cjs +19 -0
  48. package/mcp-server/lib/change-workflow.cjs +596 -44
  49. package/mcp-server/lib/context-envelope.cjs +508 -0
  50. package/mcp-server/lib/decision-records.cjs +337 -0
  51. package/mcp-server/lib/doctor.cjs +91 -33
  52. package/mcp-server/lib/init-project.cjs +78 -39
  53. package/mcp-server/lib/managed-file-write.cjs +116 -0
  54. package/mcp-server/lib/plan-checkpoint.cjs +177 -0
  55. package/mcp-server/lib/project-context.cjs +178 -0
  56. package/mcp-server/lib/stage-checkpoints.cjs +309 -0
  57. package/mcp-server/lib/state-db.cjs +155 -24
  58. package/mcp-server/lib/task-ledger.cjs +522 -13
  59. package/mcp-server/lib/ultra-facade.cjs +630 -442
  60. package/mcp-server/lib/worker-packet.cjs +358 -0
  61. package/mcp-server/server.cjs +200 -1103
  62. package/orchestrator/daemon.cjs +27 -9
  63. package/orchestrator/parallel-orchestrator.cjs +111 -76
  64. package/orchestrator/session-runner.cjs +135 -34
  65. package/package.json +10 -3
  66. package/skills/ultra-change/SKILL.md +9 -8
  67. package/skills/ultra-deliver/SKILL.md +3 -3
  68. package/skills/ultra-dev/SKILL.md +18 -11
  69. package/skills/ultra-doctor/SKILL.md +2 -2
  70. package/skills/ultra-init/SKILL.md +6 -5
  71. package/skills/ultra-init/references/brownfield-adoption.md +11 -8
  72. package/skills/ultra-plan/SKILL.md +10 -8
  73. package/skills/ultra-research/SKILL.md +12 -7
  74. package/skills/ultra-review/SKILL.md +9 -6
  75. package/skills/ultra-review/references/unified-schema.md +2 -0
  76. package/skills/ultra-review/scripts/review_wait.py +8 -0
  77. package/skills/ultra-status/SKILL.md +2 -2
  78. package/skills/ultra-test/SKILL.md +4 -2
  79. package/skills/ultra-think/SKILL.md +7 -7
  80. package/skills/ultra-think/references/interaction-boundary.md +56 -0
  81. package/spec/cli-protocol.md +80 -199
  82. package/spec/mcp-tools.yaml +37 -2804
  83. package/spec/schemas/state-db.sql +107 -4
  84. package/spec/schemas/{task-ledger.v1.schema.json → task-ledger.v2.schema.json} +70 -6
  85. package/ultra-tools/commands/status.cjs +369 -243
  86. package/mcp-server/lib/context-spine.cjs +0 -1208
  87. package/mcp-server/lib/decision-dialogue.cjs +0 -1128
  88. package/mcp-server/lib/project-breadcrumb.cjs +0 -173
  89. package/mcp-server/lib/spec-learning.cjs +0 -284
  90. package/mcp-server/lib/workflow-state.cjs +0 -3129
  91. package/skills/ultra-think/references/decision-dialogue.md +0 -155
@@ -5,10 +5,15 @@ const fs = require('node:fs');
5
5
  const path = require('node:path');
6
6
  const util = require('node:util');
7
7
 
8
+ const canonical = require('./canonical-json.cjs');
9
+ const decisionRecords = require('./decision-records.cjs');
8
10
  const ops = require('./state-ops.cjs');
11
+ const { readStableProjectFile } = require('./safe-project-file.cjs');
12
+ const stageCheckpoints = require('./stage-checkpoints.cjs');
9
13
 
10
14
  const LEDGER_KIND = 'ultra-team-task-ledger';
11
- const LEDGER_SCHEMA_VERSION = '1.0';
15
+ const LEDGER_SCHEMA_VERSION = '2.0';
16
+ const LEGACY_LEDGER_SCHEMA_VERSION = '1.0';
12
17
  const LEDGER_RELATIVE_PATH = '.ultra/tasks/tasks.json';
13
18
  const HASH_PATTERN = /^[0-9a-f]{64}$/;
14
19
  const MAX_ANCESTORS = 64;
@@ -33,11 +38,16 @@ const DURABLE_BASELINE_FIELDS = Object.freeze([
33
38
  'gaps',
34
39
  'classification',
35
40
  'provider_refs',
36
- 'research_run_id',
41
+ 'research_checkpoint_id',
37
42
  'approved_by',
38
43
  'approval_note',
39
44
  'converged_at',
40
45
  ]);
46
+ const LEGACY_DURABLE_BASELINE_FIELDS = Object.freeze(
47
+ DURABLE_BASELINE_FIELDS.map((field) => (
48
+ field === 'research_checkpoint_id' ? 'research_run_id' : field
49
+ )),
50
+ );
41
51
  const DURABLE_TASK_FIELDS = Object.freeze([
42
52
  'id',
43
53
  'title',
@@ -77,6 +87,27 @@ const DURABLE_CHANGE_FIELDS = Object.freeze([
77
87
  'artifact_root',
78
88
  'closed_at',
79
89
  ]);
90
+ const DURABLE_DECISION_FIELDS = Object.freeze([
91
+ 'id',
92
+ 'scope_type',
93
+ 'scope_id',
94
+ 'selection',
95
+ 'effects',
96
+ 'status',
97
+ 'artifact_path',
98
+ 'artifact_digest',
99
+ 'supersedes_id',
100
+ ]);
101
+ const DURABLE_CHECKPOINT_FIELDS = Object.freeze([
102
+ 'stage',
103
+ 'scope_type',
104
+ 'scope_id',
105
+ 'revision',
106
+ 'payload',
107
+ 'evidence',
108
+ 'diagnostics',
109
+ 'status',
110
+ ]);
80
111
 
81
112
  class TaskLedgerError extends Error {
82
113
  constructor(code, message, details) {
@@ -166,7 +197,7 @@ function portableBaseline(row) {
166
197
  ? row.classification : parseJson(row.classification_json, {})],
167
198
  ['provider_refs', row.provider_refs && typeof row.provider_refs === 'object'
168
199
  ? row.provider_refs : parseJson(row.provider_refs_json, {})],
169
- ['research_run_id', row.research_run_id],
200
+ ['research_checkpoint_id', row.research_checkpoint_id || row.research_run_id],
170
201
  ['approved_by', row.approved_by],
171
202
  ['approval_note', row.approval_note],
172
203
  ['converged_at', row.converged_at],
@@ -201,7 +232,7 @@ function portableChange(row) {
201
232
  ['id', row.id],
202
233
  ['title', row.title],
203
234
  ['kind', row.kind],
204
- ['status', row.status],
235
+ ['status', ['blocked', 'ready'].includes(row.status) ? 'active' : row.status],
205
236
  ['intent', row.intent],
206
237
  ['docs_impact', parseJson(row.docs_impact_json, {})],
207
238
  ['provider_refs', parseJson(row.provider_refs_json, {})],
@@ -243,6 +274,57 @@ function readChangesForLedger(db, taskRows, previousChanges = new Map()) {
243
274
  return rows.map((row) => durableChange(row, previousChanges.get(row.id)));
244
275
  }
245
276
 
277
+ function durableDecision(row) {
278
+ const value = compactObject([
279
+ ['id', row.id],
280
+ ['scope_type', row.scope_type],
281
+ ['scope_id', row.scope_id],
282
+ ['selection', row.selection],
283
+ ['effects', parseJson(row.effects_json, {})],
284
+ ['status', row.status],
285
+ ['artifact_path', row.artifact_path],
286
+ ['artifact_digest', row.digest],
287
+ ['supersedes_id', row.supersedes_id],
288
+ ]);
289
+ return {
290
+ ...value,
291
+ digest: sha256(value),
292
+ };
293
+ }
294
+
295
+ function readDecisionsForLedger(db) {
296
+ return db.prepare(
297
+ `SELECT * FROM decision_records
298
+ WHERE status IN ('accepted', 'superseded')
299
+ ORDER BY scope_type, scope_id, created_at, id`,
300
+ ).all().map(durableDecision);
301
+ }
302
+
303
+ function durableCheckpoint(row) {
304
+ const value = compactObject([
305
+ ['stage', row.stage],
306
+ ['scope_type', row.scope_type],
307
+ ['scope_id', row.scope_id],
308
+ ['revision', row.revision],
309
+ ['payload', parseJson(row.payload_json, {})],
310
+ ['evidence', parseJson(row.evidence_json, [])],
311
+ ['diagnostics', parseJson(row.diagnostics_json, [])],
312
+ ['status', row.status],
313
+ ]);
314
+ return {
315
+ ...value,
316
+ digest: sha256(value),
317
+ };
318
+ }
319
+
320
+ function readCheckpointsForLedger(db) {
321
+ return db.prepare(
322
+ `SELECT * FROM stage_checkpoints
323
+ WHERE status = 'accepted'
324
+ ORDER BY stage, scope_type, scope_id, revision`,
325
+ ).all().map(durableCheckpoint);
326
+ }
327
+
246
328
  function durableStatus(task, previous) {
247
329
  if (task.status !== 'in_progress') return task.status;
248
330
  if (previous && previous.status !== 'in_progress') return previous.status;
@@ -283,10 +365,18 @@ function durableTask(task, previous = null) {
283
365
  };
284
366
  }
285
367
 
286
- function statePayload({ baseline, changes, tasks }) {
368
+ function statePayload({
369
+ baseline,
370
+ changes,
371
+ decisions,
372
+ checkpoints,
373
+ tasks,
374
+ }) {
287
375
  return {
288
376
  baseline: baseline || null,
289
377
  changes: Array.isArray(changes) ? changes : [],
378
+ decisions: Array.isArray(decisions) ? decisions : [],
379
+ checkpoints: Array.isArray(checkpoints) ? checkpoints : [],
290
380
  tasks: Array.isArray(tasks) ? tasks : [],
291
381
  };
292
382
  }
@@ -353,6 +443,38 @@ function validateBaseline(entry) {
353
443
  }
354
444
  }
355
445
 
446
+ function validateLegacyBaseline(entry) {
447
+ if (entry == null) return;
448
+ if (typeof entry !== 'object' || Array.isArray(entry)) {
449
+ throw new TaskLedgerError('TASK_LEDGER_INVALID', 'baseline ledger entry must be an object');
450
+ }
451
+ for (const field of [
452
+ 'id', 'project_name', 'mode', 'status', 'scope', 'revision', 'digest',
453
+ ]) {
454
+ if (entry[field] === undefined || entry[field] === null || entry[field] === '') {
455
+ throw new TaskLedgerError(
456
+ 'TASK_LEDGER_INVALID',
457
+ `baseline ledger entry ${entry.id || '(unknown)'} is missing ${field}`,
458
+ );
459
+ }
460
+ }
461
+ const value = Object.fromEntries(LEGACY_DURABLE_BASELINE_FIELDS
462
+ .filter((field) => entry[field] !== undefined)
463
+ .map((field) => [field, entry[field]]));
464
+ if (!HASH_PATTERN.test(entry.digest) || entry.digest !== sha256(value)) {
465
+ throw new TaskLedgerError(
466
+ 'TASK_LEDGER_DIGEST_MISMATCH',
467
+ `baseline ledger entry ${entry.id} does not match its v1 digest`,
468
+ );
469
+ }
470
+ if (entry.parent_digest != null && !HASH_PATTERN.test(entry.parent_digest)) {
471
+ throw new TaskLedgerError(
472
+ 'TASK_LEDGER_INVALID',
473
+ `baseline ledger entry ${entry.id} has an invalid parent_digest`,
474
+ );
475
+ }
476
+ }
477
+
356
478
  function validateChange(entry) {
357
479
  if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
358
480
  throw new TaskLedgerError('TASK_LEDGER_INVALID', 'every Change ledger entry must be an object');
@@ -384,7 +506,100 @@ function validateChange(entry) {
384
506
  }
385
507
  }
386
508
 
509
+ function validateDecision(entry) {
510
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
511
+ throw new TaskLedgerError('TASK_LEDGER_INVALID', 'every decision entry must be an object');
512
+ }
513
+ for (const field of [
514
+ 'id', 'scope_type', 'scope_id', 'selection', 'status',
515
+ 'artifact_path', 'artifact_digest', 'digest',
516
+ ]) {
517
+ if (entry[field] === undefined || entry[field] === null || entry[field] === '') {
518
+ throw new TaskLedgerError(
519
+ 'TASK_LEDGER_INVALID',
520
+ `decision entry ${entry.id || '(unknown)'} is missing ${field}`,
521
+ );
522
+ }
523
+ }
524
+ const value = Object.fromEntries(DURABLE_DECISION_FIELDS
525
+ .filter((field) => entry[field] !== undefined)
526
+ .map((field) => [field, entry[field]]));
527
+ if (!HASH_PATTERN.test(entry.artifact_digest)
528
+ || !HASH_PATTERN.test(entry.digest)
529
+ || entry.digest !== sha256(value)) {
530
+ throw new TaskLedgerError(
531
+ 'TASK_LEDGER_DIGEST_MISMATCH',
532
+ `decision entry ${entry.id} does not match its digest`,
533
+ );
534
+ }
535
+ }
536
+
537
+ function validateCheckpoint(entry) {
538
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
539
+ throw new TaskLedgerError('TASK_LEDGER_INVALID', 'every checkpoint entry must be an object');
540
+ }
541
+ for (const field of [
542
+ 'stage', 'scope_type', 'scope_id', 'revision', 'payload',
543
+ 'evidence', 'diagnostics', 'status', 'digest',
544
+ ]) {
545
+ if (entry[field] === undefined || entry[field] === null) {
546
+ throw new TaskLedgerError(
547
+ 'TASK_LEDGER_INVALID',
548
+ `checkpoint entry ${entry.stage || '(unknown)'} is missing ${field}`,
549
+ );
550
+ }
551
+ }
552
+ const value = Object.fromEntries(DURABLE_CHECKPOINT_FIELDS
553
+ .filter((field) => entry[field] !== undefined)
554
+ .map((field) => [field, entry[field]]));
555
+ if (entry.status !== 'accepted'
556
+ || !HASH_PATTERN.test(entry.digest)
557
+ || entry.digest !== sha256(value)) {
558
+ throw new TaskLedgerError(
559
+ 'TASK_LEDGER_DIGEST_MISMATCH',
560
+ `checkpoint ${entry.stage}:${entry.scope_type}:${entry.scope_id} is invalid`,
561
+ );
562
+ }
563
+ }
564
+
565
+ function validateLegacyLedger(document, file) {
566
+ if (!document || document.kind !== LEDGER_KIND
567
+ || document.schema_version !== LEGACY_LEDGER_SCHEMA_VERSION
568
+ || !Array.isArray(document.tasks)
569
+ || !Array.isArray(document.changes)
570
+ || !Array.isArray(document.ancestors)) {
571
+ return false;
572
+ }
573
+ validateLegacyBaseline(document.baseline);
574
+ document.tasks.forEach(validateTask);
575
+ document.changes.forEach(validateChange);
576
+ const expected = sha256({
577
+ baseline: document.baseline || null,
578
+ changes: document.changes,
579
+ tasks: document.tasks,
580
+ });
581
+ if (!HASH_PATTERN.test(document.state_digest || '')
582
+ || document.state_digest !== expected) {
583
+ throw new TaskLedgerError(
584
+ 'TASK_LEDGER_DIGEST_MISMATCH',
585
+ `${file} legacy state_digest does not match its durable payload`,
586
+ );
587
+ }
588
+ return true;
589
+ }
590
+
387
591
  function validateLedger(document, file = '(memory)') {
592
+ if (document?.schema_version === LEGACY_LEDGER_SCHEMA_VERSION
593
+ && validateLegacyLedger(document, file)) {
594
+ throw new TaskLedgerError(
595
+ 'TASK_LEDGER_SCHEMA_MIGRATION_REQUIRED',
596
+ `${file} is a valid v${LEGACY_LEDGER_SCHEMA_VERSION} ledger and must migrate to v${LEDGER_SCHEMA_VERSION}`,
597
+ {
598
+ from: LEGACY_LEDGER_SCHEMA_VERSION,
599
+ to: LEDGER_SCHEMA_VERSION,
600
+ },
601
+ );
602
+ }
388
603
  if (!document || typeof document !== 'object' || Array.isArray(document)
389
604
  || document.kind !== LEDGER_KIND
390
605
  || document.schema_version !== LEDGER_SCHEMA_VERSION
@@ -392,7 +607,9 @@ function validateLedger(document, file = '(memory)') {
392
607
  || document.generation < 0
393
608
  || !Array.isArray(document.ancestors)
394
609
  || !Array.isArray(document.tasks)
395
- || !Array.isArray(document.changes)) {
610
+ || !Array.isArray(document.changes)
611
+ || !Array.isArray(document.decisions)
612
+ || !Array.isArray(document.checkpoints)) {
396
613
  throw new TaskLedgerError(
397
614
  'TASK_LEDGER_INVALID',
398
615
  `${file} is not an Ultra team task ledger v${LEDGER_SCHEMA_VERSION}`,
@@ -425,6 +642,29 @@ function validateLedger(document, file = '(memory)') {
425
642
  }
426
643
  seenChanges.add(change.id);
427
644
  }
645
+ const seenDecisions = new Set();
646
+ for (const decision of document.decisions) {
647
+ validateDecision(decision);
648
+ if (seenDecisions.has(decision.id)) {
649
+ throw new TaskLedgerError(
650
+ 'TASK_LEDGER_INVALID',
651
+ `${file} contains duplicate decision ${decision.id}`,
652
+ );
653
+ }
654
+ seenDecisions.add(decision.id);
655
+ }
656
+ const seenCheckpoints = new Set();
657
+ for (const checkpoint of document.checkpoints) {
658
+ validateCheckpoint(checkpoint);
659
+ const key = `${checkpoint.stage}:${checkpoint.scope_type}:${checkpoint.scope_id}`;
660
+ if (seenCheckpoints.has(key)) {
661
+ throw new TaskLedgerError(
662
+ 'TASK_LEDGER_INVALID',
663
+ `${file} contains multiple accepted checkpoints for ${key}`,
664
+ );
665
+ }
666
+ seenCheckpoints.add(key);
667
+ }
428
668
  const expected = sha256(statePayload(document));
429
669
  if (!HASH_PATTERN.test(document.state_digest || '') || document.state_digest !== expected) {
430
670
  throw new TaskLedgerError(
@@ -556,10 +796,56 @@ function migrateLegacyProjection(db, rootDir, originalError) {
556
796
  return { previous: null, migrated: true, backup };
557
797
  }
558
798
 
799
+ function migrateLegacyTeamLedger(rootDir, originalError) {
800
+ const file = ledgerPath(rootDir);
801
+ let bytes;
802
+ let document;
803
+ try {
804
+ bytes = fs.readFileSync(file);
805
+ document = JSON.parse(bytes.toString('utf8'));
806
+ if (!validateLegacyLedger(document, file)) throw originalError;
807
+ } catch (error) {
808
+ if (error instanceof TaskLedgerError) throw error;
809
+ throw originalError;
810
+ }
811
+ const digest = sha256(bytes);
812
+ const backupDir = safeDirectoryChain(
813
+ rootDir,
814
+ ['.ultra', '.runtime', 'backups', 'task-ledger'],
815
+ { create: true },
816
+ );
817
+ const backup = path.join(backupDir, `team-ledger-v1-${digest.slice(0, 16)}.json`);
818
+ if (fs.existsSync(backup)) {
819
+ const stat = fs.lstatSync(backup);
820
+ if (stat.isSymbolicLink() || !stat.isFile()
821
+ || !fs.readFileSync(backup).equals(bytes)) {
822
+ throw new TaskLedgerError(
823
+ 'TASK_LEDGER_UNSAFE',
824
+ `legacy team ledger backup is unsafe or conflicting: ${backup}`,
825
+ );
826
+ }
827
+ } else {
828
+ fs.writeFileSync(backup, bytes, { flag: 'wx' });
829
+ }
830
+ return {
831
+ previous: {
832
+ ...document,
833
+ decisions: [],
834
+ checkpoints: [],
835
+ },
836
+ migrated: true,
837
+ backup,
838
+ migration_kind: 'team-ledger-v1',
839
+ };
840
+ }
841
+
559
842
  function priorLedgerForPublish(db, rootDir) {
560
843
  try {
561
844
  return { previous: readTaskLedger(rootDir), migrated: false, backup: null };
562
845
  } catch (error) {
846
+ if (error.code === 'TASK_LEDGER_SCHEMA_MIGRATION_REQUIRED') {
847
+ return migrateLegacyTeamLedger(rootDir, error);
848
+ }
563
849
  if (!['TASK_LEDGER_INVALID', 'TASK_LEDGER_DIGEST_MISMATCH'].includes(error.code)) {
564
850
  throw error;
565
851
  }
@@ -572,6 +858,9 @@ function syncTaskLedger(db, { rootDir = '.' } = {}) {
572
858
  try {
573
859
  document = readTaskLedger(rootDir);
574
860
  } catch (error) {
861
+ if (error.code === 'TASK_LEDGER_SCHEMA_MIGRATION_REQUIRED') {
862
+ return { status: 'legacy', imported: 0, already_current: false };
863
+ }
575
864
  if (!['TASK_LEDGER_INVALID', 'TASK_LEDGER_DIGEST_MISMATCH'].includes(error.code)) {
576
865
  throw error;
577
866
  }
@@ -598,6 +887,10 @@ function syncTaskLedger(db, { rootDir = '.' } = {}) {
598
887
  deleted: 0,
599
888
  imported_changes: 0,
600
889
  preserved_changes: document.changes.length,
890
+ imported_decisions: 0,
891
+ preserved_decisions: document.decisions.length,
892
+ imported_checkpoints: 0,
893
+ preserved_checkpoints: document.checkpoints.length,
601
894
  imported_baseline: false,
602
895
  preserved_baseline: Boolean(document.baseline),
603
896
  generation: document.generation,
@@ -617,6 +910,13 @@ function taskDigestMap(tasks) {
617
910
  return Object.fromEntries(tasks.map((task) => [task.id, task.digest]));
618
911
  }
619
912
 
913
+ function checkpointDigestMap(checkpoints) {
914
+ return Object.fromEntries(checkpoints.map((checkpoint) => [
915
+ `${checkpoint.stage}:${checkpoint.scope_type}:${checkpoint.scope_id}`,
916
+ checkpoint.digest,
917
+ ]));
918
+ }
919
+
620
920
  function recordLedgerEvent(db, type, ledger, extra = {}) {
621
921
  ops.appendEvent(db, {
622
922
  type,
@@ -627,6 +927,8 @@ function recordLedgerEvent(db, type, ledger, extra = {}) {
627
927
  baseline_checkpoint: ledger.baseline || null,
628
928
  task_digests: taskDigestMap(ledger.tasks),
629
929
  change_digests: taskDigestMap(ledger.changes),
930
+ decision_digests: taskDigestMap(ledger.decisions || []),
931
+ checkpoint_digests: checkpointDigestMap(ledger.checkpoints || []),
630
932
  ...extra,
631
933
  },
632
934
  });
@@ -662,7 +964,15 @@ function publishTaskLedger(db, {
662
964
  );
663
965
  }
664
966
  const changes = readChangesForLedger(db, rows, priorChanges);
665
- const digest = sha256(statePayload({ baseline, changes, tasks }));
967
+ const decisions = readDecisionsForLedger(db);
968
+ const checkpoints = readCheckpointsForLedger(db);
969
+ const digest = sha256(statePayload({
970
+ baseline,
971
+ changes,
972
+ decisions,
973
+ checkpoints,
974
+ tasks,
975
+ }));
666
976
  if (previous?.state_digest === digest) {
667
977
  return {
668
978
  changed: false,
@@ -683,6 +993,8 @@ function publishTaskLedger(db, {
683
993
  reason,
684
994
  baseline,
685
995
  changes,
996
+ decisions,
997
+ checkpoints,
686
998
  tasks,
687
999
  };
688
1000
  validateLedger(document);
@@ -717,6 +1029,13 @@ function latestImportedCheckpoint(db) {
717
1029
  change_digests: payload.change_digests && typeof payload.change_digests === 'object'
718
1030
  ? payload.change_digests
719
1031
  : {},
1032
+ decision_digests: payload.decision_digests && typeof payload.decision_digests === 'object'
1033
+ ? payload.decision_digests
1034
+ : {},
1035
+ checkpoint_digests: payload.checkpoint_digests
1036
+ && typeof payload.checkpoint_digests === 'object'
1037
+ ? payload.checkpoint_digests
1038
+ : {},
720
1039
  baseline_digest: HASH_PATTERN.test(payload.baseline_digest || '')
721
1040
  ? payload.baseline_digest
722
1041
  : null,
@@ -750,7 +1069,7 @@ function baselineForLocalCheckout(baseline) {
750
1069
  worktree_accepted: false,
751
1070
  known_red_accepted: false,
752
1071
  gaps,
753
- research_run_id: undefined,
1072
+ research_checkpoint_id: undefined,
754
1073
  approved_by: undefined,
755
1074
  approval_note: undefined,
756
1075
  converged_at: undefined,
@@ -770,7 +1089,8 @@ function writeBaseline(db, baseline, { update = false } = {}) {
770
1089
  worktree_state = ?, worktree_digest = ?, worktree_accepted = ?,
771
1090
  known_red_accepted = ?, spec_refs_json = ?, evidence_json = ?,
772
1091
  verification_json = ?, unknowns_json = ?, gaps_json = ?,
773
- classification_json = ?, provider_refs_json = ?, research_run_id = ?,
1092
+ classification_json = ?, provider_refs_json = ?,
1093
+ research_checkpoint_id = ?, research_run_id = NULL,
774
1094
  approved_by = ?, approval_note = ?, converged_at = ?, updated_at = ?
775
1095
  WHERE id = ?`,
776
1096
  ).run(
@@ -793,7 +1113,7 @@ function writeBaseline(db, baseline, { update = false } = {}) {
793
1113
  JSON.stringify(value.gaps || []),
794
1114
  JSON.stringify(value.classification || {}),
795
1115
  JSON.stringify(value.provider_refs || {}),
796
- value.research_run_id || null,
1116
+ value.research_checkpoint_id || null,
797
1117
  value.approved_by || null,
798
1118
  value.approval_note || null,
799
1119
  value.converged_at || null,
@@ -808,8 +1128,9 @@ function writeBaseline(db, baseline, { update = false } = {}) {
808
1128
  repository_revision, repository_branch, worktree_state, worktree_digest,
809
1129
  worktree_accepted, known_red_accepted, spec_refs_json, evidence_json,
810
1130
  verification_json, unknowns_json, gaps_json, classification_json,
811
- provider_refs_json, research_run_id, approved_by, approval_note, converged_at)
812
- VALUES (?, ?, ?, ?, ?, ?, '.', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1131
+ provider_refs_json, research_checkpoint_id, research_run_id,
1132
+ approved_by, approval_note, converged_at)
1133
+ VALUES (?, ?, ?, ?, ?, ?, '.', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?)`,
813
1134
  ).run(
814
1135
  value.id,
815
1136
  value.project_name,
@@ -831,7 +1152,7 @@ function writeBaseline(db, baseline, { update = false } = {}) {
831
1152
  JSON.stringify(value.gaps || []),
832
1153
  JSON.stringify(value.classification || {}),
833
1154
  JSON.stringify(value.provider_refs || {}),
834
- value.research_run_id || null,
1155
+ value.research_checkpoint_id || null,
835
1156
  value.approved_by || null,
836
1157
  value.approval_note || null,
837
1158
  value.converged_at || null,
@@ -1034,6 +1355,166 @@ function updateTaskFromLedger(db, task) {
1034
1355
  );
1035
1356
  }
1036
1357
 
1358
+ function decisionDocument(rootDir, entry) {
1359
+ const read = readStableProjectFile(rootDir, entry.artifact_path, { encoding: 'utf8' });
1360
+ let document;
1361
+ try { document = JSON.parse(read.text); }
1362
+ catch (error) {
1363
+ throw new TaskLedgerError(
1364
+ 'TASK_LEDGER_DECISION_INVALID',
1365
+ `decision artifact ${entry.artifact_path} is invalid JSON: ${error.message}`,
1366
+ );
1367
+ }
1368
+ const { digest, ...value } = document;
1369
+ if (digest !== entry.artifact_digest || canonical.digest(value) !== digest) {
1370
+ throw new TaskLedgerError(
1371
+ 'TASK_LEDGER_DECISION_CONFLICT',
1372
+ `decision artifact ${entry.artifact_path} does not match the team ledger`,
1373
+ {
1374
+ decision_id: entry.id,
1375
+ ledger_digest: entry.artifact_digest,
1376
+ artifact_digest: digest || null,
1377
+ },
1378
+ );
1379
+ }
1380
+ return document;
1381
+ }
1382
+
1383
+ function importDecisionEntry(db, rootDir, entry) {
1384
+ const existing = decisionRecords.readDecision(db, entry.id);
1385
+ if (existing) {
1386
+ if (existing.digest !== entry.artifact_digest) {
1387
+ throw new TaskLedgerError(
1388
+ 'TASK_LEDGER_DECISION_CONFLICT',
1389
+ `decision ${entry.id} changed both locally and in the team ledger`,
1390
+ );
1391
+ }
1392
+ return false;
1393
+ }
1394
+ const document = decisionDocument(rootDir, entry);
1395
+ const accepted = decisionRecords.acceptDecision(db, {
1396
+ id: document.id,
1397
+ scope: document.scope.type === 'baseline'
1398
+ ? { baseline_id: document.scope.id }
1399
+ : { change_id: document.scope.id },
1400
+ question: document.question,
1401
+ recommendation: document.recommendation,
1402
+ selection: document.selection,
1403
+ effects: document.effects,
1404
+ non_goals: document.non_goals,
1405
+ owner: document.owner,
1406
+ source: document.source,
1407
+ provenance: document.provenance,
1408
+ applied_refs: document.applied_refs,
1409
+ supersedes_id: document.supersedes_id || undefined,
1410
+ }, { rootDir });
1411
+ if (accepted.digest !== entry.artifact_digest) {
1412
+ throw new TaskLedgerError(
1413
+ 'TASK_LEDGER_DECISION_CONFLICT',
1414
+ `decision ${entry.id} changed while it was imported`,
1415
+ );
1416
+ }
1417
+ return true;
1418
+ }
1419
+
1420
+ function mergeTeamDecisions(db, rootDir, entries) {
1421
+ const pending = new Map(entries.map((entry) => [entry.id, entry]));
1422
+ let imported = 0;
1423
+ while (pending.size > 0) {
1424
+ let progressed = false;
1425
+ for (const [id, entry] of pending) {
1426
+ if (entry.supersedes_id
1427
+ && pending.has(entry.supersedes_id)
1428
+ && !decisionRecords.readDecision(db, entry.supersedes_id)) {
1429
+ continue;
1430
+ }
1431
+ if (importDecisionEntry(db, rootDir, entry)) imported += 1;
1432
+ pending.delete(id);
1433
+ progressed = true;
1434
+ }
1435
+ if (!progressed) {
1436
+ throw new TaskLedgerError(
1437
+ 'TASK_LEDGER_DECISION_CONFLICT',
1438
+ 'decision supersession graph cannot be imported deterministically',
1439
+ { decision_ids: [...pending.keys()].sort() },
1440
+ );
1441
+ }
1442
+ }
1443
+ return imported;
1444
+ }
1445
+
1446
+ function checkpointKey(entry) {
1447
+ return `${entry.stage}:${entry.scope_type}:${entry.scope_id}`;
1448
+ }
1449
+
1450
+ function mergeTeamCheckpoints(db, entries, lastDigests = {}) {
1451
+ let imported = 0;
1452
+ for (const entry of entries) {
1453
+ const current = db.prepare(
1454
+ `SELECT * FROM stage_checkpoints
1455
+ WHERE stage = ? AND scope_type = ? AND scope_id = ? AND status = 'accepted'
1456
+ ORDER BY revision DESC LIMIT 1`,
1457
+ ).get(entry.stage, entry.scope_type, entry.scope_id);
1458
+ const currentDurable = current ? durableCheckpoint(current) : null;
1459
+ if (currentDurable?.digest === entry.digest) continue;
1460
+ const key = checkpointKey(entry);
1461
+ const last = lastDigests[key] || null;
1462
+ if (currentDurable && (!last || currentDurable.digest !== last)) {
1463
+ throw new TaskLedgerError(
1464
+ 'TASK_LEDGER_CHECKPOINT_CONFLICT',
1465
+ `checkpoint ${key} changed both locally and in the team ledger`,
1466
+ {
1467
+ checkpoint: key,
1468
+ local_digest: currentDurable.digest,
1469
+ incoming_digest: entry.digest,
1470
+ last_digest: last,
1471
+ },
1472
+ );
1473
+ }
1474
+ if (current) {
1475
+ db.prepare(
1476
+ `UPDATE stage_checkpoints SET status = 'superseded',
1477
+ updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
1478
+ WHERE id = ?`,
1479
+ ).run(current.id);
1480
+ }
1481
+ const importedId = `checkpoint-team-${entry.digest.slice(0, 24)}`;
1482
+ const importedDigest = stageCheckpoints.checkpointDigest({
1483
+ stage: entry.stage,
1484
+ scope_type: entry.scope_type,
1485
+ scope_id: entry.scope_id,
1486
+ revision: entry.revision,
1487
+ payload: entry.payload,
1488
+ evidence: entry.evidence,
1489
+ diagnostics: entry.diagnostics,
1490
+ context_envelope_id: null,
1491
+ supersedes_id: current?.id || null,
1492
+ });
1493
+ db.prepare(
1494
+ `INSERT INTO stage_checkpoints
1495
+ (id, stage, scope_type, scope_id, revision, status, payload_json,
1496
+ evidence_json, diagnostics_json, context_envelope_id, digest,
1497
+ supersedes_id, idempotency_key, accepted_at)
1498
+ VALUES (?, ?, ?, ?, ?, 'accepted', ?, ?, ?, NULL, ?, ?, ?,
1499
+ strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`,
1500
+ ).run(
1501
+ importedId,
1502
+ entry.stage,
1503
+ entry.scope_type,
1504
+ entry.scope_id,
1505
+ entry.revision,
1506
+ JSON.stringify(entry.payload),
1507
+ JSON.stringify(entry.evidence),
1508
+ JSON.stringify(entry.diagnostics),
1509
+ importedDigest,
1510
+ current?.id || null,
1511
+ `team-import:${entry.digest}`,
1512
+ );
1513
+ imported += 1;
1514
+ }
1515
+ return imported;
1516
+ }
1517
+
1037
1518
  function importTaskLedger(db, {
1038
1519
  rootDir = '.',
1039
1520
  } = {}) {
@@ -1073,6 +1554,10 @@ function importTaskLedger(db, {
1073
1554
  deleted: 0,
1074
1555
  imported_changes: 0,
1075
1556
  preserved_changes: document.changes.length,
1557
+ imported_decisions: 0,
1558
+ preserved_decisions: document.decisions.length,
1559
+ imported_checkpoints: 0,
1560
+ preserved_checkpoints: document.checkpoints.length,
1076
1561
  imported_baseline: false,
1077
1562
  preserved_baseline: Boolean(document.baseline),
1078
1563
  generation: document.generation,
@@ -1089,6 +1574,8 @@ function importTaskLedger(db, {
1089
1574
  let deleted = 0;
1090
1575
  let importedChanges = 0;
1091
1576
  let preservedChanges = 0;
1577
+ let importedDecisions = 0;
1578
+ let importedCheckpoints = 0;
1092
1579
  let baselineImport = {
1093
1580
  imported: false,
1094
1581
  preserved: false,
@@ -1242,12 +1729,20 @@ function importTaskLedger(db, {
1242
1729
  }
1243
1730
  db.prepare('UPDATE tasks SET parent_id = ? WHERE id = ?').run(parentId, id);
1244
1731
  }
1732
+ importedDecisions = mergeTeamDecisions(db, rootDir, document.decisions);
1733
+ importedCheckpoints = mergeTeamCheckpoints(
1734
+ db,
1735
+ document.checkpoints,
1736
+ checkpoint.checkpoint_digests,
1737
+ );
1245
1738
  recordLedgerEvent(db, 'task_ledger_imported', document, {
1246
1739
  imported,
1247
1740
  preserved,
1248
1741
  deleted,
1249
1742
  imported_changes: importedChanges,
1250
1743
  preserved_changes: preservedChanges,
1744
+ imported_decisions: importedDecisions,
1745
+ imported_checkpoints: importedCheckpoints,
1251
1746
  });
1252
1747
  });
1253
1748
  return {
@@ -1256,6 +1751,10 @@ function importTaskLedger(db, {
1256
1751
  deleted,
1257
1752
  imported_changes: importedChanges,
1258
1753
  preserved_changes: preservedChanges,
1754
+ imported_decisions: importedDecisions,
1755
+ preserved_decisions: document.decisions.length - importedDecisions,
1756
+ imported_checkpoints: importedCheckpoints,
1757
+ preserved_checkpoints: document.checkpoints.length - importedCheckpoints,
1259
1758
  imported_baseline: baselineImport.imported,
1260
1759
  preserved_baseline: baselineImport.preserved,
1261
1760
  generation: document.generation,
@@ -1289,10 +1788,14 @@ function inspectTaskLedger(db, { rootDir = '.' } = {}) {
1289
1788
  localTasks,
1290
1789
  new Map(document.changes.map((change) => [change.id, change])),
1291
1790
  );
1791
+ const localDecisions = readDecisionsForLedger(db);
1792
+ const localCheckpoints = readCheckpointsForLedger(db);
1292
1793
  const localBaseline = readBaselineForLedger(db, document.baseline || null);
1293
1794
  const localDigest = sha256(statePayload({
1294
1795
  baseline: localBaseline,
1295
1796
  changes: localChanges,
1797
+ decisions: localDecisions,
1798
+ checkpoints: localCheckpoints,
1296
1799
  tasks: local,
1297
1800
  }));
1298
1801
  const baselineRequiresRevalidation = Array.isArray(localBaseline?.gaps)
@@ -1303,6 +1806,8 @@ function inspectTaskLedger(db, { rootDir = '.' } = {}) {
1303
1806
  ? sha256(statePayload({
1304
1807
  baseline: document.baseline,
1305
1808
  changes: localChanges,
1809
+ decisions: localDecisions,
1810
+ checkpoints: localCheckpoints,
1306
1811
  tasks: local,
1307
1812
  }))
1308
1813
  : null;
@@ -1324,12 +1829,16 @@ function inspectTaskLedger(db, { rootDir = '.' } = {}) {
1324
1829
  module.exports = {
1325
1830
  DURABLE_BASELINE_FIELDS,
1326
1831
  DURABLE_CHANGE_FIELDS,
1832
+ DURABLE_CHECKPOINT_FIELDS,
1833
+ DURABLE_DECISION_FIELDS,
1327
1834
  DURABLE_TASK_FIELDS,
1328
1835
  LEDGER_KIND,
1329
1836
  LEDGER_RELATIVE_PATH,
1330
1837
  LEDGER_SCHEMA_VERSION,
1331
1838
  TaskLedgerError,
1332
1839
  durableChange,
1840
+ durableCheckpoint,
1841
+ durableDecision,
1333
1842
  durableBaseline,
1334
1843
  durableTask,
1335
1844
  importTaskLedger,