ultra-builder-pro-cli 0.19.1 → 0.21.1

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 (103) hide show
  1. package/.ultra-template/docs/research/README.md +26 -5
  2. package/.ultra-template/specs/discovery.md +1 -1
  3. package/.ultra-template/tasks/tasks.json +1 -1
  4. package/CHANGELOG.md +90 -1
  5. package/README.md +62 -29
  6. package/adapters/_shared/codex-assets.cjs +69 -11
  7. package/adapters/_shared/interaction-contract.cjs +60 -5
  8. package/adapters/_shared/runtime-assets.cjs +41 -1
  9. package/adapters/claude.js +7 -5
  10. package/adapters/codex.js +1 -1
  11. package/adapters/kimi.js +2 -8
  12. package/adapters/opencode.js +30 -28
  13. package/bin/orchestrator.js +64 -24
  14. package/commands/ultra-research.md +1 -1
  15. package/docs/AGENT-CONTEXT.md +79 -45
  16. package/docs/ARCHITECTURE.md +75 -38
  17. package/docs/ARTIFACT-AUTHORITY.md +234 -0
  18. package/docs/DECISIONS.md +20 -10
  19. package/docs/LEGACY-CLI-CROSSWALK.md +15 -11
  20. package/docs/PLUGIN-ISOLATION-CONTRACT.md +4 -3
  21. package/docs/ROADMAP.md +21 -6
  22. package/docs/RUNTIME-COMPAT-MATRIX.md +30 -13
  23. package/docs/STATE-DB-ACCESS-POLICY.md +11 -9
  24. package/docs/WORKFLOW-LIFECYCLE.md +105 -54
  25. package/hooks/README.md +6 -5
  26. package/hooks/active_task_context.py +97 -12
  27. package/hooks/context_spine.py +16 -4
  28. package/hooks/health_check.py +5 -4
  29. package/hooks/pre_stop_check.py +5 -2
  30. package/hooks/runtime_paths.py +396 -0
  31. package/hooks/subagent_tracker.py +5 -2
  32. package/hooks/workflow_checkpoint.py +16 -3
  33. package/hooks/workflow_context.py +11 -3
  34. package/hooks/workflow_resume.py +7 -4
  35. package/mcp-server/breadcrumb.cjs +20 -2
  36. package/mcp-server/lib/archive-journal.cjs +1026 -111
  37. package/mcp-server/lib/archive-mutation-worker.py +365 -0
  38. package/mcp-server/lib/artifact-registry.cjs +1126 -0
  39. package/mcp-server/lib/baseline-workflow.cjs +9 -4
  40. package/mcp-server/lib/change-packet.cjs +818 -0
  41. package/mcp-server/lib/change-workflow.cjs +932 -176
  42. package/mcp-server/lib/context-paths.cjs +111 -0
  43. package/mcp-server/lib/context-spine.cjs +511 -45
  44. package/mcp-server/lib/decision-dialogue.cjs +427 -12
  45. package/mcp-server/lib/delivery-transaction.cjs +471 -0
  46. package/mcp-server/lib/doctor-backup-worker.cjs +328 -0
  47. package/mcp-server/lib/doctor.cjs +375 -12
  48. package/mcp-server/lib/git-bootstrap.cjs +459 -47
  49. package/mcp-server/lib/init-project.cjs +190 -95
  50. package/mcp-server/lib/plan-publication-worker.cjs +830 -0
  51. package/mcp-server/lib/plan-store.cjs +700 -5
  52. package/mcp-server/lib/project-breadcrumb.cjs +23 -4
  53. package/mcp-server/lib/projector.cjs +174 -40
  54. package/mcp-server/lib/runtime-paths.cjs +1687 -0
  55. package/mcp-server/lib/safe-project-file.cjs +385 -0
  56. package/mcp-server/lib/spec-learning.cjs +28 -12
  57. package/mcp-server/lib/state-db.cjs +397 -13
  58. package/mcp-server/lib/state-ops.cjs +248 -49
  59. package/mcp-server/lib/telemetry.cjs +12 -3
  60. package/mcp-server/lib/workflow-state.cjs +614 -62
  61. package/mcp-server/server.cjs +282 -33
  62. package/orchestrator/daemon.cjs +2 -10
  63. package/orchestrator/parallel-orchestrator.cjs +314 -50
  64. package/orchestrator/planner/plan-builder.cjs +1 -1
  65. package/orchestrator/recovery.cjs +125 -7
  66. package/orchestrator/session-close-journal-worker.cjs +5 -0
  67. package/orchestrator/session-close-journal.cjs +1014 -0
  68. package/orchestrator/session-runner.cjs +679 -69
  69. package/orchestrator/worktree-manager.cjs +186 -35
  70. package/package.json +2 -3
  71. package/skills/cc-collab/SKILL.md +4 -1
  72. package/skills/codex-collab/SKILL.md +5 -2
  73. package/skills/ultra-change/SKILL.md +38 -13
  74. package/skills/ultra-deliver/SKILL.md +23 -11
  75. package/skills/ultra-dev/SKILL.md +33 -6
  76. package/skills/ultra-doctor/SKILL.md +6 -2
  77. package/skills/ultra-init/SKILL.md +4 -2
  78. package/skills/ultra-plan/SKILL.md +68 -18
  79. package/skills/ultra-research/SKILL.md +45 -32
  80. package/skills/ultra-review/SKILL.md +9 -5
  81. package/skills/ultra-status/SKILL.md +9 -4
  82. package/skills/ultra-test/SKILL.md +15 -7
  83. package/skills/ultra-think/SKILL.md +6 -0
  84. package/skills/ultra-think/references/decision-dialogue.md +61 -14
  85. package/skills/ultra-verify/SKILL.md +4 -1
  86. package/spec/cli-protocol.md +19 -7
  87. package/spec/mcp-tools.yaml +531 -46
  88. package/spec/schemas/change-delta.v1.schema.json +153 -0
  89. package/spec/schemas/documentation-reconciliation.v1.schema.json +106 -0
  90. package/spec/schemas/mcp-tools.schema.json +1 -1
  91. package/spec/schemas/state-db.sql +57 -17
  92. package/spec/schemas/tasks.v4.5.schema.json +1 -1
  93. package/ultra-tools/cli.cjs +3 -2
  94. package/ultra-tools/commands/db.cjs +76 -25
  95. package/ultra-tools/commands/migrate.cjs +995 -80
  96. package/ultra-tools/commands/session.cjs +34 -8
  97. package/ultra-tools/commands/status.cjs +34 -7
  98. package/ultra-tools/commands/system.cjs +129 -22
  99. package/commands/learn.md +0 -10
  100. package/orchestrator/skill-miner.cjs +0 -133
  101. package/output-styles/coding-vibes.md +0 -67
  102. package/output-styles/structural-thinking.md +0 -61
  103. package/skills/learn/SKILL.md +0 -58
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  // MCP server (stdio JSON-RPC) exposing task.* + session.* tools backed by
4
- // .ultra/state.db. Uses the low-level Server API from
4
+ // .ultra/.runtime/state.db. Uses the low-level Server API from
5
5
  // @modelcontextprotocol/server so we can pass raw JSON Schema (Draft 2020-12)
6
6
  // straight from spec/mcp-tools.yaml without translating to zod.
7
7
 
@@ -30,10 +30,14 @@ const planStore = require('./lib/plan-store.cjs');
30
30
  const { initProject } = require('./lib/init-project.cjs');
31
31
  const baselines = require('./lib/baseline-workflow.cjs');
32
32
  const changes = require('./lib/change-workflow.cjs');
33
+ const contextSpine = require('./lib/context-spine.cjs');
33
34
  const runtimeState = require('./lib/runtime-state.cjs');
35
+ const runtimePaths = require('./lib/runtime-paths.cjs');
36
+ const gitBootstrap = require('./lib/git-bootstrap.cjs');
34
37
  const workflows = require('./lib/workflow-state.cjs');
35
38
  const decisions = require('./lib/decision-dialogue.cjs');
36
39
  const doctor = require('./lib/doctor.cjs');
40
+ const artifactRegistry = require('./lib/artifact-registry.cjs');
37
41
  const {
38
42
  readProjectBreadcrumb,
39
43
  renderProjectBreadcrumb,
@@ -85,6 +89,8 @@ const BASELINE_TOOLS = Object.freeze([
85
89
  const CHANGE_TOOLS = Object.freeze([
86
90
  'change.create',
87
91
  'change.update',
92
+ 'change.delta',
93
+ 'change.documentation_reconcile',
88
94
  'change.get',
89
95
  'change.list',
90
96
  'change.context',
@@ -100,6 +106,8 @@ const WORKFLOW_TOOLS = Object.freeze([
100
106
  'workflow.get',
101
107
  'workflow.list',
102
108
  'workflow.step',
109
+ 'workflow.revise',
110
+ 'workflow.supersede',
103
111
  'workflow.complete',
104
112
  ]);
105
113
 
@@ -112,6 +120,7 @@ const DECISION_TOOLS = Object.freeze([
112
120
  'decision.delegate',
113
121
  'decision.defer',
114
122
  'decision.supersede',
123
+ 'decision.complete',
115
124
  'decision.checkpoint',
116
125
  ]);
117
126
 
@@ -119,10 +128,16 @@ const SYSTEM_TOOLS = Object.freeze([
119
128
  'system.doctor',
120
129
  ]);
121
130
 
131
+ const ARTIFACT_TOOLS = Object.freeze([
132
+ 'artifact.record',
133
+ 'artifact.get',
134
+ ]);
135
+
122
136
  const REGISTERED_TOOLS = Object.freeze([
123
137
  ...TASK_TOOLS, ...SESSION_TOOLS, ...PLAN_TOOLS, ...BASELINE_TOOLS, ...CHANGE_TOOLS,
124
138
  ...DECISION_TOOLS,
125
139
  ...WORKFLOW_TOOLS,
140
+ ...ARTIFACT_TOOLS,
126
141
  ...SYSTEM_TOOLS,
127
142
  ]);
128
143
 
@@ -136,11 +151,14 @@ const MUTATING_TOOLS = new Set([
136
151
  'session.spawn', 'session.close', 'session.heartbeat',
137
152
  'plan.export',
138
153
  'baseline.start', 'baseline.record', 'baseline.converge',
139
- 'change.create', 'change.update', 'change.context', 'change.converge', 'change.archive',
154
+ 'change.create', 'change.update', 'change.delta', 'change.documentation_reconcile',
155
+ 'change.context', 'change.converge', 'change.archive',
140
156
  'change.learning_propose', 'change.learning_resolve',
141
157
  'decision.thread_start', 'decision.open', 'decision.resolve', 'decision.delegate',
142
- 'decision.defer', 'decision.supersede', 'decision.checkpoint',
143
- 'workflow.start', 'workflow.step', 'workflow.complete',
158
+ 'decision.defer', 'decision.supersede', 'decision.complete', 'decision.checkpoint',
159
+ 'workflow.start', 'workflow.step', 'workflow.revise', 'workflow.supersede',
160
+ 'workflow.complete',
161
+ 'artifact.record',
144
162
  ]);
145
163
 
146
164
  function loadRegisteredTools() {
@@ -259,12 +277,11 @@ async function dispatchTool(name, input, db, ctx = {}) {
259
277
  return { session };
260
278
  }
261
279
  case 'session.admission_check': {
262
- sessionRunner.assertSessionTaskReady(
280
+ return sessionRunner.admissionCheck(
263
281
  db,
264
282
  ctx.rootDir || process.cwd(),
265
283
  input.task_id,
266
284
  );
267
- return ops.admissionCheck(db, input.task_id);
268
285
  }
269
286
  case 'session.list': {
270
287
  const sessions = ops.listActiveSessions(db, { task_id: input.task_id });
@@ -409,41 +426,175 @@ async function dispatchTool(name, input, db, ctx = {}) {
409
426
  err.code = 'CHANGE_NOT_MUTABLE';
410
427
  throw err;
411
428
  }
412
- const abs = path.isAbsolute(input.out_path)
413
- ? input.out_path
414
- : path.join(rootDir, input.out_path);
415
- const tasks = ops.listTasks(db, { tag: input.tag, change_id: change.id });
429
+ const tasks = ops.listTasks(db, { change_id: change.id });
416
430
  if (tasks.length === 0) {
417
431
  const err = new Error('no tasks to plan');
418
432
  err.code = 'NO_TASKS';
419
433
  throw err;
420
434
  }
421
- const plan = planStore.buildPlan(tasks, { changeId: change.id });
422
- const { plan_path } = planStore.savePlanArtifact(plan, abs, input.format || 'json');
423
- ops.appendEvent(db, {
424
- type: 'plan_exported',
435
+ const context = contextSpine.validateContextSnapshot(db, {
425
436
  change_id: change.id,
426
- payload: {
427
- change_id: change.id, plan_path, wave_count: plan.waves.length, tag: input.tag || null,
437
+ task_id: null,
438
+ role: 'plan',
439
+ gate: 'planning',
440
+ }, { rootDir });
441
+ if (!context.snapshot || context.blockers.length > 0) {
442
+ const err = new Error('plan export requires a current planning Context Manifest');
443
+ err.code = context.snapshot ? 'PLAN_CONTEXT_STALE' : 'PLAN_CONTEXT_REQUIRED';
444
+ err.details = { blockers: context.blockers };
445
+ throw err;
446
+ }
447
+ const plan = planStore.buildPlan(tasks, { changeId: change.id });
448
+ const planRun = db.prepare(
449
+ `SELECT id FROM workflow_runs
450
+ WHERE kind = 'plan' AND change_id = ? AND task_id IS NULL
451
+ AND status IN ('active', 'blocked', 'ready')
452
+ ORDER BY started_at DESC, rowid DESC LIMIT 1`,
453
+ ).get(change.id);
454
+ const owner = { type: 'change', id: change.id };
455
+ const contextArtifact = db.prepare(
456
+ `SELECT id FROM artifacts
457
+ WHERE path = ? AND status <> 'archived'
458
+ ORDER BY updated_at DESC, rowid DESC LIMIT 1`,
459
+ ).get(context.snapshot.manifest_path);
460
+ const publication = planStore.prepareChangePlanPublication(plan, {
461
+ rootDir,
462
+ change,
463
+ tasks,
464
+ context: {
465
+ snapshot_id: context.snapshot.id,
466
+ manifest_path: context.snapshot.manifest_path,
467
+ manifest_digest: context.snapshot.manifest_hash,
428
468
  },
429
469
  });
430
- return { plan_path, wave_count: plan.waves.length };
470
+ const artifactInputs = publication.artifacts.map(({ kind, path: file, digest }) => ({
471
+ kind,
472
+ file,
473
+ digest,
474
+ input: {
475
+ owner_type: owner.type,
476
+ owner_id: owner.id,
477
+ kind,
478
+ path: path.relative(rootDir, file),
479
+ source_refs: [
480
+ { type: 'change', id: change.id, relation: 'planned_for' },
481
+ ...(contextArtifact
482
+ ? [{ type: 'artifact', id: contextArtifact.id, relation: 'compiled_from' }]
483
+ : []),
484
+ ...tasks.map((task) => ({
485
+ type: 'task', id: task.id, relation: 'compiled_from_task_contract',
486
+ })),
487
+ ],
488
+ consumer_refs: planRun
489
+ ? [{ type: 'workflow', id: planRun.id, relation: 'verified_by' }]
490
+ : [],
491
+ provenance: {
492
+ writer: 'plan.export',
493
+ workflow_run_id: planRun?.id || null,
494
+ publication_transaction_id: publication.transaction_id,
495
+ },
496
+ metadata: {
497
+ context_snapshot_id: context.snapshot.id,
498
+ context_digest: context.snapshot.manifest_hash,
499
+ terminal_role: !planRun,
500
+ },
501
+ },
502
+ }));
503
+ try {
504
+ ops.tx(db, () => {
505
+ const preflight = artifactInputs.map((artifact) => (
506
+ artifactRegistry.preflightArtifactPublication(
507
+ db, artifact.input, { rootDir },
508
+ )
509
+ ));
510
+ publication.publish();
511
+ const recorded = artifactInputs.map((artifact, index) => (
512
+ artifactRegistry.recordArtifactInTx(db, {
513
+ ...artifact.input,
514
+ id: preflight[index].artifact_id || undefined,
515
+ expected_before_digest: preflight[index].expected_before_digest,
516
+ content_digest: artifact.digest,
517
+ }, { rootDir })
518
+ ));
519
+ ops.appendEventInTx(db, {
520
+ type: 'plan_exported',
521
+ change_id: change.id,
522
+ payload: {
523
+ change_id: change.id,
524
+ plan_path: publication.plan_path,
525
+ plan_md_path: publication.plan_md_path,
526
+ publication_transaction_id: publication.transaction_id,
527
+ wave_count: plan.waves.length,
528
+ },
529
+ });
530
+ return recorded;
531
+ });
532
+ } catch (error) {
533
+ const rollback = publication.rollback();
534
+ const failure = rollback?.rolled_back === false
535
+ ? planStore.planRecoveryRequiredError(
536
+ error,
537
+ rollback,
538
+ publication.transaction_id,
539
+ )
540
+ : error;
541
+ if (failure.code === 'PLAN_RECOVERY_REQUIRED') {
542
+ const issue = failure.details?.rollback_issue
543
+ || failure.details?.issue
544
+ || {
545
+ code: failure.cause?.code || 'PLAN_RECOVERY_FAILED',
546
+ message: failure.cause?.message || failure.message,
547
+ };
548
+ ctx.markPlanRecoveryRequired?.({
549
+ recovered: 0,
550
+ finalized: 0,
551
+ pending: 1,
552
+ issues: [issue],
553
+ });
554
+ }
555
+ throw failure;
556
+ }
557
+ try {
558
+ publication.commit();
559
+ } catch (error) {
560
+ const issue = error.details?.issue || {
561
+ code: error.cause?.code || 'PLAN_RECOVERY_FAILED',
562
+ message: error.cause?.message || error.message,
563
+ };
564
+ ctx.markPlanRecoveryRequired?.({
565
+ recovered: 0,
566
+ finalized: 0,
567
+ pending: 1,
568
+ issues: [issue],
569
+ });
570
+ // The registry and target bytes committed atomically before cleanup.
571
+ // The remaining journal is still authoritative recovery work, so the
572
+ // caller must see a typed failure and later mutations must stop.
573
+ throw error;
574
+ }
575
+ return {
576
+ plan_path: publication.plan_path,
577
+ plan_md_path: publication.plan_md_path,
578
+ wave_count: plan.waves.length,
579
+ };
431
580
  }
432
581
  case 'plan.get': {
433
582
  const rootDir = ctx.rootDir || process.cwd();
434
- const loaded = planStore.loadPlanArtifact(rootDir);
583
+ const change = changes.readChange(db, input.change_id);
584
+ if (!change) {
585
+ const err = new Error(`change ${input.change_id} not found`);
586
+ err.code = 'CHANGE_NOT_FOUND';
587
+ throw err;
588
+ }
589
+ const loaded = planStore.loadChangePlanArtifact(rootDir, change, {
590
+ db,
591
+ strict: true,
592
+ });
435
593
  if (!loaded) {
436
594
  const err = new Error('no plan has been computed yet');
437
595
  err.code = 'NO_PLAN';
438
596
  throw err;
439
597
  }
440
- if (loaded.change_id !== input.change_id) {
441
- const err = new Error(
442
- `execution plan belongs to ${loaded.change_id || '(unbound)'}, not ${input.change_id}`,
443
- );
444
- err.code = 'PLAN_CHANGE_MISMATCH';
445
- throw err;
446
- }
447
598
  return { plan: planStore.selectSection(loaded, input.section) };
448
599
  }
449
600
  case 'baseline.start': {
@@ -545,6 +696,14 @@ async function dispatchTool(name, input, db, ctx = {}) {
545
696
  case 'change.archive': {
546
697
  return changes.archiveChange(db, input, { rootDir: ctx.rootDir || process.cwd() });
547
698
  }
699
+ case 'change.delta': {
700
+ return changes.recordDelta(db, input, { rootDir: ctx.rootDir || process.cwd() });
701
+ }
702
+ case 'change.documentation_reconcile': {
703
+ return changes.recordDocumentationReconciliation(
704
+ db, input, { rootDir: ctx.rootDir || process.cwd() },
705
+ );
706
+ }
548
707
  case 'decision.thread_start': {
549
708
  return { thread: decisions.startDecisionThread(db, input) };
550
709
  }
@@ -576,6 +735,13 @@ async function dispatchTool(name, input, db, ctx = {}) {
576
735
  case 'decision.supersede': {
577
736
  return { thread: decisions.supersedeDecision(db, input) };
578
737
  }
738
+ case 'decision.complete': {
739
+ return {
740
+ thread: decisions.completeDecisionThread(
741
+ db, input, { rootDir: ctx.rootDir || process.cwd() },
742
+ ),
743
+ };
744
+ }
579
745
  case 'decision.checkpoint': {
580
746
  return {
581
747
  thread: decisions.checkpointDecisionThread(
@@ -614,6 +780,18 @@ async function dispatchTool(name, input, db, ctx = {}) {
614
780
  ),
615
781
  };
616
782
  }
783
+ case 'workflow.revise': {
784
+ return {
785
+ workflow: workflows.reviseWorkflow(
786
+ db, input, { rootDir: ctx.rootDir || process.cwd() },
787
+ ),
788
+ };
789
+ }
790
+ case 'workflow.supersede': {
791
+ return workflows.supersedeWorkflow(
792
+ db, input, { rootDir: ctx.rootDir || process.cwd() },
793
+ );
794
+ }
617
795
  case 'workflow.complete': {
618
796
  return {
619
797
  workflow: workflows.completeWorkflow(
@@ -621,6 +799,14 @@ async function dispatchTool(name, input, db, ctx = {}) {
621
799
  ),
622
800
  };
623
801
  }
802
+ case 'artifact.record': {
803
+ return artifactRegistry.recordArtifact(
804
+ db, input, { rootDir: ctx.rootDir || process.cwd() },
805
+ );
806
+ }
807
+ case 'artifact.get': {
808
+ return { artifact: artifactRegistry.getArtifact(db, input) };
809
+ }
624
810
  case 'system.doctor': {
625
811
  return doctor.runDoctor(db, {
626
812
  rootDir: ctx.rootDir || process.cwd(),
@@ -653,8 +839,55 @@ function startServer({
653
839
  project = projector.projectAll,
654
840
  }) {
655
841
  let db = null;
656
- const getDb = () => {
657
- if (!db) db = initStateDb(dbPath).db;
842
+ let planRecovery = null;
843
+ let authorityDbPath = path.resolve(dbPath);
844
+ const markPlanRecoveryRequired = (details) => {
845
+ planRecovery = {
846
+ recovered: Number(details?.recovered || 0),
847
+ finalized: Number(details?.finalized || 0),
848
+ pending: Math.max(1, Number(details?.pending || 0)),
849
+ issues: Array.isArray(details?.issues) ? details.issues : [],
850
+ };
851
+ };
852
+ const getDb = (toolName = null) => {
853
+ if (!db) {
854
+ const projectPaths = runtimePaths.pathsFor(rootDir);
855
+ if ([projectPaths.legacyStateDbPath, projectPaths.stateDbPath].includes(authorityDbPath)) {
856
+ // The packaged launcher has no explicit DB override. Its default path
857
+ // is the future canonical runtime location, which can legitimately be
858
+ // absent while a legacy project still owns its project-level DB. Admit and
859
+ // migrate that project before applying configured-authority binding.
860
+ authorityDbPath = runtimePaths.ensureRuntimeState(rootDir, {
861
+ admitStorageBoundary: () => gitBootstrap.ensureExistingProjectStorageBoundary(rootDir),
862
+ }).stateDbPath;
863
+ } else {
864
+ // Only a real external/task-linked override reaches binding
865
+ // validation. It may never invent or borrow project authority.
866
+ runtimePaths.locateStateDb(rootDir, {
867
+ env: { UBP_DB_PATH: authorityDbPath },
868
+ });
869
+ runtimePaths.ensureRuntimeState(rootDir, {
870
+ env: { UBP_DB_PATH: authorityDbPath },
871
+ allowConfiguredRuntimeLink: true,
872
+ migrateState: false,
873
+ admitStorageBoundary: () => gitBootstrap.ensureExistingProjectStorageBoundary(rootDir),
874
+ });
875
+ }
876
+ db = initStateDb(authorityDbPath).db;
877
+ planRecovery = planStore.recoverPlanPublications(db, { rootDir });
878
+ }
879
+ if (toolName !== 'system.doctor'
880
+ && (planRecovery?.pending > 0 || planRecovery?.issues?.length > 0)) {
881
+ planRecovery = planStore.recoverPlanPublications(db, { rootDir });
882
+ if (planRecovery.pending > 0 || planRecovery.issues.length > 0) {
883
+ const error = new Error(
884
+ 'plan publication recovery is incomplete; run system.doctor with repair=true',
885
+ );
886
+ error.code = 'PLAN_RECOVERY_REQUIRED';
887
+ error.details = planRecovery;
888
+ throw error;
889
+ }
890
+ }
658
891
  return db;
659
892
  };
660
893
 
@@ -719,14 +952,22 @@ function startServer({
719
952
  let result;
720
953
  let toolDb = null;
721
954
  try {
722
- toolDb = STATELESS_TOOLS.has(name) ? null : getDb();
955
+ toolDb = STATELESS_TOOLS.has(name) ? null : getDb(name);
723
956
  if (toolDb) assertStateAuthority(toolDb, rootDir);
724
957
  result = await dispatchTool(
725
958
  name,
726
959
  args,
727
960
  toolDb,
728
- { rootDir, sessionId, projector: project },
961
+ {
962
+ rootDir,
963
+ sessionId,
964
+ projector: project,
965
+ markPlanRecoveryRequired,
966
+ },
729
967
  );
968
+ if (name === 'system.doctor' && args.repair === true) {
969
+ planRecovery = result.repair?.plan_publications || null;
970
+ }
730
971
  } catch (err) {
731
972
  const code = err.code || (err instanceof ops.StateOpsError ? err.code : 'STATE_DB_ERROR');
732
973
  toolError = code;
@@ -785,12 +1026,18 @@ function startServer({
785
1026
  }
786
1027
 
787
1028
  async function main() {
788
- const dbPath = process.env.UBP_DB_PATH
789
- ? path.resolve(process.env.UBP_DB_PATH)
790
- : path.resolve('.ultra', 'state.db');
791
1029
  const rootDir = process.env.UBP_ROOT_DIR
792
1030
  ? path.resolve(process.env.UBP_ROOT_DIR)
793
1031
  : path.resolve('.');
1032
+ // Keep tools/list side-effect free. Root/layout validation happens inside the
1033
+ // first state-backed tool call so clients receive a typed MCP error instead
1034
+ // of a transport-level startup failure.
1035
+ const configuredDb = typeof process.env.UBP_DB_PATH === 'string'
1036
+ ? process.env.UBP_DB_PATH.trim()
1037
+ : '';
1038
+ const dbPath = configuredDb
1039
+ ? path.resolve(configuredDb)
1040
+ : runtimePaths.pathsFor(rootDir).stateDbPath;
794
1041
  const sessionId = process.env.UBP_SESSION_ID || null;
795
1042
  const handle = startServer({ dbPath, rootDir, sessionId });
796
1043
  const transport = new StdioServerTransport();
@@ -812,7 +1059,7 @@ function appendHookLifecycleEvent({ rootDir, action, hookInput = {} } = {}) {
812
1059
  throw error;
813
1060
  }
814
1061
  const root = path.resolve(rootDir || process.cwd());
815
- const dbPath = path.join(root, '.ultra', 'state.db');
1062
+ const dbPath = runtimePaths.locateStateDb(root);
816
1063
  if (!fs.existsSync(dbPath)) return { recorded: false, reason: 'STATE_DB_MISSING' };
817
1064
 
818
1065
  const db = openStateDb(dbPath);
@@ -874,11 +1121,13 @@ module.exports = {
874
1121
  BASELINE_TOOLS,
875
1122
  CHANGE_TOOLS,
876
1123
  WORKFLOW_TOOLS,
1124
+ ARTIFACT_TOOLS,
877
1125
  SYSTEM_TOOLS,
878
1126
  REGISTERED_TOOLS,
879
1127
  STATELESS_TOOLS,
880
1128
  MUTATING_TOOLS,
881
1129
  appendHookLifecycleEvent,
1130
+ findProjectRoot: runtimePaths.findProjectRoot,
882
1131
  readProjectBreadcrumb,
883
1132
  renderProjectBreadcrumb,
884
1133
  };
@@ -97,7 +97,7 @@ function runDaemon({
97
97
  const children = [];
98
98
  let bootRecovery;
99
99
  try {
100
- bootRecovery = recovery.recoverOnBoot(db);
100
+ bootRecovery = recovery.recoverOnBoot(db, { repoRoot });
101
101
  } catch (error) {
102
102
  runtimeState.recordIncident(db, {
103
103
  code: 'BOOT_RECOVERY_FAILED', severity: 'critical', retryable: true,
@@ -208,17 +208,9 @@ function runDaemon({
208
208
  runtime,
209
209
  command,
210
210
  args: commandArgs,
211
+ mark_task_started: true,
211
212
  });
212
213
  handle.task_id = task.id;
213
- try {
214
- ops.patchTask(db, task.id, { status: 'in_progress' });
215
- } catch (error) {
216
- runner.closeSession(
217
- { db, repoRoot, sid: handle.sid },
218
- { status: 'crashed', remove_worktree: false },
219
- );
220
- throw error;
221
- }
222
214
  settleExecution(handle, task);
223
215
  children.push(handle);
224
216
  } catch (err) {