ultra-builder-pro-cli 0.15.0 → 0.17.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 (71) hide show
  1. package/.ultra-template/specs/research-distillate.md +4 -3
  2. package/CHANGELOG.md +120 -1
  3. package/README.md +67 -30
  4. package/adapters/_shared/codex-assets.cjs +10 -5
  5. package/adapters/_shared/handbook.cjs +3 -2
  6. package/adapters/_shared/interaction-contract.cjs +61 -0
  7. package/adapters/claude.js +1 -1
  8. package/adapters/opencode.js +1 -1
  9. package/bin/orchestrator.js +175 -19
  10. package/commands/ultra-change.md +1 -1
  11. package/commands/ultra-deliver.md +3 -3
  12. package/commands/ultra-init.md +1 -1
  13. package/commands/ultra-plan.md +1 -1
  14. package/commands/ultra-research.md +2 -2
  15. package/commands/ultra-review.md +10 -0
  16. package/commands/ultra-status.md +1 -1
  17. package/commands/ultra-test.md +1 -1
  18. package/commands/ultra-think.md +2 -2
  19. package/docs/AGENT-CONTEXT.md +38 -18
  20. package/docs/ARCHITECTURE.md +91 -13
  21. package/docs/LEGACY-CLI-CROSSWALK.md +163 -0
  22. package/docs/PLAN.zh-CN.md +19 -12
  23. package/docs/ROADMAP.md +5 -3
  24. package/docs/RUNTIME-COMPAT-MATRIX.md +4 -4
  25. package/docs/STATE-DB-ACCESS-POLICY.md +8 -6
  26. package/docs/USER-HANDBOOK-CONTRACT.md +8 -2
  27. package/docs/WORKFLOW-LIFECYCLE.md +179 -39
  28. package/hooks/pre_stop_check.py +3 -2
  29. package/mcp-server/lib/baseline-workflow.cjs +84 -16
  30. package/mcp-server/lib/change-workflow.cjs +107 -39
  31. package/mcp-server/lib/context-spine.cjs +215 -87
  32. package/mcp-server/lib/decision-dialogue.cjs +713 -0
  33. package/mcp-server/lib/doctor.cjs +9 -0
  34. package/mcp-server/lib/git-bootstrap.cjs +240 -0
  35. package/mcp-server/lib/init-project.cjs +178 -41
  36. package/mcp-server/lib/project-breadcrumb.cjs +23 -14
  37. package/mcp-server/lib/state-db.cjs +279 -9
  38. package/mcp-server/lib/state-ops.cjs +64 -1
  39. package/mcp-server/lib/workflow-state.cjs +313 -205
  40. package/mcp-server/server.cjs +112 -34
  41. package/orchestrator/auto-merge.cjs +19 -0
  42. package/orchestrator/daemon.cjs +104 -0
  43. package/orchestrator/parallel-orchestrator.cjs +371 -49
  44. package/orchestrator/session-runner.cjs +354 -25
  45. package/package.json +2 -1
  46. package/skills/ultra-change/SKILL.md +69 -65
  47. package/skills/ultra-deliver/SKILL.md +37 -58
  48. package/skills/ultra-dev/SKILL.md +55 -49
  49. package/skills/ultra-doctor/SKILL.md +44 -49
  50. package/skills/ultra-init/SKILL.md +78 -100
  51. package/skills/ultra-plan/SKILL.md +78 -79
  52. package/skills/ultra-research/SKILL.md +99 -98
  53. package/skills/ultra-review/SKILL.md +38 -47
  54. package/skills/ultra-review/references/review-modes.md +5 -3
  55. package/skills/ultra-review/references/unified-schema.md +26 -6
  56. package/skills/ultra-review/scripts/review_wait.py +12 -0
  57. package/skills/ultra-status/SKILL.md +34 -40
  58. package/skills/ultra-test/SKILL.md +61 -48
  59. package/skills/ultra-think/SKILL.md +47 -47
  60. package/skills/ultra-think/references/decision-dialogue.md +106 -0
  61. package/spec/cli-protocol.md +46 -2
  62. package/spec/mcp-tools.yaml +444 -29
  63. package/spec/schemas/delivery-report.v1.schema.json +1 -46
  64. package/spec/schemas/mcp-tools.schema.json +1 -1
  65. package/spec/schemas/state-db.sql +66 -2
  66. package/spec/schemas/test-report.v1.schema.json +45 -14
  67. package/ultra-tools/cli.cjs +1 -1
  68. package/ultra-tools/commands/session.cjs +10 -3
  69. package/ultra-tools/commands/status.cjs +34 -14
  70. package/ultra-tools/commands/system.cjs +2 -2
  71. package/ultra-tools/commands/task.cjs +3 -0
@@ -6,6 +6,7 @@ const path = require('node:path');
6
6
  const { spawnSync } = require('node:child_process');
7
7
  const baselines = require('./baseline-workflow.cjs');
8
8
  const workflows = require('./workflow-state.cjs');
9
+ const decisions = require('./decision-dialogue.cjs');
9
10
  const ops = require('./state-ops.cjs');
10
11
 
11
12
  const ROLES = new Set(['plan', 'implement', 'check', 'review']);
@@ -51,6 +52,10 @@ function parseJson(value, fallback = {}) {
51
52
  try { return JSON.parse(value); } catch { return fallback; }
52
53
  }
53
54
 
55
+ function uniqueStrings(values) {
56
+ return [...new Set(values.filter((value) => typeof value === 'string' && value.length > 0))];
57
+ }
58
+
54
59
  function hasIncidentBreakGlass(change) {
55
60
  const bypass = change?.baseline_bypass
56
61
  || parseJson(change?.baseline_bypass_json, null);
@@ -127,6 +132,22 @@ function taskStateDigest(task) {
127
132
  return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex');
128
133
  }
129
134
 
135
+ function changeStateDigest(change) {
136
+ if (!change) return null;
137
+ const payload = {
138
+ id: change.id,
139
+ kind: change.kind,
140
+ intent: change.intent,
141
+ docs_impact: change.docs_impact || parseJson(change.docs_impact_json, {}),
142
+ provider_refs: change.provider_refs || parseJson(change.provider_refs_json, {}),
143
+ contract: change.contract || parseJson(change.contract_json, {}),
144
+ classification: change.classification || parseJson(change.classification_json, {}),
145
+ research_disposition: change.research_disposition
146
+ || parseJson(change.research_disposition_json, {}),
147
+ };
148
+ return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex');
149
+ }
150
+
130
151
  function nonEmpty(value, field) {
131
152
  const text = typeof value === 'string' ? value.trim() : '';
132
153
  if (!text) throw new ContextSpineError('VALIDATION_ERROR', `${field} must be a non-empty string`);
@@ -290,27 +311,106 @@ function defaultGate(role) {
290
311
  }[role];
291
312
  }
292
313
 
293
- function deriveNextAction({ change, tasks, task, role, readiness, blockers = [] }) {
314
+ function deriveTransitions({
315
+ change,
316
+ tasks = [],
317
+ task,
318
+ role,
319
+ readiness,
320
+ blockers = [],
321
+ activeWorkflow = null,
322
+ deliveryReady = false,
323
+ }) {
324
+ const unique = (values) => [...new Set(values.filter(Boolean))];
325
+ const has = (prefix) => blockers.some((blocker) => blocker === prefix || blocker.startsWith(`${prefix}:`));
326
+ if (has('STATE_DB_UNREADABLE') || has('STATE_SCHEMA_INCOMPLETE') || has('STATE_AUTHORITY_CONFLICT')) {
327
+ return {
328
+ allowed_transitions: ['ultra-doctor', 'ultra-status'],
329
+ required_transition: 'ultra-doctor',
330
+ };
331
+ }
332
+ if (has('STATE_DB_MISSING') || has('BASELINE_MISSING')) {
333
+ return {
334
+ allowed_transitions: ['ultra-init', 'ultra-status'],
335
+ required_transition: 'ultra-init',
336
+ };
337
+ }
338
+ if (blockers.some((blocker) => blocker.startsWith('DECISION_'))) {
339
+ return {
340
+ allowed_transitions: ['ultra-think', 'ultra-status'],
341
+ required_transition: 'ultra-think',
342
+ };
343
+ }
344
+ if (blockers.some((blocker) => blocker.startsWith('BASELINE_SCHEMA_')
345
+ || blocker.startsWith('STATE_SCHEMA_MIGRATION_'))) {
346
+ return {
347
+ allowed_transitions: ['ultra-doctor', 'ultra-init', 'ultra-status'],
348
+ required_transition: 'ultra-doctor',
349
+ };
350
+ }
351
+
352
+ const transitions = [];
353
+ if (activeWorkflow) transitions.push(`ultra-${activeWorkflow.kind}`);
354
+ if (!change) {
355
+ transitions.push('ultra-research', 'ultra-change', 'ultra-think', 'ultra-status', 'ultra-doctor');
356
+ return { allowed_transitions: unique(transitions), required_transition: null };
357
+ }
294
358
  if (readiness === 'blocked') {
359
+ if (blockers.some((blocker) => blocker.startsWith('CONTEXT_')
360
+ || blocker.startsWith('EXECUTION_'))) {
361
+ transitions.push('change.context');
362
+ }
295
363
  if (blockers.some((blocker) => blocker.startsWith('BASELINE_'))) {
296
- return 'Complete or refresh the Ultra project baseline, then recompile change.context.';
364
+ transitions.push('ultra-research', 'ultra-doctor');
297
365
  }
298
- return 'Resolve the context readiness blockers, then recompile change.context.';
299
- }
300
- if (change.status === 'ready') return `Run ultra-deliver for change ${change.id}.`;
301
- if (change.status === 'blocked') return `Resolve the blockers for change ${change.id}, then recompile context.`;
302
- if (!task && tasks.length === 0) return `Create the first fresh-context tracer-bullet task for change ${change.id}.`;
303
- if (task?.status === 'pending') return `Start task ${task.id} through ultra-dev.`;
304
- if (task?.status === 'in_progress') {
305
- if (role === 'check') return `Run the exact verification command for task ${task.id}.`;
306
- if (role === 'review') return `Complete independent spec-fidelity and engineering-standards review for task ${task.id}.`;
307
- return `Continue the approved vertical slice for task ${task.id}.`;
308
- }
309
- if (tasks.length > 0 && tasks.every((row) => ['completed', 'expanded'].includes(row.status))) {
310
- return `Run ultra-test for change ${change.id}.`;
311
- }
312
- const pending = tasks.find((row) => row.status === 'pending');
313
- return pending ? `Compile implement context for task ${pending.id}.` : `Inspect blockers for change ${change.id}.`;
366
+ transitions.push('ultra-change', 'ultra-think', 'ultra-status');
367
+ return { allowed_transitions: unique(transitions), required_transition: null };
368
+ }
369
+ if (change.status === 'ready') {
370
+ transitions.push('ultra-deliver', 'ultra-review', 'ultra-test');
371
+ } else if (change.status === 'blocked') {
372
+ transitions.push('ultra-think', 'ultra-change');
373
+ } else if (!task && tasks.length === 0) {
374
+ transitions.push('ultra-plan', 'ultra-research', 'ultra-change');
375
+ } else if (task?.status === 'pending' || task?.status === 'in_progress') {
376
+ transitions.push('ultra-dev', 'ultra-plan');
377
+ if (role === 'review') transitions.push('ultra-review');
378
+ } else if (tasks.length > 0 && tasks.every((row) => ['completed', 'expanded'].includes(row.status))) {
379
+ transitions.push('ultra-test', 'ultra-review');
380
+ if (deliveryReady) transitions.push('ultra-deliver');
381
+ } else {
382
+ transitions.push('ultra-plan', 'ultra-change');
383
+ }
384
+ transitions.push('ultra-think', 'ultra-status');
385
+ return { allowed_transitions: unique(transitions), required_transition: null };
386
+ }
387
+
388
+ function normalizeRecommendation(value) {
389
+ if (value === undefined || value === null) return null;
390
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
391
+ throw new ContextSpineError('VALIDATION_ERROR', 'recommendation must be an object');
392
+ }
393
+ const workflow = String(value.workflow || '').trim();
394
+ const rationale = String(value.rationale || '').trim();
395
+ if (!workflow || rationale.length < 3) {
396
+ throw new ContextSpineError(
397
+ 'VALIDATION_ERROR', 'recommendation requires workflow and rationale',
398
+ );
399
+ }
400
+ return { workflow, rationale, authoritative: false };
401
+ }
402
+
403
+ function deliveryCapabilityReady(db, change, tasks, rootDir) {
404
+ if (!change || !Array.isArray(tasks) || tasks.length === 0
405
+ || !tasks.every((row) => ['completed', 'expanded'].includes(row.status))) {
406
+ return false;
407
+ }
408
+ try {
409
+ workflows.validateDeliveryPrerequisites(db, { change_id: change.id }, rootDir);
410
+ return true;
411
+ } catch {
412
+ return false;
413
+ }
314
414
  }
315
415
 
316
416
  function latestContext(db, changeId, taskId) {
@@ -327,8 +427,8 @@ function latestContext(db, changeId, taskId) {
327
427
  function compileRoleContext(db, { input, change, tasks, rootDir }) {
328
428
  if (input.next_action !== undefined) {
329
429
  throw new ContextSpineError(
330
- 'CONTEXT_NEXT_ACTION_AUTHORITY_VIOLATION',
331
- 'next_action is derived from authoritative workflow and task state',
430
+ 'CONTEXT_LEGACY_NEXT_ACTION_UNSUPPORTED',
431
+ 'next_action is not an authority field; provide a non-authoritative recommendation instead',
332
432
  );
333
433
  }
334
434
  const task = input.task_id ? tasks.find((row) => row.id === input.task_id) : null;
@@ -375,13 +475,20 @@ function compileRoleContext(db, { input, change, tasks, rootDir }) {
375
475
  }
376
476
  }
377
477
  const readiness = blockers.length === 0 ? 'ready' : 'blocked';
378
- const nextAction = deriveNextAction({
379
- change, tasks, task, role, readiness, blockers,
478
+ const control = deriveTransitions({
479
+ change,
480
+ tasks,
481
+ task,
482
+ role,
483
+ readiness,
484
+ blockers,
485
+ deliveryReady: deliveryCapabilityReady(db, change, tasks, rootDir),
380
486
  });
381
487
  return {
382
488
  role,
383
489
  gate,
384
- next_action: nextAction,
490
+ control,
491
+ recommendation: normalizeRecommendation(input.recommendation),
385
492
  readiness: { status: readiness, blockers, warnings: [...new Set(warnings)] },
386
493
  context: { items, budget, token_estimate: tokenEstimate, file_count: fileCount },
387
494
  execution_contract: executionContract,
@@ -396,6 +503,7 @@ function compileRoleContext(db, { input, change, tasks, rootDir }) {
396
503
  selected_task: task,
397
504
  resume: {
398
505
  change_id: change.id,
506
+ change_state_digest: changeStateDigest(change),
399
507
  task_id: task?.id || null,
400
508
  task_status: task?.status || null,
401
509
  task_state_digest: taskStateDigest(task),
@@ -409,15 +517,6 @@ function compileRoleContext(db, { input, change, tasks, rootDir }) {
409
517
  };
410
518
  }
411
519
 
412
- function recommendedWorkflow(change, task, tasks, readiness) {
413
- if (!change) return 'ultra-change';
414
- if (readiness !== 'ready' || change.status === 'blocked') return 'ultra-change';
415
- if (change.status === 'ready') return 'ultra-deliver';
416
- if (task && ['pending', 'in_progress'].includes(task.status)) return 'ultra-dev';
417
- if (tasks.length > 0 && tasks.every((row) => ['completed', 'expanded'].includes(row.status))) return 'ultra-test';
418
- return 'ultra-change';
419
- }
420
-
421
520
  function actionableBaselineWorkflow(db, baselineId, rootDir) {
422
521
  if (!baselineId) return null;
423
522
  const candidates = workflows.listWorkflows(db, { baseline_id: baselineId, limit: 100 }, { rootDir })
@@ -442,14 +541,23 @@ function summarizeWorkflow(run) {
442
541
  };
443
542
  }
444
543
 
445
- function workflowNextAction(run) {
446
- if (run.status === 'blocked') {
447
- return `Resolve ${run.kind} workflow blockers at ${run.current_step}: ${run.blockers.join(', ')}.`;
448
- }
449
- if (run.status === 'ready') {
450
- return `Finalize ${run.kind} workflow ${run.id} after verifying its recorded evidence and outputs.`;
451
- }
452
- return `Continue ${run.kind} workflow ${run.id} at ${run.current_step}.`;
544
+ function summarizeDecision(gate) {
545
+ if (!gate?.thread) return null;
546
+ const current = gate.current_decision;
547
+ return {
548
+ thread_id: gate.thread.id,
549
+ status: gate.thread.status,
550
+ mode: gate.thread.mode,
551
+ current: current ? {
552
+ id: current.id,
553
+ phase: current.phase,
554
+ question: current.question,
555
+ why_now: current.why_now,
556
+ recommendation: current.recommendation,
557
+ options: current.options,
558
+ effects: current.effects,
559
+ } : null,
560
+ };
453
561
  }
454
562
 
455
563
  function readBreadcrumb(db, { id } = {}, { rootDir = process.cwd() } = {}) {
@@ -464,18 +572,44 @@ function readBreadcrumb(db, { id } = {}, { rootDir = process.cwd() } = {}) {
464
572
  }
465
573
  if (!change) {
466
574
  const baseline = baselineHealth.baseline;
575
+ const decisionGate = decisions.decisionGate(
576
+ db, { baseline_id: baseline?.id || null }, { rootDir },
577
+ );
578
+ if (!decisionGate.ready) {
579
+ return {
580
+ change_id: null, task_id: null, role: 'plan', gate: 'alignment', readiness: 'blocked',
581
+ blockers: [...new Set([...baselineHealth.blockers, ...decisionGate.blockers])],
582
+ warnings: baselineHealth.warnings,
583
+ allowed_transitions: ['ultra-think', 'ultra-status'], required_transition: 'ultra-think',
584
+ workflow: null, decision: summarizeDecision(decisionGate),
585
+ context_manifest_path: null, context_manifest_hash: null, git_head: currentHead(rootDir),
586
+ baseline: baseline ? {
587
+ id: baseline.id, mode: baseline.mode, status: baseline.status,
588
+ repository_revision: baseline.repository_revision,
589
+ } : null,
590
+ };
591
+ }
467
592
  if (baselineHealth.status !== 'pass') {
468
593
  const activeWorkflow = actionableBaselineWorkflow(db, baseline?.id, rootDir);
469
- const nextAction = activeWorkflow
470
- ? workflowNextAction(activeWorkflow)
471
- : baseline
472
- ? `Complete ${baseline.mode} baseline adoption ${baseline.id} through ultra-init.`
473
- : 'Initialize the project and start baseline adoption with ultra-init.';
594
+ const control = baseline
595
+ ? {
596
+ allowed_transitions: uniqueStrings([
597
+ activeWorkflow ? `ultra-${activeWorkflow.kind}` : 'ultra-research',
598
+ 'ultra-status',
599
+ 'ultra-doctor',
600
+ ]),
601
+ required_transition: null,
602
+ }
603
+ : {
604
+ allowed_transitions: ['ultra-init', 'ultra-status'],
605
+ required_transition: 'ultra-init',
606
+ };
474
607
  return {
475
608
  change_id: null, task_id: null, role: 'plan', gate: 'alignment', readiness: 'blocked',
476
- blockers: baselineHealth.blockers, warnings: baselineHealth.warnings, next_action: nextAction,
477
- recommended_workflow: activeWorkflow ? `ultra-${activeWorkflow.kind}` : 'ultra-init',
478
- workflow: summarizeWorkflow(activeWorkflow), context_manifest_path: null,
609
+ blockers: baselineHealth.blockers, warnings: baselineHealth.warnings,
610
+ allowed_transitions: control.allowed_transitions,
611
+ required_transition: control.required_transition,
612
+ workflow: summarizeWorkflow(activeWorkflow), decision: null, context_manifest_path: null,
479
613
  context_manifest_hash: null, git_head: currentHead(rootDir),
480
614
  baseline: baseline ? {
481
615
  id: baseline.id, mode: baseline.mode, status: baseline.status,
@@ -485,8 +619,10 @@ function readBreadcrumb(db, { id } = {}, { rootDir = process.cwd() } = {}) {
485
619
  }
486
620
  return {
487
621
  change_id: null, task_id: null, role: 'plan', gate: 'alignment', readiness: 'ready',
488
- next_action: 'Start daily work with ultra-change.', recommended_workflow: 'ultra-change',
622
+ allowed_transitions: ['ultra-change', 'ultra-research', 'ultra-think', 'ultra-status'],
623
+ required_transition: null,
489
624
  workflow: null,
625
+ decision: null,
490
626
  context_manifest_path: null, context_manifest_hash: null, git_head: currentHead(rootDir),
491
627
  blockers: [], warnings: baselineHealth.warnings,
492
628
  baseline: {
@@ -513,6 +649,7 @@ function readBreadcrumb(db, { id } = {}, { rootDir = process.cwd() } = {}) {
513
649
  !context.resume || !context.context || !context.readiness || !('baseline' in context)
514
650
  || !Object.prototype.hasOwnProperty.call(context.resume, 'worktree_digest')
515
651
  || !Object.prototype.hasOwnProperty.call(context.resume, 'task_state_digest')
652
+ || !Object.prototype.hasOwnProperty.call(context.resume, 'change_state_digest')
516
653
  ),
517
654
  );
518
655
  const headStale = Boolean(snapshot?.git_head && head && snapshot.git_head !== head);
@@ -525,17 +662,28 @@ function readBreadcrumb(db, { id } = {}, { rootDir = process.cwd() } = {}) {
525
662
  const taskContractChanged = Boolean(
526
663
  context.resume && context.resume.task_state_digest !== taskStateDigest(task),
527
664
  );
665
+ const changeContractChanged = Boolean(
666
+ context.resume && context.resume.change_state_digest !== changeStateDigest(change),
667
+ );
528
668
  const blockers = [];
529
669
  const baselineGate = baselineGateForChange(db, change, baselineHealth);
670
+ const decisionGate = decisions.decisionGate(db, {
671
+ baseline_id: baselineHealth.baseline?.id || null,
672
+ change_id: change.id,
673
+ workflow_run_id: actionableChangeWorkflow(db, change.id, rootDir)?.id || null,
674
+ }, { rootDir });
530
675
  const warnings = [...baselineGate.warnings];
531
676
  blockers.push(...baselineGate.blockers);
532
- if (snapshotMissing) blockers.push('CONTEXT_NOT_COMPILED');
677
+ blockers.push(...decisionGate.blockers);
678
+ if (snapshotMissing && tasks.length > 0) blockers.push('CONTEXT_NOT_COMPILED');
679
+ else if (snapshotMissing) warnings.push('CONTEXT_NOT_COMPILED');
533
680
  else {
534
681
  if (legacyContext) blockers.push('CONTEXT_SNAPSHOT_UPGRADE_REQUIRED');
535
682
  if (headStale) blockers.push('CONTEXT_HEAD_STALE');
536
683
  if (worktreeStale) blockers.push('CONTEXT_WORKTREE_STALE');
537
684
  if (stateChanged) blockers.push('CONTEXT_TASK_STATE_STALE');
538
685
  if (taskContractChanged) blockers.push('CONTEXT_TASK_CONTRACT_STALE');
686
+ if (changeContractChanged) blockers.push('CONTEXT_CHANGE_CONTRACT_STALE');
539
687
  for (const blocker of parseJson(snapshot.blockers_json, [])) {
540
688
  if (ADVISORY_CONTEXT_CODES.has(blocker)
541
689
  || (blocker.startsWith('BASELINE_') && baselineGate.blockers.length === 0)) {
@@ -546,36 +694,18 @@ function readBreadcrumb(db, { id } = {}, { rootDir = process.cwd() } = {}) {
546
694
  if (!warnings.includes(warning)) warnings.push(warning);
547
695
  }
548
696
  }
549
- const readiness = blockers.length > 0 ? 'blocked' : snapshot.readiness;
697
+ const readiness = blockers.length > 0 ? 'blocked' : (snapshot?.readiness || 'ready');
550
698
  const activeWorkflow = actionableChangeWorkflow(db, change.id, rootDir);
551
- let nextAction;
552
- if (baselineGate.blockers.length > 0) {
553
- nextAction = deriveNextAction({
554
- change, tasks, task, role: snapshot?.role || 'plan', readiness: 'blocked',
555
- blockers: baselineGate.blockers,
556
- });
557
- } else if (activeWorkflow?.status === 'blocked') {
558
- nextAction = workflowNextAction(activeWorkflow);
559
- } else if (snapshotMissing) {
560
- nextAction = `Compile change.context for change ${change.id} before continuing.`;
561
- } else if (legacyContext) {
562
- nextAction = 'Recompile change.context to upgrade this legacy context snapshot.';
563
- } else if (headStale) {
564
- nextAction = 'Git HEAD changed after context compilation; recompile change.context.';
565
- } else if (worktreeStale) {
566
- nextAction = 'The worktree changed after context compilation; recompile change.context.';
567
- } else if (stateChanged) {
568
- nextAction = 'Task state changed after context compilation; recompile change.context.';
569
- } else if (taskContractChanged) {
570
- nextAction = 'The task execution contract changed after context compilation; recompile change.context.';
571
- } else if (activeWorkflow) {
572
- nextAction = workflowNextAction(activeWorkflow);
573
- } else {
574
- nextAction = deriveNextAction({
575
- change, tasks, task, role: snapshot?.role || 'plan', readiness,
576
- blockers,
577
- });
578
- }
699
+ const control = deriveTransitions({
700
+ change,
701
+ tasks,
702
+ task,
703
+ role: snapshot?.role || 'plan',
704
+ readiness,
705
+ blockers,
706
+ activeWorkflow,
707
+ deliveryReady: deliveryCapabilityReady(db, change, tasks, rootDir),
708
+ });
579
709
  return {
580
710
  change_id: change.id,
581
711
  change_status: change.status,
@@ -583,16 +713,12 @@ function readBreadcrumb(db, { id } = {}, { rootDir = process.cwd() } = {}) {
583
713
  task_status: task?.status || null,
584
714
  session_id: task?.session_id || null,
585
715
  role: snapshot?.role || 'plan',
586
- gate: snapshot?.gate || 'alignment',
716
+ gate: decisionGate.ready ? (snapshot?.gate || 'alignment') : 'alignment',
587
717
  readiness,
588
718
  blockers,
589
719
  warnings: [...new Set(warnings)],
590
- next_action: nextAction,
591
- recommended_workflow: baselineGate.blockers.length > 0
592
- ? (baselineHealth.baseline?.status === 'ready' ? 'ultra-doctor' : 'ultra-init')
593
- : activeWorkflow
594
- ? `ultra-${activeWorkflow.kind}`
595
- : recommendedWorkflow(change, task, tasks, readiness),
720
+ allowed_transitions: control.allowed_transitions,
721
+ required_transition: control.required_transition,
596
722
  context_manifest_path: snapshot?.manifest_path || null,
597
723
  context_manifest_hash: snapshot?.manifest_hash || null,
598
724
  git_head: head,
@@ -602,13 +728,15 @@ function readBreadcrumb(db, { id } = {}, { rootDir = process.cwd() } = {}) {
602
728
  repository_revision: baselineHealth.baseline.repository_revision,
603
729
  } : null,
604
730
  workflow: summarizeWorkflow(activeWorkflow),
731
+ decision: summarizeDecision(decisionGate),
605
732
  };
606
733
  }
607
734
 
608
735
  module.exports = {
609
736
  ContextSpineError,
610
737
  baselineGateForChange,
738
+ changeStateDigest,
611
739
  compileRoleContext,
612
- deriveNextAction,
740
+ deriveTransitions,
613
741
  readBreadcrumb,
614
742
  };