ultra-builder-pro-cli 0.23.0 → 0.24.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 (95) hide show
  1. package/.ultra-template/docs/research/README.md +4 -4
  2. package/.ultra-template/tasks/tasks.json +4 -2
  3. package/CHANGELOG.md +72 -0
  4. package/README.md +55 -39
  5. package/adapters/_shared/codex-assets.cjs +121 -65
  6. package/adapters/_shared/interaction-contract.cjs +7 -1
  7. package/adapters/_shared/provenance.cjs +7 -2
  8. package/adapters/_shared/runtime-assets.cjs +3 -2
  9. package/adapters/_shared/runtime-native.cjs +132 -0
  10. package/adapters/claude.js +60 -30
  11. package/adapters/codex.js +178 -81
  12. package/adapters/grok.js +372 -0
  13. package/adapters/kimi.js +7 -3
  14. package/adapters/opencode.js +210 -32
  15. package/agents/code-reviewer.md +4 -2
  16. package/agents/debugger.md +4 -1
  17. package/agents/review-code.md +7 -3
  18. package/agents/review-comments.md +7 -4
  19. package/agents/review-coordinator.md +11 -7
  20. package/agents/review-design.md +8 -4
  21. package/agents/review-errors.md +8 -4
  22. package/agents/review-spec.md +8 -5
  23. package/agents/review-tests.md +7 -4
  24. package/agents/tdd-runner.md +5 -2
  25. package/bin/install.js +9 -4
  26. package/bin/orchestrator.js +4 -13
  27. package/docs/AGENT-CONTEXT.md +143 -229
  28. package/docs/ARCHITECTURE.md +205 -494
  29. package/docs/ARTIFACT-AUTHORITY.md +35 -28
  30. package/docs/LEGACY-CLI-CROSSWALK.md +1 -1
  31. package/docs/PLUGIN-ISOLATION-CONTRACT.md +12 -9
  32. package/docs/ROADMAP.md +85 -110
  33. package/docs/RUNTIME-COMPAT-MATRIX.md +139 -175
  34. package/docs/SKILL-AUTHORING.md +3 -3
  35. package/docs/STATE-DB-ACCESS-POLICY.md +13 -8
  36. package/docs/WORKFLOW-LIFECYCLE.md +189 -448
  37. package/hooks/README.md +11 -8
  38. package/hooks/active_task_context.py +16 -16
  39. package/hooks/adapters/grok.py +134 -0
  40. package/hooks/{context_spine.py → context_envelope.py} +26 -22
  41. package/hooks/health_check.py +29 -34
  42. package/hooks/pre_stop_check.py +22 -12
  43. package/hooks/subagent_tracker.py +1 -1
  44. package/hooks/workflow_checkpoint.py +14 -13
  45. package/hooks/workflow_context.py +11 -12
  46. package/hooks/workflow_resume.py +14 -13
  47. package/mcp-server/{breadcrumb.cjs → hook-context.cjs} +7 -9
  48. package/mcp-server/lib/baseline-workflow.cjs +108 -82
  49. package/mcp-server/lib/canonical-json.cjs +19 -0
  50. package/mcp-server/lib/change-authority.cjs +26 -0
  51. package/mcp-server/lib/change-workflow.cjs +630 -45
  52. package/mcp-server/lib/context-envelope.cjs +508 -0
  53. package/mcp-server/lib/decision-records.cjs +337 -0
  54. package/mcp-server/lib/doctor.cjs +91 -33
  55. package/mcp-server/lib/init-project.cjs +78 -39
  56. package/mcp-server/lib/managed-file-write.cjs +116 -0
  57. package/mcp-server/lib/plan-checkpoint.cjs +177 -0
  58. package/mcp-server/lib/project-context.cjs +178 -0
  59. package/mcp-server/lib/stage-checkpoints.cjs +309 -0
  60. package/mcp-server/lib/state-db.cjs +155 -24
  61. package/mcp-server/lib/state-ops.cjs +21 -3
  62. package/mcp-server/lib/task-ledger.cjs +522 -13
  63. package/mcp-server/lib/ultra-facade.cjs +757 -442
  64. package/mcp-server/lib/worker-packet.cjs +358 -0
  65. package/mcp-server/server.cjs +200 -1103
  66. package/orchestrator/daemon.cjs +27 -9
  67. package/orchestrator/parallel-orchestrator.cjs +111 -76
  68. package/orchestrator/session-runner.cjs +135 -34
  69. package/package.json +10 -3
  70. package/skills/ultra-change/SKILL.md +10 -8
  71. package/skills/ultra-deliver/SKILL.md +17 -19
  72. package/skills/ultra-dev/SKILL.md +20 -11
  73. package/skills/ultra-doctor/SKILL.md +4 -4
  74. package/skills/ultra-init/SKILL.md +6 -5
  75. package/skills/ultra-init/references/brownfield-adoption.md +20 -14
  76. package/skills/ultra-plan/SKILL.md +13 -9
  77. package/skills/ultra-research/SKILL.md +18 -9
  78. package/skills/ultra-review/SKILL.md +14 -9
  79. package/skills/ultra-review/references/unified-schema.md +2 -0
  80. package/skills/ultra-review/scripts/review_wait.py +8 -0
  81. package/skills/ultra-status/SKILL.md +2 -2
  82. package/skills/ultra-test/SKILL.md +10 -6
  83. package/skills/ultra-think/SKILL.md +8 -7
  84. package/skills/ultra-think/references/interaction-boundary.md +56 -0
  85. package/spec/cli-protocol.md +80 -199
  86. package/spec/mcp-tools.yaml +43 -2806
  87. package/spec/schemas/state-db.sql +107 -4
  88. package/spec/schemas/{task-ledger.v1.schema.json → task-ledger.v2.schema.json} +70 -6
  89. package/ultra-tools/commands/status.cjs +369 -243
  90. package/mcp-server/lib/context-spine.cjs +0 -1208
  91. package/mcp-server/lib/decision-dialogue.cjs +0 -1128
  92. package/mcp-server/lib/project-breadcrumb.cjs +0 -173
  93. package/mcp-server/lib/spec-learning.cjs +0 -284
  94. package/mcp-server/lib/workflow-state.cjs +0 -3129
  95. package/skills/ultra-think/references/decision-dialogue.md +0 -155
@@ -1,16 +1,16 @@
1
1
  #!/usr/bin/env python3
2
- """Re-inject live DB authority after compaction; checkpoints are advisory only."""
2
+ """Re-inject the live Context Envelope after compaction; checkpoints are advisory."""
3
3
 
4
4
  import json
5
5
  import sys
6
6
  from datetime import datetime
7
7
  from pathlib import Path
8
8
 
9
- from context_spine import (
10
- ContextSpineError,
9
+ from context_envelope import (
10
+ ContextEnvelopeError,
11
11
  find_root_for_hook,
12
- read_breadcrumb,
13
- render_breadcrumb,
12
+ read_context_envelope,
13
+ render_context_envelope,
14
14
  )
15
15
 
16
16
 
@@ -35,10 +35,11 @@ def _checkpoint_advisory(root: Path):
35
35
  return None
36
36
  if (
37
37
  not isinstance(value, dict)
38
- or value.get("schema") != 2
39
- or not isinstance(value.get("breadcrumb"), dict)
38
+ or value.get("schema") != 3
39
+ or not isinstance(value.get("context"), dict)
40
40
  or not isinstance(value.get("rendered"), str)
41
41
  or not value["rendered"].strip()
42
+ or value.get("context_digest") != value["context"].get("digest")
42
43
  ):
43
44
  print(f"[workflow_resume] invalid checkpoint schema in {file}", file=sys.stderr)
44
45
  return None
@@ -46,7 +47,7 @@ def _checkpoint_advisory(root: Path):
46
47
  "[Ultra checkpoint advisory]",
47
48
  "Live .ultra/.runtime/state.db could not be read; this checkpoint is not authority.",
48
49
  value["rendered"],
49
- "Run ultra-doctor before mutation.",
50
+ "Run ultra.doctor before mutation.",
50
51
  ])
51
52
 
52
53
 
@@ -66,11 +67,11 @@ def main() -> None:
66
67
  return
67
68
  text = None
68
69
  try:
69
- breadcrumb = read_breadcrumb(root)
70
- if breadcrumb and breadcrumb.get("workflow"):
71
- text = render_breadcrumb(root, breadcrumb)
72
- except ContextSpineError as exc:
73
- print(f"[workflow_resume] cannot inspect Context Spine: {exc}", file=sys.stderr)
70
+ envelope = read_context_envelope(root)
71
+ if envelope:
72
+ text = render_context_envelope(root, envelope)
73
+ except ContextEnvelopeError as exc:
74
+ print(f"[workflow_resume] cannot inspect Context Envelope: {exc}", file=sys.stderr)
74
75
  text = _checkpoint_advisory(root)
75
76
  if not text:
76
77
  print(json.dumps({}))
@@ -13,18 +13,16 @@ function main() {
13
13
  const requested = path.resolve(process.argv[discover ? 3 : 2] || process.cwd());
14
14
  const rootDir = discover ? runtime.findProjectRoot(requested) : requested;
15
15
  if (!rootDir) {
16
- process.stdout.write(`${JSON.stringify({
17
- root: null,
18
- breadcrumb: null,
19
- text: null,
20
- })}\n`);
16
+ process.stdout.write(`${JSON.stringify({ root: null, context: null, text: null })}\n`);
21
17
  return;
22
18
  }
23
- const breadcrumb = runtime.readProjectBreadcrumb(rootDir);
19
+ const context = runtime.readProjectContextEnvelope(rootDir, {
20
+ runtime: process.env.UBP_HOOK_RUNTIME || 'hook',
21
+ });
24
22
  process.stdout.write(`${JSON.stringify({
25
23
  root: rootDir,
26
- breadcrumb,
27
- text: breadcrumb ? runtime.renderProjectBreadcrumb(rootDir, breadcrumb) : null,
24
+ context,
25
+ text: context ? runtime.renderProjectContextEnvelope(rootDir, context) : null,
28
26
  })}\n`);
29
27
  }
30
28
 
@@ -32,7 +30,7 @@ if (require.main === module) {
32
30
  try {
33
31
  main();
34
32
  } catch (error) {
35
- process.stderr.write(`${error.code || 'BREADCRUMB_FAILED'}: ${error.message}\n`);
33
+ process.stderr.write(`${error.code || 'CONTEXT_ENVELOPE_FAILED'}: ${error.message}\n`);
36
34
  process.exitCode = 2;
37
35
  }
38
36
  }
@@ -7,7 +7,7 @@ const { spawnSync } = require('node:child_process');
7
7
 
8
8
  const ops = require('./state-ops.cjs');
9
9
  const providerRefs = require('./provider-refs.cjs');
10
- const workflows = require('./workflow-state.cjs');
10
+ const checkpoints = require('./stage-checkpoints.cjs');
11
11
 
12
12
  const BASELINE_ID = /^[a-zA-Z0-9_-]+$/;
13
13
  const MODES = new Set(['greenfield', 'brownfield']);
@@ -660,27 +660,10 @@ function storedReadyInvariantBlockers(baseline) {
660
660
  if (!Array.isArray(baseline.scope) || baseline.scope.length === 0) {
661
661
  blockers.push('BASELINE_SCOPE_MISSING');
662
662
  }
663
- if (Array.isArray(baseline.spec_refs)) {
664
- for (const kind of ['discovery', 'product', 'architecture']) {
665
- if (!baseline.spec_refs.some((item) => item?.kind === kind)) {
666
- blockers.push(`BASELINE_SPEC_MISSING:${kind}`);
667
- }
668
- }
669
- }
670
- if (!Array.isArray(baseline.evidence) || baseline.evidence.length === 0) {
671
- blockers.push('BASELINE_EVIDENCE_MISSING');
672
- } else if (baseline.mode === 'brownfield'
673
- && !baseline.evidence.some((item) => item?.kind === 'source')) {
674
- blockers.push('BASELINE_SOURCE_EVIDENCE_MISSING');
675
- }
676
- if (!Array.isArray(baseline.verification) || baseline.verification.length === 0) {
677
- blockers.push('BASELINE_VERIFICATION_MISSING');
678
- } else {
663
+ if (Array.isArray(baseline.verification)) {
679
664
  for (const item of baseline.verification) {
680
665
  if (!item || typeof item !== 'object') {
681
666
  blockers.push('BASELINE_STATE_INVALID:verification');
682
- } else if (item.status === 'not_run') {
683
- blockers.push(`BASELINE_VERIFICATION_NOT_RUN:${item.name || 'unknown'}`);
684
667
  } else if (item.status === 'known_red') {
685
668
  if (!String(item.rationale || '').trim()) {
686
669
  blockers.push(`BASELINE_KNOWN_RED_RATIONALE_MISSING:${item.name || 'unknown'}`);
@@ -688,7 +671,7 @@ function storedReadyInvariantBlockers(baseline) {
688
671
  if (!baseline.known_red_accepted) {
689
672
  blockers.push(`BASELINE_KNOWN_RED_NOT_ACCEPTED:${item.name || 'unknown'}`);
690
673
  }
691
- } else if (item.status !== 'pass') {
674
+ } else if (!['pass', 'not_run'].includes(item.status)) {
692
675
  blockers.push(`BASELINE_VERIFICATION_STATUS_INVALID:${item.name || 'unknown'}`);
693
676
  }
694
677
  }
@@ -697,68 +680,103 @@ function storedReadyInvariantBlockers(baseline) {
697
680
  blockers.push('BASELINE_REVISION_MISSING');
698
681
  }
699
682
  if (!String(baseline.approved_by || '').trim()) blockers.push('BASELINE_APPROVER_MISSING');
700
- if (String(baseline.approval_note || '').trim().length < 3) {
683
+ if (!String(baseline.approval_note || '').trim()) {
701
684
  blockers.push('BASELINE_APPROVAL_NOTE_MISSING');
702
685
  }
703
686
  if (!baseline.converged_at || Number.isNaN(Date.parse(baseline.converged_at))) {
704
687
  blockers.push('BASELINE_CONVERGENCE_TIMESTAMP_MISSING');
705
688
  }
689
+ return [...new Set(blockers)];
690
+ }
691
+
692
+ function storedReadySemanticWarnings(baseline) {
693
+ const warnings = [];
694
+ if (Array.isArray(baseline.spec_refs)) {
695
+ for (const kind of ['discovery', 'product', 'architecture']) {
696
+ if (!baseline.spec_refs.some((item) => item?.kind === kind)) {
697
+ warnings.push(`BASELINE_SPEC_MISSING:${kind}`);
698
+ }
699
+ }
700
+ }
701
+ if (Array.isArray(baseline.evidence) && baseline.evidence.length === 0) {
702
+ warnings.push('BASELINE_EVIDENCE_MISSING');
703
+ }
704
+ if (baseline.mode === 'brownfield'
705
+ && Array.isArray(baseline.evidence)
706
+ && !baseline.evidence.some((item) => item?.kind === 'source')) {
707
+ warnings.push('BASELINE_SOURCE_EVIDENCE_MISSING');
708
+ }
709
+ if (Array.isArray(baseline.verification) && baseline.verification.length === 0) {
710
+ warnings.push('BASELINE_VERIFICATION_MISSING');
711
+ } else if (Array.isArray(baseline.verification)) {
712
+ for (const item of baseline.verification) {
713
+ if (item?.status === 'not_run') {
714
+ warnings.push(`BASELINE_VERIFICATION_NOT_RUN:${item.name || 'unknown'}`);
715
+ }
716
+ }
717
+ }
706
718
  if (Array.isArray(baseline.unknowns)) {
707
719
  for (const item of baseline.unknowns.filter((unknown) => unknown?.blocking === true)) {
708
- blockers.push(`BASELINE_UNKNOWN_BLOCKING:${item.summary || 'unknown'}`);
720
+ warnings.push(`BASELINE_UNKNOWN_RECORDED:${item.summary || 'unknown'}`);
709
721
  }
710
722
  }
711
723
  if (Array.isArray(baseline.gaps)) {
712
724
  for (const gap of baseline.gaps.filter((item) => (
713
725
  item?.status === 'open' && item?.blocking === true
714
726
  ))) {
715
- blockers.push(`BASELINE_GAP_BLOCKING:${gap.id || 'unknown'}`);
727
+ warnings.push(`BASELINE_GAP_RECORDED:${gap.id || 'unknown'}`);
716
728
  }
717
729
  }
718
- return [...new Set(blockers)];
730
+ return [...new Set(warnings)];
719
731
  }
720
732
 
721
- function completedBaselineResearch(db, baseline, rootDir) {
722
- const acceptedMode = baseline.mode === 'greenfield' ? 'full' : 'adoption';
723
- const rows = db.prepare(
724
- `SELECT id FROM workflow_runs
725
- WHERE kind = 'research' AND baseline_id = ? AND mode = ? AND status = 'completed'
726
- ORDER BY completed_at DESC, rowid DESC`,
727
- ).all(baseline.id, acceptedMode);
728
- const row = rows.find((candidate) => workflows.isConsumableWorkflowAuthority(
729
- workflows.readWorkflow(db, candidate.id, { rootDir }),
730
- ));
731
- return row ? workflows.readWorkflow(db, row.id, { rootDir }) : null;
733
+ function acceptedBaselineResearch(db, baseline) {
734
+ return checkpoints.currentCheckpoint(
735
+ db,
736
+ 'research',
737
+ { baseline_id: baseline.id },
738
+ { includeDraft: false },
739
+ );
732
740
  }
733
741
 
734
- function convergenceResearchBlockers(db, baseline, rootDir) {
735
- const run = completedBaselineResearch(db, baseline, rootDir);
736
- if (!run) return ['BASELINE_RESEARCH_INCOMPLETE'];
737
- return run.artifact_health.blockers.map((item) => `BASELINE_RESEARCH_${item}`);
742
+ function convergenceResearchBlockers(db, baseline) {
743
+ const checkpoint = acceptedBaselineResearch(db, baseline);
744
+ if (!checkpoint) return ['BASELINE_RESEARCH_INCOMPLETE'];
745
+ return [];
738
746
  }
739
747
 
740
- function storedResearchHealth(db, baseline, rootDir) {
741
- if (!baseline.research_run_id) {
742
- return { blockers: ['BASELINE_RESEARCH_PROVENANCE_MISSING'], warnings: [] };
748
+ function storedResearchHealth(db, baseline) {
749
+ const checkpointId = baseline.research_checkpoint_id || baseline.research_run_id;
750
+ if (!checkpointId) {
751
+ return { blockers: [], warnings: ['BASELINE_RESEARCH_PROVENANCE_MISSING'] };
752
+ }
753
+ let checkpoint;
754
+ try {
755
+ checkpoint = checkpoints.readCheckpoint(db, checkpointId);
756
+ } catch (error) {
757
+ if (error?.code === 'CHECKPOINT_DIGEST_MISMATCH') {
758
+ return { blockers: ['BASELINE_RESEARCH_RECORD_INVALID'], warnings: [] };
759
+ }
760
+ throw error;
743
761
  }
744
- const run = workflows.readWorkflow(db, baseline.research_run_id, { rootDir });
745
- if (!run || run.kind !== 'research' || run.status !== 'completed') {
762
+ if (!checkpoint
763
+ || checkpoint.stage !== 'research'
764
+ || checkpoint.scope_type !== 'baseline'
765
+ || checkpoint.scope_id !== baseline.id
766
+ || checkpoints.checkpointDigest(checkpoint) !== checkpoint.digest) {
746
767
  return { blockers: ['BASELINE_RESEARCH_RECORD_INVALID'], warnings: [] };
747
768
  }
748
- const livingSpecifications = new Set(
749
- (Array.isArray(baseline.spec_refs) ? baseline.spec_refs : [])
750
- .map((item) => item?.path).filter(Boolean),
751
- );
752
- const blockers = run.artifact_health.blockers.filter((item) => {
753
- const separator = item.indexOf(':');
754
- if (separator < 0) return true;
755
- const artifactPath = item.slice(separator + 1);
756
- return !livingSpecifications.has(artifactPath);
757
- });
758
- return {
759
- blockers: blockers.map((item) => `BASELINE_RESEARCH_${item}`),
760
- warnings: [],
761
- };
769
+ if (checkpoint.status !== 'accepted') {
770
+ return {
771
+ blockers: [],
772
+ warnings: [
773
+ checkpoint.status === 'superseded'
774
+ ? 'BASELINE_RESEARCH_SUPERSEDED'
775
+ : `BASELINE_RESEARCH_NOT_ACCEPTED:${checkpoint.status}`,
776
+ ],
777
+ };
778
+ }
779
+ return { blockers: [], warnings: checkpoint.diagnostics || [] };
762
780
  }
763
781
 
764
782
  function convergenceBlockers(db, baseline, input, rootDir) {
@@ -777,10 +795,6 @@ function convergenceBlockers(db, baseline, input, rootDir) {
777
795
  if (baseline.repository_branch && snapshot.branch
778
796
  && baseline.repository_branch !== snapshot.branch) blockers.add('BASELINE_BRANCH_STALE');
779
797
  if (!Array.isArray(baseline.scope) || baseline.scope.length === 0) blockers.add('BASELINE_SCOPE_MISSING');
780
- for (const kind of ['discovery', 'product', 'architecture']) {
781
- if (!baseline.spec_refs.some((ref) => ref.kind === kind)) blockers.add(`BASELINE_SPEC_MISSING:${kind}`);
782
- }
783
- for (const blocker of convergenceResearchBlockers(db, baseline, rootDir)) blockers.add(blocker);
784
798
  for (const blocker of storedSpecBlockers(baseline, rootDir)) blockers.add(blocker);
785
799
  for (const blocker of storedEvidenceBlockers(baseline, rootDir)) blockers.add(blocker);
786
800
  for (const blocker of storedScopeBlockers(baseline, rootDir)) blockers.add(blocker);
@@ -791,29 +805,23 @@ function convergenceBlockers(db, baseline, input, rootDir) {
791
805
  if (baseline.worktree_state === 'dirty' && input.accept_dirty_worktree !== true) {
792
806
  blockers.add('BASELINE_DIRTY_WORKTREE_NOT_ACCEPTED');
793
807
  }
794
- if (baseline.evidence.length === 0) blockers.add('BASELINE_EVIDENCE_MISSING');
795
- if (baseline.mode === 'brownfield' && !baseline.evidence.some((item) => item.kind === 'source')) {
796
- blockers.add('BASELINE_SOURCE_EVIDENCE_MISSING');
797
- }
798
- if (baseline.verification.length === 0) blockers.add('BASELINE_VERIFICATION_MISSING');
799
808
  for (const item of baseline.verification) {
800
- if (item.status === 'not_run') blockers.add(`BASELINE_VERIFICATION_NOT_RUN:${item.name}`);
801
809
  if (item.status === 'known_red') {
802
810
  if (input.accept_known_red !== true) blockers.add(`BASELINE_KNOWN_RED_NOT_ACCEPTED:${item.name}`);
803
811
  if (!item.rationale) blockers.add(`BASELINE_KNOWN_RED_RATIONALE_MISSING:${item.name}`);
804
812
  }
805
813
  }
806
- for (const item of baseline.unknowns.filter((unknown) => unknown.blocking)) {
807
- blockers.add(`BASELINE_UNKNOWN_BLOCKING:${item.summary}`);
808
- }
809
- for (const gap of baseline.gaps.filter((item) => item.status === 'open' && item.blocking)) {
810
- blockers.add(`BASELINE_GAP_BLOCKING:${gap.id}`);
811
- }
812
814
  if (!String(input.approved_by || '').trim()) blockers.add('BASELINE_APPROVER_REQUIRED');
813
- if (String(input.approval_note || '').trim().length < 3) blockers.add('BASELINE_APPROVAL_NOTE_REQUIRED');
815
+ if (!String(input.approval_note || '').trim()) blockers.add('BASELINE_APPROVAL_NOTE_REQUIRED');
814
816
  return [...blockers].sort();
815
817
  }
816
818
 
819
+ function convergenceWarnings(db, baseline) {
820
+ const warnings = new Set(storedReadySemanticWarnings(baseline));
821
+ for (const warning of convergenceResearchBlockers(db, baseline)) warnings.add(warning);
822
+ return [...warnings].sort();
823
+ }
824
+
817
825
  function convergeBaseline(db, input = {}, { rootDir = process.cwd(), emitEvent = true } = {}) {
818
826
  const current = readBaseline(db, input.id);
819
827
  if (!current) throw new BaselineWorkflowError('BASELINE_NOT_FOUND', `baseline ${input.id} not found`);
@@ -821,6 +829,7 @@ function convergeBaseline(db, input = {}, { rootDir = process.cwd(), emitEvent =
821
829
  throw new BaselineWorkflowError('BASELINE_NOT_CONVERGEABLE', `baseline ${current.id} is ${current.status}`);
822
830
  }
823
831
  const blockers = convergenceBlockers(db, current, input, rootDir);
832
+ const warnings = convergenceWarnings(db, current);
824
833
  const ts = nowIso();
825
834
  if (blockers.length > 0) {
826
835
  ops.tx(db, () => {
@@ -832,18 +841,25 @@ function convergeBaseline(db, input = {}, { rootDir = process.cwd(), emitEvent =
832
841
  });
833
842
  }
834
843
  });
835
- return { ready: false, status: 'blocked', blockers, baseline: readBaseline(db, current.id) };
844
+ return {
845
+ ready: false,
846
+ status: 'blocked',
847
+ blockers,
848
+ warnings,
849
+ baseline: readBaseline(db, current.id),
850
+ };
836
851
  }
837
- const research = completedBaselineResearch(db, current, rootDir);
852
+ const research = acceptedBaselineResearch(db, current);
838
853
  ops.tx(db, () => {
839
854
  db.prepare(
840
855
  `UPDATE baselines SET status = 'ready', approved_by = ?, approval_note = ?,
841
- worktree_accepted = ?, known_red_accepted = ?, research_run_id = ?,
856
+ worktree_accepted = ?, known_red_accepted = ?, research_checkpoint_id = ?,
857
+ research_run_id = NULL,
842
858
  converged_at = ?, updated_at = ? WHERE id = ?`,
843
859
  ).run(
844
860
  String(input.approved_by).trim(), String(input.approval_note).trim(),
845
861
  input.accept_dirty_worktree === true ? 1 : 0,
846
- input.accept_known_red === true ? 1 : 0, research.id, ts, ts, current.id,
862
+ input.accept_known_red === true ? 1 : 0, research?.id || null, ts, ts, current.id,
847
863
  );
848
864
  if (emitEvent) {
849
865
  ops.appendEventInTx(db, {
@@ -851,7 +867,13 @@ function convergeBaseline(db, input = {}, { rootDir = process.cwd(), emitEvent =
851
867
  });
852
868
  }
853
869
  });
854
- return { ready: true, status: 'ready', blockers: [], baseline: readBaseline(db, current.id) };
870
+ return {
871
+ ready: true,
872
+ status: 'ready',
873
+ blockers: [],
874
+ warnings,
875
+ baseline: readBaseline(db, current.id),
876
+ };
855
877
  }
856
878
 
857
879
  function inspectBaseline(db, { rootDir = process.cwd(), id } = {}) {
@@ -874,7 +896,7 @@ function inspectBaseline(db, { rootDir = process.cwd(), id } = {}) {
874
896
  status: 'fail', blockers: [`BASELINE_NOT_READY:${baseline.status}`], warnings: [], baseline,
875
897
  };
876
898
  }
877
- const research = storedResearchHealth(db, baseline, rootDir);
899
+ const research = storedResearchHealth(db, baseline);
878
900
  const blockers = [
879
901
  ...storedReadyInvariantBlockers(baseline),
880
902
  ...(Array.isArray(baseline.scope) ? storedScopeBlockers(baseline, rootDir) : []),
@@ -882,6 +904,10 @@ function inspectBaseline(db, { rootDir = process.cwd(), id } = {}) {
882
904
  ...(Array.isArray(baseline.evidence) ? storedEvidenceBlockers(baseline, rootDir) : []),
883
905
  ...research.blockers,
884
906
  ];
907
+ const warnings = [
908
+ ...storedReadySemanticWarnings(baseline),
909
+ ...research.warnings,
910
+ ];
885
911
  const snapshot = gitWorktreeSnapshot(
886
912
  rootDir, Array.isArray(baseline.scope) && baseline.scope.length > 0 ? baseline.scope : ['.'],
887
913
  );
@@ -911,7 +937,7 @@ function inspectBaseline(db, { rootDir = process.cwd(), id } = {}) {
911
937
  }
912
938
  return {
913
939
  status: blockers.length === 0 ? 'pass' : 'fail', blockers: [...new Set(blockers)].sort(),
914
- warnings: research.warnings, baseline,
940
+ warnings: [...new Set(warnings)], baseline,
915
941
  };
916
942
  }
917
943
 
@@ -0,0 +1,19 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('node:crypto');
4
+
5
+ function canonicalJson(value) {
6
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
7
+ if (value && typeof value === 'object') {
8
+ return `{${Object.keys(value).sort().map(
9
+ (key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`,
10
+ ).join(',')}}`;
11
+ }
12
+ return JSON.stringify(value);
13
+ }
14
+
15
+ function digest(value) {
16
+ return crypto.createHash('sha256').update(canonicalJson(value)).digest('hex');
17
+ }
18
+
19
+ module.exports = { canonicalJson, digest };
@@ -0,0 +1,26 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('node:crypto');
4
+
5
+ function parseJson(value, fallback = {}) {
6
+ if (typeof value !== 'string' || !value.trim()) return fallback;
7
+ try { return JSON.parse(value); } catch { return fallback; }
8
+ }
9
+
10
+ function changeStateDigest(change) {
11
+ if (!change) return null;
12
+ const payload = {
13
+ id: change.id,
14
+ kind: change.kind,
15
+ intent: change.intent,
16
+ docs_impact: change.docs_impact || parseJson(change.docs_impact_json, {}),
17
+ provider_refs: change.provider_refs || parseJson(change.provider_refs_json, {}),
18
+ contract: change.contract || parseJson(change.contract_json, {}),
19
+ classification: change.classification || parseJson(change.classification_json, {}),
20
+ research_disposition: change.research_disposition
21
+ || parseJson(change.research_disposition_json, {}),
22
+ };
23
+ return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex');
24
+ }
25
+
26
+ module.exports = { changeStateDigest };