ultra-builder-pro-cli 0.16.0 → 0.17.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 (66) hide show
  1. package/.ultra-template/reports/templates/delivery-report.json +17 -0
  2. package/.ultra-template/reports/templates/test-report.json +18 -0
  3. package/.ultra-template/specs/research-distillate.md +4 -3
  4. package/CHANGELOG.md +95 -0
  5. package/README.md +54 -24
  6. package/adapters/_shared/codex-assets.cjs +10 -5
  7. package/adapters/_shared/handbook.cjs +1 -1
  8. package/adapters/_shared/interaction-contract.cjs +61 -0
  9. package/adapters/claude.js +1 -1
  10. package/adapters/opencode.js +1 -1
  11. package/bin/orchestrator.js +175 -19
  12. package/commands/ultra-change.md +1 -1
  13. package/commands/ultra-deliver.md +3 -3
  14. package/commands/ultra-init.md +1 -1
  15. package/commands/ultra-plan.md +1 -1
  16. package/commands/ultra-research.md +2 -2
  17. package/commands/ultra-review.md +10 -0
  18. package/commands/ultra-status.md +1 -1
  19. package/commands/ultra-test.md +1 -1
  20. package/docs/AGENT-CONTEXT.md +11 -8
  21. package/docs/ARCHITECTURE.md +67 -9
  22. package/docs/LEGACY-CLI-CROSSWALK.md +163 -0
  23. package/docs/PLAN.zh-CN.md +19 -13
  24. package/docs/RUNTIME-COMPAT-MATRIX.md +2 -2
  25. package/docs/WORKFLOW-LIFECYCLE.md +123 -59
  26. package/hooks/pre_stop_check.py +3 -2
  27. package/mcp-server/lib/baseline-workflow.cjs +84 -16
  28. package/mcp-server/lib/change-workflow.cjs +55 -35
  29. package/mcp-server/lib/context-spine.cjs +171 -105
  30. package/mcp-server/lib/git-bootstrap.cjs +240 -0
  31. package/mcp-server/lib/init-project.cjs +178 -41
  32. package/mcp-server/lib/project-breadcrumb.cjs +14 -14
  33. package/mcp-server/lib/state-db.cjs +257 -10
  34. package/mcp-server/lib/state-ops.cjs +64 -1
  35. package/mcp-server/lib/workflow-state.cjs +300 -205
  36. package/mcp-server/server.cjs +58 -34
  37. package/orchestrator/auto-merge.cjs +19 -0
  38. package/orchestrator/daemon.cjs +104 -0
  39. package/orchestrator/parallel-orchestrator.cjs +371 -49
  40. package/orchestrator/session-runner.cjs +354 -25
  41. package/package.json +2 -1
  42. package/skills/ultra-change/SKILL.md +69 -94
  43. package/skills/ultra-deliver/SKILL.md +37 -65
  44. package/skills/ultra-dev/SKILL.md +50 -52
  45. package/skills/ultra-doctor/SKILL.md +34 -49
  46. package/skills/ultra-init/SKILL.md +78 -105
  47. package/skills/ultra-plan/SKILL.md +78 -99
  48. package/skills/ultra-research/SKILL.md +99 -113
  49. package/skills/ultra-review/SKILL.md +38 -53
  50. package/skills/ultra-review/references/review-modes.md +5 -3
  51. package/skills/ultra-review/references/unified-schema.md +26 -6
  52. package/skills/ultra-review/scripts/review_wait.py +12 -0
  53. package/skills/ultra-status/SKILL.md +34 -44
  54. package/skills/ultra-test/SKILL.md +61 -54
  55. package/skills/ultra-think/SKILL.md +37 -51
  56. package/skills/ultra-think/references/decision-dialogue.md +87 -111
  57. package/spec/cli-protocol.md +35 -0
  58. package/spec/mcp-tools.yaml +145 -28
  59. package/spec/schemas/delivery-report.v1.schema.json +1 -46
  60. package/spec/schemas/state-db.sql +8 -2
  61. package/spec/schemas/test-report.v1.schema.json +45 -14
  62. package/ultra-tools/cli.cjs +1 -1
  63. package/ultra-tools/commands/session.cjs +10 -3
  64. package/ultra-tools/commands/status.cjs +19 -14
  65. package/ultra-tools/commands/system.cjs +2 -2
  66. package/ultra-tools/commands/task.cjs +3 -0
@@ -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
  };
@@ -0,0 +1,240 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { spawnSync } = require('node:child_process');
6
+
7
+ const GIT_MODES = new Set(['auto', 'initialize', 'skip']);
8
+ // No trailing slash: isolated session worktrees expose `.ultra` as a symlink
9
+ // to the central authority, and Git's directory-only pattern does not ignore
10
+ // that symlink reliably.
11
+ const ULTRA_IGNORE_LINE = '.ultra';
12
+
13
+ class GitBootstrapError extends Error {
14
+ constructor(code, message, { cause } = {}) {
15
+ super(message);
16
+ this.name = 'GitBootstrapError';
17
+ this.code = code;
18
+ if (cause) this.cause = cause;
19
+ }
20
+ }
21
+
22
+ function runGit(rootDir, args) {
23
+ return spawnSync('git', args, {
24
+ cwd: rootDir,
25
+ encoding: 'utf8',
26
+ stdio: ['ignore', 'pipe', 'pipe'],
27
+ });
28
+ }
29
+
30
+ function gitValue(rootDir, args) {
31
+ const result = runGit(rootDir, args);
32
+ return result.status === 0 ? result.stdout.trim() : null;
33
+ }
34
+
35
+ function inspectGitRepository(rootDir) {
36
+ const repositoryRoot = gitValue(rootDir, ['rev-parse', '--show-toplevel']);
37
+ if (!repositoryRoot) {
38
+ return {
39
+ present: false,
40
+ repository_root: null,
41
+ branch: null,
42
+ head: null,
43
+ };
44
+ }
45
+ return {
46
+ present: true,
47
+ repository_root: path.resolve(repositoryRoot),
48
+ branch: gitValue(rootDir, ['symbolic-ref', '--quiet', '--short', 'HEAD'])
49
+ || gitValue(rootDir, ['branch', '--show-current']),
50
+ head: gitValue(rootDir, ['rev-parse', '--verify', 'HEAD']),
51
+ };
52
+ }
53
+
54
+ function initializeRepository(rootDir) {
55
+ let result = runGit(rootDir, ['init', '-q', '-b', 'main']);
56
+ if (result.status !== 0) {
57
+ result = runGit(rootDir, ['init', '-q']);
58
+ if (result.status === 0) {
59
+ const branch = runGit(rootDir, ['symbolic-ref', 'HEAD', 'refs/heads/main']);
60
+ if (branch.status !== 0) result = branch;
61
+ }
62
+ }
63
+ if (result.status !== 0) {
64
+ throw new GitBootstrapError(
65
+ 'GIT_INIT_FAILED',
66
+ `git init failed: ${(result.stderr || result.stdout || 'unknown error').trim()}`,
67
+ );
68
+ }
69
+ }
70
+
71
+ function containsUltraIgnore(contents, { requireSymlinkSafe = false } = {}) {
72
+ const accepted = requireSymlinkSafe
73
+ ? ['.ultra', '/.ultra']
74
+ : ['.ultra', '.ultra/', '/.ultra', '/.ultra/'];
75
+ return contents.split(/\r?\n/).some((line) => {
76
+ const value = line.trim();
77
+ return accepted.includes(value);
78
+ });
79
+ }
80
+
81
+ function effectiveUltraIgnore(rootDir) {
82
+ return runGit(rootDir, ['check-ignore', '--quiet', '--no-index', '--', '.ultra']).status === 0;
83
+ }
84
+
85
+ function ensureUltraIgnored(rootDir, { requireSymlinkSafe = false } = {}) {
86
+ const file = path.join(rootDir, '.gitignore');
87
+ let stat = null;
88
+ try { stat = fs.lstatSync(file); }
89
+ catch (error) {
90
+ if (error.code !== 'ENOENT') throw error;
91
+ }
92
+ const existed = stat !== null;
93
+ if (stat) {
94
+ if (stat.isSymbolicLink() || !stat.isFile()) {
95
+ throw new GitBootstrapError(
96
+ 'GITIGNORE_UNSAFE',
97
+ `refusing to update non-regular root .gitignore: ${file}`,
98
+ );
99
+ }
100
+ }
101
+ const before = existed ? fs.readFileSync(file, 'utf8') : null;
102
+ if (!requireSymlinkSafe && effectiveUltraIgnore(rootDir)) {
103
+ return {
104
+ changed: false,
105
+ path: file,
106
+ rollback: { existed, before },
107
+ };
108
+ }
109
+ if (before !== null && containsUltraIgnore(before, { requireSymlinkSafe })) {
110
+ return {
111
+ changed: false,
112
+ path: file,
113
+ rollback: { existed, before },
114
+ };
115
+ }
116
+ const prefix = before && !before.endsWith('\n') ? `${before}\n` : (before || '');
117
+ const separator = prefix && !prefix.endsWith('\n\n') ? '\n' : '';
118
+ const next = `${prefix}${separator}# Ultra Builder Pro local authority\n${ULTRA_IGNORE_LINE}\n`;
119
+ fs.writeFileSync(file, next);
120
+ return {
121
+ changed: true,
122
+ path: file,
123
+ rollback: { existed, before },
124
+ };
125
+ }
126
+
127
+ function assertUltraIgnored(rootDir) {
128
+ const result = runGit(rootDir, ['check-ignore', '--quiet', '--no-index', '--', '.ultra']);
129
+ if (result.status === 0) return;
130
+ const detail = result.status === 1
131
+ ? 'the effective Git rules re-include .ultra'
132
+ : (result.stderr || result.stdout || 'Git could not evaluate the rule').trim();
133
+ throw new GitBootstrapError(
134
+ 'GITIGNORE_INEFFECTIVE',
135
+ `Ultra authority is not ignored by Git: ${detail}`,
136
+ );
137
+ }
138
+
139
+ function rollbackGitBootstrap(rootDir, internal) {
140
+ const errors = [];
141
+ if (!internal) return errors;
142
+ const gitignore = internal.gitignore;
143
+ if (gitignore?.changed) {
144
+ try {
145
+ if (gitignore.rollback.existed) {
146
+ fs.writeFileSync(gitignore.path, gitignore.rollback.before);
147
+ } else {
148
+ fs.rmSync(gitignore.path, { force: true });
149
+ }
150
+ } catch (error) {
151
+ errors.push(`gitignore restore failed: ${error.message}`);
152
+ }
153
+ }
154
+ if (internal.created_repository) {
155
+ try {
156
+ fs.rmSync(path.join(rootDir, '.git'), { recursive: true, force: true });
157
+ } catch (error) {
158
+ errors.push(`git repository rollback failed: ${error.message}`);
159
+ }
160
+ }
161
+ return errors;
162
+ }
163
+
164
+ function bootstrapGit(rootDir, { mode = 'auto' } = {}) {
165
+ if (!GIT_MODES.has(mode)) {
166
+ throw new GitBootstrapError(
167
+ 'VALIDATION_ERROR',
168
+ `git_mode must be one of: ${[...GIT_MODES].join(', ')}`,
169
+ );
170
+ }
171
+ const before = inspectGitRepository(rootDir);
172
+ if (!before.present && mode === 'skip') {
173
+ return {
174
+ result: {
175
+ requested_mode: mode,
176
+ status: 'skipped',
177
+ repository_root: null,
178
+ branch: null,
179
+ head: null,
180
+ gitignore_path: null,
181
+ gitignore_changed: false,
182
+ initial_commit_required: false,
183
+ },
184
+ internal: null,
185
+ };
186
+ }
187
+
188
+ const internal = {
189
+ created_repository: false,
190
+ gitignore: null,
191
+ };
192
+ try {
193
+ if (!before.present) {
194
+ initializeRepository(rootDir);
195
+ internal.created_repository = true;
196
+ }
197
+ internal.gitignore = ensureUltraIgnored(rootDir, {
198
+ // A repository created in this call has no tracked baseline to preserve,
199
+ // so its ignore rule can be made safe for the session authority symlink.
200
+ requireSymlinkSafe: internal.created_repository,
201
+ });
202
+ assertUltraIgnored(rootDir);
203
+ const after = inspectGitRepository(rootDir);
204
+ if (!after.present) {
205
+ throw new GitBootstrapError(
206
+ 'GIT_INIT_FAILED',
207
+ 'git initialization completed without a discoverable repository',
208
+ );
209
+ }
210
+ return {
211
+ result: {
212
+ requested_mode: mode,
213
+ status: internal.created_repository ? 'initialized' : 'existing',
214
+ repository_root: after.repository_root,
215
+ branch: after.branch,
216
+ head: after.head,
217
+ gitignore_path: path.relative(rootDir, internal.gitignore.path) || '.gitignore',
218
+ gitignore_changed: internal.gitignore.changed,
219
+ initial_commit_required: after.head === null,
220
+ },
221
+ internal,
222
+ };
223
+ } catch (error) {
224
+ rollbackGitBootstrap(rootDir, internal);
225
+ if (error instanceof GitBootstrapError) throw error;
226
+ throw new GitBootstrapError(
227
+ 'GIT_INIT_FAILED',
228
+ `Git bootstrap failed: ${error.message}`,
229
+ { cause: error },
230
+ );
231
+ }
232
+ }
233
+
234
+ module.exports = {
235
+ GitBootstrapError,
236
+ GIT_MODES,
237
+ bootstrapGit,
238
+ inspectGitRepository,
239
+ rollbackGitBootstrap,
240
+ };