ultra-builder-pro-cli 0.16.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 (64) hide show
  1. package/.ultra-template/specs/research-distillate.md +4 -3
  2. package/CHANGELOG.md +86 -0
  3. package/README.md +54 -24
  4. package/adapters/_shared/codex-assets.cjs +10 -5
  5. package/adapters/_shared/handbook.cjs +1 -1
  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/docs/AGENT-CONTEXT.md +11 -8
  19. package/docs/ARCHITECTURE.md +67 -9
  20. package/docs/LEGACY-CLI-CROSSWALK.md +163 -0
  21. package/docs/PLAN.zh-CN.md +19 -13
  22. package/docs/RUNTIME-COMPAT-MATRIX.md +2 -2
  23. package/docs/WORKFLOW-LIFECYCLE.md +123 -59
  24. package/hooks/pre_stop_check.py +3 -2
  25. package/mcp-server/lib/baseline-workflow.cjs +84 -16
  26. package/mcp-server/lib/change-workflow.cjs +55 -35
  27. package/mcp-server/lib/context-spine.cjs +171 -105
  28. package/mcp-server/lib/git-bootstrap.cjs +240 -0
  29. package/mcp-server/lib/init-project.cjs +178 -41
  30. package/mcp-server/lib/project-breadcrumb.cjs +14 -14
  31. package/mcp-server/lib/state-db.cjs +257 -10
  32. package/mcp-server/lib/state-ops.cjs +64 -1
  33. package/mcp-server/lib/workflow-state.cjs +300 -205
  34. package/mcp-server/server.cjs +58 -34
  35. package/orchestrator/auto-merge.cjs +19 -0
  36. package/orchestrator/daemon.cjs +104 -0
  37. package/orchestrator/parallel-orchestrator.cjs +371 -49
  38. package/orchestrator/session-runner.cjs +354 -25
  39. package/package.json +2 -1
  40. package/skills/ultra-change/SKILL.md +69 -94
  41. package/skills/ultra-deliver/SKILL.md +37 -65
  42. package/skills/ultra-dev/SKILL.md +50 -52
  43. package/skills/ultra-doctor/SKILL.md +34 -49
  44. package/skills/ultra-init/SKILL.md +78 -105
  45. package/skills/ultra-plan/SKILL.md +78 -99
  46. package/skills/ultra-research/SKILL.md +99 -113
  47. package/skills/ultra-review/SKILL.md +38 -53
  48. package/skills/ultra-review/references/review-modes.md +5 -3
  49. package/skills/ultra-review/references/unified-schema.md +26 -6
  50. package/skills/ultra-review/scripts/review_wait.py +12 -0
  51. package/skills/ultra-status/SKILL.md +34 -44
  52. package/skills/ultra-test/SKILL.md +61 -54
  53. package/skills/ultra-think/SKILL.md +37 -51
  54. package/skills/ultra-think/references/decision-dialogue.md +87 -111
  55. package/spec/cli-protocol.md +35 -0
  56. package/spec/mcp-tools.yaml +145 -28
  57. package/spec/schemas/delivery-report.v1.schema.json +1 -46
  58. package/spec/schemas/state-db.sql +8 -2
  59. package/spec/schemas/test-report.v1.schema.json +45 -14
  60. package/ultra-tools/cli.cjs +1 -1
  61. package/ultra-tools/commands/session.cjs +10 -3
  62. package/ultra-tools/commands/status.cjs +19 -14
  63. package/ultra-tools/commands/system.cjs +2 -2
  64. package/ultra-tools/commands/task.cjs +3 -0
@@ -24,6 +24,10 @@ const CHANGE_PATCH_FIELDS = new Set([
24
24
  'title', 'intent', 'status', 'docs_impact', 'provider_refs',
25
25
  'contract', 'classification', 'research_disposition',
26
26
  ]);
27
+ const SEMANTIC_AUTHORITY_FIELDS = new Set([
28
+ 'intent', 'docs_impact', 'provider_refs', 'contract', 'classification',
29
+ 'research_disposition',
30
+ ]);
27
31
  const CHANGE_TRANSITIONS = Object.freeze({
28
32
  active: new Set(['active', 'blocked', 'cancelled']),
29
33
  blocked: new Set(['active', 'blocked', 'cancelled']),
@@ -544,9 +548,12 @@ function compileContext(db, input, { rootDir = process.cwd() } = {}) {
544
548
  ).all(change.id).map((task) => ops.readTask(db, task.id));
545
549
  const specs = Array.isArray(input.spec_refs) ? input.spec_refs : [];
546
550
  const spine = contextSpine.compileRoleContext(db, { input, change, tasks, rootDir });
551
+ const recommendation = spine.recommendation;
552
+ const durableSpine = { ...spine };
553
+ delete durableSpine.recommendation;
547
554
  const snapshotId = `ctx-${crypto.randomUUID().slice(0, 12)}`;
548
555
  const manifest = {
549
- schema_version: '2.0',
556
+ schema_version: '3.0',
550
557
  snapshot_id: snapshotId,
551
558
  generated_at: nowIso(),
552
559
  change: {
@@ -566,7 +573,7 @@ function compileContext(db, input, { rootDir = process.cwd() } = {}) {
566
573
  specs,
567
574
  role: spine.role,
568
575
  gate: spine.gate,
569
- next_action: spine.next_action,
576
+ control: spine.control,
570
577
  readiness: spine.readiness,
571
578
  context: spine.context,
572
579
  execution_contract: spine.execution_contract,
@@ -588,14 +595,16 @@ function compileContext(db, input, { rootDir = process.cwd() } = {}) {
588
595
  db.prepare(
589
596
  `INSERT INTO context_snapshots
590
597
  (id, change_id, task_id, git_head, provider_refs_json, manifest_path, manifest_hash,
591
- role, gate, next_action, readiness, blockers_json, context_json,
592
- token_estimate, token_budget)
593
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
598
+ role, gate, next_action, allowed_transitions_json, required_transition,
599
+ readiness, blockers_json, context_json, token_estimate, token_budget)
600
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
594
601
  ).run(
595
602
  snapshotId, change.id, input.task_id || null,
596
603
  spine.resume.git_head, JSON.stringify(providers), relative, hash,
597
- spine.role, spine.gate, spine.next_action, spine.readiness.status,
598
- JSON.stringify(spine.readiness.blockers), JSON.stringify(spine),
604
+ spine.role, spine.gate, '',
605
+ JSON.stringify(spine.control.allowed_transitions), spine.control.required_transition,
606
+ spine.readiness.status,
607
+ JSON.stringify(spine.readiness.blockers), JSON.stringify(durableSpine),
599
608
  spine.context.token_estimate, spine.context.budget.max_tokens,
600
609
  );
601
610
  upsertArtifact(db, {
@@ -623,34 +632,17 @@ function compileContext(db, input, { rootDir = process.cwd() } = {}) {
623
632
  manifest_path: relative, manifest_hash: hash, git_head: spine.resume.git_head,
624
633
  worktree_digest: spine.resume.worktree_digest,
625
634
  role: spine.role, gate: spine.gate, readiness: spine.readiness.status,
626
- next_action: spine.next_action,
635
+ allowed_transitions: spine.control.allowed_transitions,
636
+ required_transition: spine.control.required_transition,
627
637
  },
628
638
  });
629
- const linkedWorkflow = db.prepare(
630
- `SELECT id FROM workflow_runs
631
- WHERE kind = 'change' AND change_id = ? AND current_step = 'compile-context'
632
- AND status IN ('active', 'blocked')
633
- ORDER BY updated_at DESC, rowid DESC LIMIT 1`,
634
- ).get(change.id);
635
- if (linkedWorkflow) {
636
- if (spine.readiness.status === 'ready') {
637
- workflows.recordWorkflowStep(db, {
638
- id: linkedWorkflow.id, step_id: 'compile-context', status: 'completed',
639
- evidence: [{
640
- kind: 'context', ref: relative,
641
- summary: `Role ${spine.role} context is ready for ${input.task_id || 'change planning'}.`,
642
- }],
643
- outputs: [{ path: relative, kind: 'context-manifest' }],
644
- }, { rootDir });
645
- } else {
646
- workflows.recordWorkflowStep(db, {
647
- id: linkedWorkflow.id, step_id: 'compile-context', status: 'blocked',
648
- blockers: spine.readiness.blockers,
649
- }, { rootDir });
650
- }
651
- }
652
639
  });
653
- return { manifest, context_manifest_path: manifestPath, manifest_hash: hash };
640
+ return {
641
+ manifest,
642
+ recommendation,
643
+ context_manifest_path: manifestPath,
644
+ manifest_hash: hash,
645
+ };
654
646
  }
655
647
 
656
648
  function createChange(db, input, { rootDir = process.cwd() } = {}) {
@@ -793,7 +785,6 @@ function createChange(db, input, { rootDir = process.cwd() } = {}) {
793
785
  change_id: row.id, subject: `Establish executable readiness for ${row.title}.`,
794
786
  metadata: { change_kind: row.kind, base_commit: row.base_commit },
795
787
  }, { rootDir });
796
- const context = compileContext(db, { id: row.id }, { rootDir });
797
788
  workflow = workflows.recordWorkflowStep(db, {
798
789
  id: workflow.id, step_id: 'bind-baseline', status: 'completed',
799
790
  evidence: [{
@@ -819,9 +810,10 @@ function createChange(db, input, { rootDir = process.cwd() } = {}) {
819
810
  research_disposition: row.research_disposition,
820
811
  }],
821
812
  }, { rootDir });
813
+ workflow = workflows.completeWorkflow(db, { id: workflow.id }, { rootDir });
822
814
  return {
823
815
  change: readChange(db, row.id), intent_path: intentPath,
824
- context_manifest_path: context.context_manifest_path,
816
+ context_manifest_path: null,
825
817
  workflow,
826
818
  };
827
819
  });
@@ -914,6 +906,30 @@ function updateChange(db, id, patch = {}, { rootDir = process.cwd() } = {}) {
914
906
  sets.push('updated_at = ?'); values.push(nowIso(), id);
915
907
  db.prepare(`UPDATE changes SET ${sets.join(', ')} WHERE id = ?`).run(...values);
916
908
  const updated = readChange(db, id);
909
+ const invalidatedTasks = [];
910
+ if (contextSpine.changeStateDigest(current) !== contextSpine.changeStateDigest(updated)) {
911
+ const tasks = db.prepare(
912
+ 'SELECT id, stale FROM tasks WHERE change_id = ? ORDER BY created_at ASC, id ASC',
913
+ ).all(id);
914
+ const markStale = db.prepare(
915
+ 'UPDATE tasks SET stale = 1, updated_at = ? WHERE id = ?',
916
+ );
917
+ const invalidatedAt = nowIso();
918
+ for (const task of tasks) {
919
+ if (task.stale) continue;
920
+ markStale.run(invalidatedAt, task.id);
921
+ invalidatedTasks.push(task.id);
922
+ ops.appendEventInTx(db, {
923
+ type: 'task_stale_marked',
924
+ task_id: task.id,
925
+ change_id: id,
926
+ payload: {
927
+ reason: 'change_semantic_authority_updated',
928
+ fields: Object.keys(patch).filter((field) => SEMANTIC_AUTHORITY_FIELDS.has(field)),
929
+ },
930
+ });
931
+ }
932
+ }
917
933
  if (syncIntent) {
918
934
  fs.mkdirSync(path.dirname(intentPath), { recursive: true });
919
935
  writeIntent(intentPath, updated);
@@ -924,7 +940,11 @@ function updateChange(db, id, patch = {}, { rootDir = process.cwd() } = {}) {
924
940
  contentHash: crypto.createHash('sha256').update(fs.readFileSync(intentPath)).digest('hex'),
925
941
  });
926
942
  }
927
- ops.appendEventInTx(db, { type: 'change_updated', change_id: id, payload: { fields: Object.keys(patch) } });
943
+ ops.appendEventInTx(db, {
944
+ type: 'change_updated',
945
+ change_id: id,
946
+ payload: { fields: Object.keys(patch), invalidated_tasks: invalidatedTasks },
947
+ });
928
948
  return updated;
929
949
  });
930
950
  } catch (error) {
@@ -52,6 +52,10 @@ function parseJson(value, fallback = {}) {
52
52
  try { return JSON.parse(value); } catch { return fallback; }
53
53
  }
54
54
 
55
+ function uniqueStrings(values) {
56
+ return [...new Set(values.filter((value) => typeof value === 'string' && value.length > 0))];
57
+ }
58
+
55
59
  function hasIncidentBreakGlass(change) {
56
60
  const bypass = change?.baseline_bypass
57
61
  || parseJson(change?.baseline_bypass_json, null);
@@ -128,6 +132,22 @@ function taskStateDigest(task) {
128
132
  return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex');
129
133
  }
130
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
+
131
151
  function nonEmpty(value, field) {
132
152
  const text = typeof value === 'string' ? value.trim() : '';
133
153
  if (!text) throw new ContextSpineError('VALIDATION_ERROR', `${field} must be a non-empty string`);
@@ -291,27 +311,106 @@ function defaultGate(role) {
291
311
  }[role];
292
312
  }
293
313
 
294
- 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
+ }
295
358
  if (readiness === 'blocked') {
359
+ if (blockers.some((blocker) => blocker.startsWith('CONTEXT_')
360
+ || blocker.startsWith('EXECUTION_'))) {
361
+ transitions.push('change.context');
362
+ }
296
363
  if (blockers.some((blocker) => blocker.startsWith('BASELINE_'))) {
297
- return 'Complete or refresh the Ultra project baseline, then recompile change.context.';
364
+ transitions.push('ultra-research', 'ultra-doctor');
298
365
  }
299
- return 'Resolve the context readiness blockers, then recompile change.context.';
300
- }
301
- if (change.status === 'ready') return `Run ultra-deliver for change ${change.id}.`;
302
- if (change.status === 'blocked') return `Resolve the blockers for change ${change.id}, then recompile context.`;
303
- if (!task && tasks.length === 0) return `Create the first fresh-context tracer-bullet task for change ${change.id}.`;
304
- if (task?.status === 'pending') return `Start task ${task.id} through ultra-dev.`;
305
- if (task?.status === 'in_progress') {
306
- if (role === 'check') return `Run the exact verification command for task ${task.id}.`;
307
- if (role === 'review') return `Complete independent spec-fidelity and engineering-standards review for task ${task.id}.`;
308
- return `Continue the approved vertical slice for task ${task.id}.`;
309
- }
310
- if (tasks.length > 0 && tasks.every((row) => ['completed', 'expanded'].includes(row.status))) {
311
- return `Run ultra-test for change ${change.id}.`;
312
- }
313
- const pending = tasks.find((row) => row.status === 'pending');
314
- 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
+ }
315
414
  }
316
415
 
317
416
  function latestContext(db, changeId, taskId) {
@@ -328,8 +427,8 @@ function latestContext(db, changeId, taskId) {
328
427
  function compileRoleContext(db, { input, change, tasks, rootDir }) {
329
428
  if (input.next_action !== undefined) {
330
429
  throw new ContextSpineError(
331
- 'CONTEXT_NEXT_ACTION_AUTHORITY_VIOLATION',
332
- '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',
333
432
  );
334
433
  }
335
434
  const task = input.task_id ? tasks.find((row) => row.id === input.task_id) : null;
@@ -376,13 +475,20 @@ function compileRoleContext(db, { input, change, tasks, rootDir }) {
376
475
  }
377
476
  }
378
477
  const readiness = blockers.length === 0 ? 'ready' : 'blocked';
379
- const nextAction = deriveNextAction({
380
- 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),
381
486
  });
382
487
  return {
383
488
  role,
384
489
  gate,
385
- next_action: nextAction,
490
+ control,
491
+ recommendation: normalizeRecommendation(input.recommendation),
386
492
  readiness: { status: readiness, blockers, warnings: [...new Set(warnings)] },
387
493
  context: { items, budget, token_estimate: tokenEstimate, file_count: fileCount },
388
494
  execution_contract: executionContract,
@@ -397,6 +503,7 @@ function compileRoleContext(db, { input, change, tasks, rootDir }) {
397
503
  selected_task: task,
398
504
  resume: {
399
505
  change_id: change.id,
506
+ change_state_digest: changeStateDigest(change),
400
507
  task_id: task?.id || null,
401
508
  task_status: task?.status || null,
402
509
  task_state_digest: taskStateDigest(task),
@@ -410,15 +517,6 @@ function compileRoleContext(db, { input, change, tasks, rootDir }) {
410
517
  };
411
518
  }
412
519
 
413
- function recommendedWorkflow(change, task, tasks, readiness) {
414
- if (!change) return 'ultra-change';
415
- if (readiness !== 'ready' || change.status === 'blocked') return 'ultra-change';
416
- if (change.status === 'ready') return 'ultra-deliver';
417
- if (task && ['pending', 'in_progress'].includes(task.status)) return 'ultra-dev';
418
- if (tasks.length > 0 && tasks.every((row) => ['completed', 'expanded'].includes(row.status))) return 'ultra-test';
419
- return 'ultra-change';
420
- }
421
-
422
520
  function actionableBaselineWorkflow(db, baselineId, rootDir) {
423
521
  if (!baselineId) return null;
424
522
  const candidates = workflows.listWorkflows(db, { baseline_id: baselineId, limit: 100 }, { rootDir })
@@ -443,16 +541,6 @@ function summarizeWorkflow(run) {
443
541
  };
444
542
  }
445
543
 
446
- function workflowNextAction(run) {
447
- if (run.status === 'blocked') {
448
- return `Resolve ${run.kind} workflow blockers at ${run.current_step}: ${run.blockers.join(', ')}.`;
449
- }
450
- if (run.status === 'ready') {
451
- return `Finalize ${run.kind} workflow ${run.id} after verifying its recorded evidence and outputs.`;
452
- }
453
- return `Continue ${run.kind} workflow ${run.id} at ${run.current_step}.`;
454
- }
455
-
456
544
  function summarizeDecision(gate) {
457
545
  if (!gate?.thread) return null;
458
546
  const current = gate.current_decision;
@@ -472,19 +560,6 @@ function summarizeDecision(gate) {
472
560
  };
473
561
  }
474
562
 
475
- function decisionNextAction(gate) {
476
- if (gate.blockers?.some((blocker) => blocker.startsWith('DECISION_CHECKPOINT_ARTIFACT_STALE:'))) {
477
- return `Reprepare and confirm decision checkpoint ${gate.thread?.id || 'for the active alignment thread'} against the current artifact.`;
478
- }
479
- if (gate.current_decision) {
480
- return `Answer decision ${gate.current_decision.id}: ${gate.current_decision.question}`;
481
- }
482
- if (gate.thread?.status === 'checkpoint_ready') {
483
- return `Confirm decision checkpoint ${gate.thread.id} after reviewing its durable effects.`;
484
- }
485
- return `Prepare the confirmed decision checkpoint for ${gate.thread?.id || 'the active alignment thread'}.`;
486
- }
487
-
488
563
  function readBreadcrumb(db, { id } = {}, { rootDir = process.cwd() } = {}) {
489
564
  const baselineHealth = baselines.inspectBaseline(db, { rootDir });
490
565
  const change = id
@@ -505,7 +580,7 @@ function readBreadcrumb(db, { id } = {}, { rootDir = process.cwd() } = {}) {
505
580
  change_id: null, task_id: null, role: 'plan', gate: 'alignment', readiness: 'blocked',
506
581
  blockers: [...new Set([...baselineHealth.blockers, ...decisionGate.blockers])],
507
582
  warnings: baselineHealth.warnings,
508
- next_action: decisionNextAction(decisionGate), recommended_workflow: 'ultra-think',
583
+ allowed_transitions: ['ultra-think', 'ultra-status'], required_transition: 'ultra-think',
509
584
  workflow: null, decision: summarizeDecision(decisionGate),
510
585
  context_manifest_path: null, context_manifest_hash: null, git_head: currentHead(rootDir),
511
586
  baseline: baseline ? {
@@ -516,15 +591,24 @@ function readBreadcrumb(db, { id } = {}, { rootDir = process.cwd() } = {}) {
516
591
  }
517
592
  if (baselineHealth.status !== 'pass') {
518
593
  const activeWorkflow = actionableBaselineWorkflow(db, baseline?.id, rootDir);
519
- const nextAction = activeWorkflow
520
- ? workflowNextAction(activeWorkflow)
521
- : baseline
522
- ? `Complete ${baseline.mode} baseline adoption ${baseline.id} through ultra-init.`
523
- : '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
+ };
524
607
  return {
525
608
  change_id: null, task_id: null, role: 'plan', gate: 'alignment', readiness: 'blocked',
526
- blockers: baselineHealth.blockers, warnings: baselineHealth.warnings, next_action: nextAction,
527
- recommended_workflow: activeWorkflow ? `ultra-${activeWorkflow.kind}` : 'ultra-init',
609
+ blockers: baselineHealth.blockers, warnings: baselineHealth.warnings,
610
+ allowed_transitions: control.allowed_transitions,
611
+ required_transition: control.required_transition,
528
612
  workflow: summarizeWorkflow(activeWorkflow), decision: null, context_manifest_path: null,
529
613
  context_manifest_hash: null, git_head: currentHead(rootDir),
530
614
  baseline: baseline ? {
@@ -535,7 +619,8 @@ function readBreadcrumb(db, { id } = {}, { rootDir = process.cwd() } = {}) {
535
619
  }
536
620
  return {
537
621
  change_id: null, task_id: null, role: 'plan', gate: 'alignment', readiness: 'ready',
538
- 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,
539
624
  workflow: null,
540
625
  decision: null,
541
626
  context_manifest_path: null, context_manifest_hash: null, git_head: currentHead(rootDir),
@@ -564,6 +649,7 @@ function readBreadcrumb(db, { id } = {}, { rootDir = process.cwd() } = {}) {
564
649
  !context.resume || !context.context || !context.readiness || !('baseline' in context)
565
650
  || !Object.prototype.hasOwnProperty.call(context.resume, 'worktree_digest')
566
651
  || !Object.prototype.hasOwnProperty.call(context.resume, 'task_state_digest')
652
+ || !Object.prototype.hasOwnProperty.call(context.resume, 'change_state_digest')
567
653
  ),
568
654
  );
569
655
  const headStale = Boolean(snapshot?.git_head && head && snapshot.git_head !== head);
@@ -576,6 +662,9 @@ function readBreadcrumb(db, { id } = {}, { rootDir = process.cwd() } = {}) {
576
662
  const taskContractChanged = Boolean(
577
663
  context.resume && context.resume.task_state_digest !== taskStateDigest(task),
578
664
  );
665
+ const changeContractChanged = Boolean(
666
+ context.resume && context.resume.change_state_digest !== changeStateDigest(change),
667
+ );
579
668
  const blockers = [];
580
669
  const baselineGate = baselineGateForChange(db, change, baselineHealth);
581
670
  const decisionGate = decisions.decisionGate(db, {
@@ -586,13 +675,15 @@ function readBreadcrumb(db, { id } = {}, { rootDir = process.cwd() } = {}) {
586
675
  const warnings = [...baselineGate.warnings];
587
676
  blockers.push(...baselineGate.blockers);
588
677
  blockers.push(...decisionGate.blockers);
589
- if (snapshotMissing) blockers.push('CONTEXT_NOT_COMPILED');
678
+ if (snapshotMissing && tasks.length > 0) blockers.push('CONTEXT_NOT_COMPILED');
679
+ else if (snapshotMissing) warnings.push('CONTEXT_NOT_COMPILED');
590
680
  else {
591
681
  if (legacyContext) blockers.push('CONTEXT_SNAPSHOT_UPGRADE_REQUIRED');
592
682
  if (headStale) blockers.push('CONTEXT_HEAD_STALE');
593
683
  if (worktreeStale) blockers.push('CONTEXT_WORKTREE_STALE');
594
684
  if (stateChanged) blockers.push('CONTEXT_TASK_STATE_STALE');
595
685
  if (taskContractChanged) blockers.push('CONTEXT_TASK_CONTRACT_STALE');
686
+ if (changeContractChanged) blockers.push('CONTEXT_CHANGE_CONTRACT_STALE');
596
687
  for (const blocker of parseJson(snapshot.blockers_json, [])) {
597
688
  if (ADVISORY_CONTEXT_CODES.has(blocker)
598
689
  || (blocker.startsWith('BASELINE_') && baselineGate.blockers.length === 0)) {
@@ -603,38 +694,18 @@ function readBreadcrumb(db, { id } = {}, { rootDir = process.cwd() } = {}) {
603
694
  if (!warnings.includes(warning)) warnings.push(warning);
604
695
  }
605
696
  }
606
- const readiness = blockers.length > 0 ? 'blocked' : snapshot.readiness;
697
+ const readiness = blockers.length > 0 ? 'blocked' : (snapshot?.readiness || 'ready');
607
698
  const activeWorkflow = actionableChangeWorkflow(db, change.id, rootDir);
608
- let nextAction;
609
- if (baselineGate.blockers.length > 0) {
610
- nextAction = deriveNextAction({
611
- change, tasks, task, role: snapshot?.role || 'plan', readiness: 'blocked',
612
- blockers: baselineGate.blockers,
613
- });
614
- } else if (!decisionGate.ready) {
615
- nextAction = decisionNextAction(decisionGate);
616
- } else if (activeWorkflow?.status === 'blocked') {
617
- nextAction = workflowNextAction(activeWorkflow);
618
- } else if (snapshotMissing) {
619
- nextAction = `Compile change.context for change ${change.id} before continuing.`;
620
- } else if (legacyContext) {
621
- nextAction = 'Recompile change.context to upgrade this legacy context snapshot.';
622
- } else if (headStale) {
623
- nextAction = 'Git HEAD changed after context compilation; recompile change.context.';
624
- } else if (worktreeStale) {
625
- nextAction = 'The worktree changed after context compilation; recompile change.context.';
626
- } else if (stateChanged) {
627
- nextAction = 'Task state changed after context compilation; recompile change.context.';
628
- } else if (taskContractChanged) {
629
- nextAction = 'The task execution contract changed after context compilation; recompile change.context.';
630
- } else if (activeWorkflow) {
631
- nextAction = workflowNextAction(activeWorkflow);
632
- } else {
633
- nextAction = deriveNextAction({
634
- change, tasks, task, role: snapshot?.role || 'plan', readiness,
635
- blockers,
636
- });
637
- }
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
+ });
638
709
  return {
639
710
  change_id: change.id,
640
711
  change_status: change.status,
@@ -646,14 +717,8 @@ function readBreadcrumb(db, { id } = {}, { rootDir = process.cwd() } = {}) {
646
717
  readiness,
647
718
  blockers,
648
719
  warnings: [...new Set(warnings)],
649
- next_action: nextAction,
650
- recommended_workflow: baselineGate.blockers.length > 0
651
- ? (baselineHealth.baseline?.status === 'ready' ? 'ultra-doctor' : 'ultra-init')
652
- : !decisionGate.ready
653
- ? 'ultra-think'
654
- : activeWorkflow
655
- ? `ultra-${activeWorkflow.kind}`
656
- : recommendedWorkflow(change, task, tasks, readiness),
720
+ allowed_transitions: control.allowed_transitions,
721
+ required_transition: control.required_transition,
657
722
  context_manifest_path: snapshot?.manifest_path || null,
658
723
  context_manifest_hash: snapshot?.manifest_hash || null,
659
724
  git_head: head,
@@ -670,7 +735,8 @@ function readBreadcrumb(db, { id } = {}, { rootDir = process.cwd() } = {}) {
670
735
  module.exports = {
671
736
  ContextSpineError,
672
737
  baselineGateForChange,
738
+ changeStateDigest,
673
739
  compileRoleContext,
674
- deriveNextAction,
740
+ deriveTransitions,
675
741
  readBreadcrumb,
676
742
  };