ultra-builder-pro-cli 0.9.0 → 0.11.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 (119) hide show
  1. package/CHANGELOG.md +92 -1
  2. package/README.md +11 -6
  3. package/adapters/_shared/codex-assets.cjs +23 -331
  4. package/adapters/_shared/handbook.cjs +2 -0
  5. package/adapters/_shared/runtime-assets.cjs +25 -0
  6. package/adapters/_shared/tests/handbook.test.cjs +3 -0
  7. package/adapters/_shared/tests/runtime-assets.test.cjs +12 -4
  8. package/adapters/claude.js +28 -5
  9. package/adapters/kimi.js +15 -138
  10. package/adapters/opencode.js +59 -63
  11. package/adapters/tests/claude.test.cjs +26 -9
  12. package/adapters/tests/codex.test.cjs +23 -9
  13. package/adapters/tests/kimi.test.cjs +15 -11
  14. package/adapters/tests/opencode.test.cjs +57 -10
  15. package/agents/code-reviewer.md +24 -85
  16. package/agents/debugger.md +30 -132
  17. package/agents/review-code.md +24 -104
  18. package/agents/review-comments.md +20 -83
  19. package/agents/review-coordinator.md +40 -137
  20. package/agents/review-design.md +21 -118
  21. package/agents/review-errors.md +22 -70
  22. package/agents/review-spec.md +36 -0
  23. package/agents/review-tests.md +20 -76
  24. package/agents/tdd-runner.md +26 -84
  25. package/commands/learn.md +5 -36
  26. package/commands/ultra-change.md +5 -43
  27. package/commands/ultra-deliver.md +4 -46
  28. package/commands/ultra-dev.md +4 -48
  29. package/commands/ultra-doctor.md +4 -30
  30. package/commands/ultra-init.md +5 -47
  31. package/commands/ultra-plan.md +5 -48
  32. package/commands/ultra-research.md +5 -54
  33. package/commands/ultra-status.md +5 -44
  34. package/commands/ultra-test.md +5 -45
  35. package/commands/ultra-think.md +5 -40
  36. package/docs/AGENT-CONTEXT.md +34 -12
  37. package/docs/ARCHITECTURE.md +31 -5
  38. package/docs/PLAN.zh-CN.md +3 -2
  39. package/docs/ROADMAP.md +5 -4
  40. package/docs/RUNTIME-COMPAT-MATRIX.md +7 -5
  41. package/docs/SKILL-AUTHORING.md +75 -0
  42. package/docs/USER-HANDBOOK-CONTRACT.md +9 -2
  43. package/hooks/README.md +6 -4
  44. package/hooks/active_task_context.py +14 -0
  45. package/hooks/context_spine.py +218 -0
  46. package/hooks/health_check.py +1 -1
  47. package/hooks/workflow_context.py +16 -123
  48. package/hooks/workflow_resume.py +21 -0
  49. package/mcp-server/lib/change-workflow.cjs +89 -11
  50. package/mcp-server/lib/change-workflow.test.cjs +202 -7
  51. package/mcp-server/lib/context-spine.cjs +331 -0
  52. package/mcp-server/lib/context-spine.test.cjs +73 -0
  53. package/mcp-server/lib/spec-learning.cjs +191 -0
  54. package/mcp-server/lib/spec-learning.test.cjs +62 -0
  55. package/mcp-server/lib/state-db.cjs +72 -10
  56. package/mcp-server/lib/state-db.test.cjs +7 -2
  57. package/mcp-server/server.cjs +31 -1
  58. package/mcp-server/tests/db-ops.test.cjs +3 -3
  59. package/mcp-server/tests/server-contract.test.cjs +74 -0
  60. package/mcp-server/tests/state-db.test.cjs +20 -3
  61. package/package.json +3 -2
  62. package/skills/cc-collab/SKILL.md +16 -18
  63. package/skills/code-review-expert/SKILL.md +39 -114
  64. package/skills/code-review-expert/references/correctness-reliability.md +30 -0
  65. package/skills/code-review-expert/references/design-boundaries.md +26 -0
  66. package/skills/code-review-expert/references/removal-plan.md +17 -45
  67. package/skills/codex-collab/SKILL.md +23 -28
  68. package/skills/codex-collab/evals/evals.json +18 -23
  69. package/skills/integration-rules/SKILL.md +28 -67
  70. package/skills/learn/SKILL.md +46 -105
  71. package/skills/security-rules/SKILL.md +34 -56
  72. package/skills/testing-rules/SKILL.md +28 -52
  73. package/skills/ultra-change/SKILL.md +61 -145
  74. package/skills/ultra-deliver/SKILL.md +63 -229
  75. package/skills/ultra-dev/SKILL.md +46 -338
  76. package/skills/ultra-doctor/SKILL.md +39 -71
  77. package/skills/ultra-init/SKILL.md +59 -139
  78. package/skills/ultra-plan/SKILL.md +50 -310
  79. package/skills/ultra-research/SKILL.md +59 -152
  80. package/skills/ultra-research/references/architecture.md +38 -0
  81. package/skills/ultra-research/references/problem-product.md +39 -0
  82. package/skills/ultra-research/references/quality-delivery.md +34 -0
  83. package/skills/ultra-research/references/synthesis.md +38 -0
  84. package/skills/ultra-research/references/users-scope.md +40 -0
  85. package/skills/ultra-review/SKILL.md +55 -427
  86. package/skills/ultra-review/references/unified-schema.md +76 -101
  87. package/skills/ultra-review/scripts/review_wait.py +242 -77
  88. package/skills/ultra-status/SKILL.md +38 -173
  89. package/skills/ultra-test/SKILL.md +41 -195
  90. package/skills/ultra-think/SKILL.md +43 -101
  91. package/skills/ultra-verify/SKILL.md +44 -84
  92. package/skills/ultra-verify/evals/evals.json +24 -27
  93. package/spec/cli-protocol.md +3 -0
  94. package/spec/mcp-tools.yaml +133 -4
  95. package/spec/schemas/command-manifest.schema.json +7 -25
  96. package/spec/schemas/skill-manifest.schema.json +9 -79
  97. package/spec/schemas/state-db.sql +31 -0
  98. package/skills/code-review-expert/references/code-quality-checklist.md +0 -112
  99. package/skills/code-review-expert/references/integration-checklist.md +0 -46
  100. package/skills/code-review-expert/references/security-checklist.md +0 -100
  101. package/skills/code-review-expert/references/solid-checklist.md +0 -65
  102. package/skills/ultra-research/steps/step-00-problem-validation.md +0 -154
  103. package/skills/ultra-research/steps/step-01-opportunity-discovery.md +0 -172
  104. package/skills/ultra-research/steps/step-02-market-assessment.md +0 -195
  105. package/skills/ultra-research/steps/step-03-competitive-landscape.md +0 -197
  106. package/skills/ultra-research/steps/step-04-product-strategy.md +0 -196
  107. package/skills/ultra-research/steps/step-05-assumptions-validation.md +0 -234
  108. package/skills/ultra-research/steps/step-10-user-personas.md +0 -169
  109. package/skills/ultra-research/steps/step-11-user-scenarios.md +0 -181
  110. package/skills/ultra-research/steps/step-20-user-stories.md +0 -156
  111. package/skills/ultra-research/steps/step-21-features-scope.md +0 -157
  112. package/skills/ultra-research/steps/step-22-success-metrics.md +0 -195
  113. package/skills/ultra-research/steps/step-30-architecture-context.md +0 -154
  114. package/skills/ultra-research/steps/step-31-solution-strategy.md +0 -172
  115. package/skills/ultra-research/steps/step-32-building-blocks.md +0 -188
  116. package/skills/ultra-research/steps/step-40-deployment.md +0 -123
  117. package/skills/ultra-research/steps/step-41-quality-risks.md +0 -135
  118. package/skills/ultra-research/steps/step-99-synthesis.md +0 -188
  119. package/skills/ultra-review/scripts/review_verdict_update.py +0 -99
@@ -31,10 +31,20 @@ function cleanup({ rootDir, db }) {
31
31
  function standardEvidence() {
32
32
  return [
33
33
  { category: 'diff', status: 'pass', evidence: 'git diff reviewed' },
34
- { category: 'tests', status: 'pass', evidence: 'node --test passed' },
34
+ {
35
+ category: 'tests', status: 'pass', evidence: 'node --test passed',
36
+ seam: 'public change workflow contract',
37
+ },
35
38
  { category: 'spec', status: 'pass', evidence: 'delta reconciled' },
36
39
  { category: 'docs', status: 'pass', evidence: 'docs/feature.md updated' },
37
- { category: 'review', status: 'pass', evidence: 'review findings resolved' },
40
+ {
41
+ category: 'review', axis: 'spec_fidelity', status: 'pass',
42
+ evidence: 'Acceptance behavior matches the approved delta.',
43
+ },
44
+ {
45
+ category: 'review', axis: 'engineering_standards', status: 'pass',
46
+ evidence: 'Engineering findings are resolved.',
47
+ },
38
48
  ];
39
49
  }
40
50
 
@@ -42,10 +52,43 @@ function incidentEvidence() {
42
52
  return [
43
53
  { category: 'diagnosis', status: 'pass', evidence: 'Structured diagnosis artifact reviewed.' },
44
54
  { category: 'diff', status: 'pass', evidence: 'Root-cause fix diff reviewed.' },
45
- { category: 'tests', status: 'pass', evidence: 'Regression test reproduces then verifies the fix.' },
55
+ {
56
+ category: 'tests', status: 'pass', evidence: 'Regression test reproduces then verifies the fix.',
57
+ seam: 'projection recovery boundary',
58
+ signal: {
59
+ command: 'node --test recovery.test.cjs',
60
+ expected_red: 'stale projection remains running',
61
+ observed_red: true,
62
+ observed_green: true,
63
+ deterministic: true,
64
+ duration_ms: 25,
65
+ },
66
+ },
46
67
  ];
47
68
  }
48
69
 
70
+ function executionContext(taskId, overrides = {}) {
71
+ return {
72
+ task_id: taskId,
73
+ role: 'implement',
74
+ gate: 'implementation',
75
+ next_action: `Implement and verify ${taskId}.`,
76
+ context_refs: [
77
+ {
78
+ ref: 'README.md', kind: 'spec', reason: 'Public behavior contract', required: true,
79
+ },
80
+ ],
81
+ budget: { max_tokens: 2_000, max_files: 4 },
82
+ execution_contract: {
83
+ slice_kind: 'tracer_bullet',
84
+ public_seam: `public seam for ${taskId}`,
85
+ verification_command: `node --test ${taskId}.test.cjs`,
86
+ context_budget_percent: 40,
87
+ },
88
+ ...overrides,
89
+ };
90
+ }
91
+
49
92
  test('createChange persists a change and an inspectable external-provider context manifest', () => {
50
93
  const fx = fixture();
51
94
  try {
@@ -120,7 +163,9 @@ test('convergeChange blocks incomplete work and marks a fully evidenced standard
120
163
  });
121
164
  ops.updateTaskStatus(fx.db, 'change-task', 'in_progress');
122
165
  ops.updateTaskStatus(fx.db, 'change-task', 'completed');
123
- changes.compileContext(fx.db, { id: 'chg-converge' }, { rootDir: fx.rootDir });
166
+ changes.compileContext(fx.db, {
167
+ id: 'chg-converge', ...executionContext('change-task'),
168
+ }, { rootDir: fx.rootDir });
124
169
 
125
170
  const ready = changes.convergeChange(fx.db, {
126
171
  id: 'chg-converge', evidence: standardEvidence(),
@@ -148,12 +193,20 @@ test('archiveChange moves a ready change into immutable history and records base
148
193
  });
149
194
  ops.updateTaskStatus(fx.db, 'archive-task', 'in_progress');
150
195
  ops.updateTaskStatus(fx.db, 'archive-task', 'completed');
151
- changes.compileContext(fx.db, { id: 'chg-archive' }, { rootDir: fx.rootDir });
196
+ changes.compileContext(fx.db, {
197
+ id: 'chg-archive', ...executionContext('archive-task'),
198
+ }, { rootDir: fx.rootDir });
152
199
  const converged = changes.convergeChange(fx.db, {
153
200
  id: 'chg-archive',
154
201
  evidence: [
155
202
  { category: 'diff', status: 'pass', evidence: 'diff reviewed' },
156
- { category: 'tests', status: 'pass', evidence: 'tests passed' },
203
+ {
204
+ category: 'tests', status: 'pass', evidence: 'tests passed', seam: 'archive command',
205
+ signal: {
206
+ command: 'node --test archive-task.test.cjs', expected_red: 'archive accepts stale context',
207
+ observed_red: true, observed_green: true, deterministic: true, duration_ms: 10,
208
+ },
209
+ },
157
210
  { category: 'spec', status: 'not_applicable', evidence: 'No behavior contract changed.' },
158
211
  ],
159
212
  }, { rootDir: fx.rootDir });
@@ -306,7 +359,9 @@ test('incident convergence requires every structured diagnosis section and refre
306
359
  });
307
360
  ops.updateTaskStatus(fx.db, 'incident-task', 'in_progress');
308
361
  ops.updateTaskStatus(fx.db, 'incident-task', 'completed');
309
- changes.compileContext(fx.db, { id: 'chg-incident-converge' }, { rootDir: fx.rootDir });
362
+ changes.compileContext(fx.db, {
363
+ id: 'chg-incident-converge', ...executionContext('incident-task'),
364
+ }, { rootDir: fx.rootDir });
310
365
 
311
366
  const blocked = changes.convergeChange(fx.db, {
312
367
  id: 'chg-incident-converge', evidence: incidentEvidence(),
@@ -344,3 +399,143 @@ test('incident convergence requires every structured diagnosis section and refre
344
399
  cleanup(fx);
345
400
  }
346
401
  });
402
+
403
+ test('compileContext v2 persists role-scoped context, budget, execution contract, and breadcrumb in state.db', () => {
404
+ const fx = fixture();
405
+ try {
406
+ changes.createChange(fx.db, {
407
+ id: 'chg-context-v2', title: 'Compile role context', kind: 'standard',
408
+ intent: 'Give each execution role only the context it needs.',
409
+ }, { rootDir: fx.rootDir });
410
+ ops.createTask(fx.db, {
411
+ id: 'context-v2-task', title: 'Build role-scoped manifest', type: 'feature', priority: 'P0',
412
+ change_id: 'chg-context-v2',
413
+ });
414
+
415
+ const compiled = changes.compileContext(fx.db, {
416
+ id: 'chg-context-v2',
417
+ ...executionContext('context-v2-task'),
418
+ }, { rootDir: fx.rootDir });
419
+
420
+ assert.equal(compiled.manifest.schema_version, '2.0');
421
+ assert.equal(compiled.manifest.role, 'implement');
422
+ assert.equal(compiled.manifest.gate, 'implementation');
423
+ assert.equal(compiled.manifest.readiness.status, 'ready');
424
+ assert.equal(compiled.manifest.context.items[0].ref, 'README.md');
425
+ assert.match(compiled.manifest.context.items[0].digest, /^[0-9a-f]{64}$/);
426
+ assert.equal(compiled.manifest.execution_contract.slice_kind, 'tracer_bullet');
427
+ assert.equal(compiled.manifest.resume.task_id, 'context-v2-task');
428
+ assert.match(compiled.manifest.next_action, /Implement and verify/);
429
+
430
+ const snapshot = fx.db.prepare(
431
+ 'SELECT role, gate, next_action, readiness, context_json, token_budget FROM context_snapshots ORDER BY created_at DESC LIMIT 1',
432
+ ).get();
433
+ assert.equal(snapshot.role, 'implement');
434
+ assert.equal(snapshot.gate, 'implementation');
435
+ assert.equal(snapshot.readiness, 'ready');
436
+ assert.equal(snapshot.token_budget, 2_000);
437
+ assert.equal(JSON.parse(snapshot.context_json).execution_contract.public_seam, 'public seam for context-v2-task');
438
+
439
+ const breadcrumb = changes.readBreadcrumb(fx.db, { id: 'chg-context-v2' }, { rootDir: fx.rootDir });
440
+ assert.equal(breadcrumb.change_id, 'chg-context-v2');
441
+ assert.equal(breadcrumb.task_id, 'context-v2-task');
442
+ assert.equal(breadcrumb.role, 'implement');
443
+ assert.equal(breadcrumb.readiness, 'ready');
444
+ assert.match(breadcrumb.context_manifest_hash, /^[0-9a-f]{64}$/);
445
+ } finally {
446
+ cleanup(fx);
447
+ }
448
+ });
449
+
450
+ test('compileContext blocks a required missing ref or an over-budget role packet', () => {
451
+ const fx = fixture();
452
+ try {
453
+ changes.createChange(fx.db, {
454
+ id: 'chg-context-budget', title: 'Bound context packet', kind: 'quick',
455
+ intent: 'Fail closed when required context is missing or too large.',
456
+ }, { rootDir: fx.rootDir });
457
+ ops.createTask(fx.db, {
458
+ id: 'budget-task', title: 'Compile bounded packet', type: 'bugfix', priority: 'P0',
459
+ change_id: 'chg-context-budget',
460
+ });
461
+
462
+ const compiled = changes.compileContext(fx.db, {
463
+ id: 'chg-context-budget',
464
+ ...executionContext('budget-task', {
465
+ context_refs: [
466
+ { ref: 'missing-contract.md', kind: 'spec', reason: 'Required contract', required: true },
467
+ { ref: 'README.md', kind: 'source', reason: 'Current implementation', required: true },
468
+ ],
469
+ budget: { max_tokens: 1, max_files: 1 },
470
+ }),
471
+ }, { rootDir: fx.rootDir });
472
+
473
+ assert.equal(compiled.manifest.readiness.status, 'blocked');
474
+ assert.ok(compiled.manifest.readiness.blockers.includes('CONTEXT_REQUIRED_REF_MISSING:missing-contract.md'));
475
+ assert.ok(compiled.manifest.readiness.blockers.includes('CONTEXT_FILE_BUDGET_EXCEEDED'));
476
+ assert.ok(compiled.manifest.readiness.blockers.includes('CONTEXT_TOKEN_BUDGET_EXCEEDED'));
477
+ } finally {
478
+ cleanup(fx);
479
+ }
480
+ });
481
+
482
+ test('spec-learning candidates are approval-gated and unresolved candidates block convergence', () => {
483
+ const fx = fixture();
484
+ try {
485
+ changes.createChange(fx.db, {
486
+ id: 'chg-learning', title: 'Converge learned contract', kind: 'quick',
487
+ intent: 'Promote stable implementation discoveries into an approved baseline update.',
488
+ docs_impact: { status: 'none', files: [], rationale: 'Fixture exercises the approval state machine.' },
489
+ }, { rootDir: fx.rootDir });
490
+ ops.createTask(fx.db, {
491
+ id: 'learning-task', title: 'Capture stable contract', type: 'bugfix', priority: 'P0',
492
+ change_id: 'chg-learning',
493
+ });
494
+ ops.updateTaskStatus(fx.db, 'learning-task', 'in_progress');
495
+ ops.updateTaskStatus(fx.db, 'learning-task', 'completed');
496
+ changes.compileContext(fx.db, {
497
+ id: 'chg-learning', ...executionContext('learning-task'),
498
+ }, { rootDir: fx.rootDir });
499
+
500
+ const proposed = changes.proposeSpecLearning(fx.db, {
501
+ id: 'learning-1', change_id: 'chg-learning', task_id: 'learning-task',
502
+ target_ref: 'README.md#contract', summary: 'The public command fails closed on stale context.',
503
+ evidence: ['node --test learning-task.test.cjs'],
504
+ }, { rootDir: fx.rootDir });
505
+ assert.equal(proposed.status, 'proposed');
506
+
507
+ const blocked = changes.convergeChange(fx.db, {
508
+ id: 'chg-learning',
509
+ evidence: [
510
+ { category: 'diff', status: 'pass', evidence: 'diff reviewed' },
511
+ {
512
+ category: 'tests', status: 'pass', evidence: 'regression passed', seam: 'public command',
513
+ signal: {
514
+ command: 'node --test learning-task.test.cjs', expected_red: 'stale context accepted',
515
+ observed_red: true, observed_green: true, deterministic: true, duration_ms: 20,
516
+ },
517
+ },
518
+ { category: 'spec', status: 'pass', evidence: 'candidate recorded' },
519
+ ],
520
+ }, { rootDir: fx.rootDir });
521
+ assert.ok(blocked.blockers.includes('SPEC_LEARNING_UNRESOLVED:learning-1'));
522
+
523
+ assert.equal(changes.resolveSpecLearning(fx.db, {
524
+ change_id: 'chg-learning', candidate_id: 'learning-1', decision: 'approve',
525
+ resolution: 'Stable public contract.',
526
+ }, { rootDir: fx.rootDir }).status, 'approved');
527
+ assert.equal(changes.resolveSpecLearning(fx.db, {
528
+ change_id: 'chg-learning', candidate_id: 'learning-1', decision: 'apply',
529
+ resolution: 'Applied to README.md#contract.',
530
+ }, { rootDir: fx.rootDir }).status, 'applied');
531
+
532
+ const projection = JSON.parse(fs.readFileSync(
533
+ path.join(fx.rootDir, '.ultra', 'changes', 'active', 'chg-learning', 'spec-learning.json'),
534
+ 'utf8',
535
+ ));
536
+ assert.equal(projection.source, '.ultra/state.db');
537
+ assert.equal(projection.candidates[0].status, 'applied');
538
+ } finally {
539
+ cleanup(fx);
540
+ }
541
+ });
@@ -0,0 +1,331 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('node:crypto');
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+ const { spawnSync } = require('node:child_process');
7
+
8
+ const ROLES = new Set(['plan', 'implement', 'check', 'review']);
9
+ const GATES = new Set([
10
+ 'alignment', 'planning', 'implementation', 'verification', 'review', 'convergence', 'recovery',
11
+ ]);
12
+ const CONTEXT_KINDS = new Set(['spec', 'source', 'test', 'docs', 'external']);
13
+ const SLICE_KINDS = new Set(['tracer_bullet', 'expand_contract', 'integration_checkpoint']);
14
+ const DEFAULT_BUDGET = Object.freeze({ max_tokens: 12_000, max_files: 12 });
15
+
16
+ class ContextSpineError extends Error {
17
+ constructor(code, message) {
18
+ super(message);
19
+ this.name = 'ContextSpineError';
20
+ this.code = code;
21
+ }
22
+ }
23
+
24
+ function currentHead(rootDir) {
25
+ const result = spawnSync('git', ['rev-parse', 'HEAD'], {
26
+ cwd: rootDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
27
+ });
28
+ return result.status === 0 ? result.stdout.trim() : null;
29
+ }
30
+
31
+ function parseJson(value, fallback = {}) {
32
+ if (typeof value !== 'string' || !value.trim()) return fallback;
33
+ try { return JSON.parse(value); } catch { return fallback; }
34
+ }
35
+
36
+ function nonEmpty(value, field) {
37
+ const text = typeof value === 'string' ? value.trim() : '';
38
+ if (!text) throw new ContextSpineError('VALIDATION_ERROR', `${field} must be a non-empty string`);
39
+ return text;
40
+ }
41
+
42
+ function localPath(rootDir, ref) {
43
+ const candidate = ref.split('#', 1)[0];
44
+ if (!candidate || path.isAbsolute(candidate)) {
45
+ throw new ContextSpineError('VALIDATION_ERROR', `context ref must be project-relative: ${ref}`);
46
+ }
47
+ const root = path.resolve(rootDir);
48
+ const resolved = path.resolve(root, candidate);
49
+ if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) {
50
+ throw new ContextSpineError('VALIDATION_ERROR', `context ref escapes project root: ${ref}`);
51
+ }
52
+ return resolved;
53
+ }
54
+
55
+ function normalizeBudget(value, inherited) {
56
+ const source = value || inherited || DEFAULT_BUDGET;
57
+ const maxTokens = Number(source.max_tokens ?? DEFAULT_BUDGET.max_tokens);
58
+ const maxFiles = Number(source.max_files ?? DEFAULT_BUDGET.max_files);
59
+ if (!Number.isInteger(maxTokens) || maxTokens < 1 || !Number.isInteger(maxFiles) || maxFiles < 1) {
60
+ throw new ContextSpineError('VALIDATION_ERROR', 'context budget must use positive integer max_tokens/max_files');
61
+ }
62
+ return { max_tokens: maxTokens, max_files: maxFiles };
63
+ }
64
+
65
+ function specRefToContextRef(value, role) {
66
+ const ref = typeof value === 'string'
67
+ ? value
68
+ : value && (value.ref || value.path || value.reference);
69
+ if (!ref) throw new ContextSpineError('VALIDATION_ERROR', 'spec_refs entries require path/ref/reference');
70
+ return {
71
+ ref,
72
+ kind: 'spec',
73
+ reason: (value && value.reason) || `Specification evidence for ${role}`,
74
+ required: value?.required !== false,
75
+ digest: value && value.digest,
76
+ };
77
+ }
78
+
79
+ function normalizeRefs(input, role, inherited) {
80
+ let refs;
81
+ if (input.context_refs !== undefined) refs = input.context_refs;
82
+ else if (input.spec_refs !== undefined) refs = input.spec_refs.map((value) => specRefToContextRef(value, role));
83
+ else refs = inherited || [];
84
+ if (!Array.isArray(refs)) {
85
+ throw new ContextSpineError('VALIDATION_ERROR', 'context_refs must be an array');
86
+ }
87
+ return refs.map((value, index) => {
88
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
89
+ throw new ContextSpineError('VALIDATION_ERROR', `context_refs[${index}] must be an object`);
90
+ }
91
+ const ref = nonEmpty(value.ref || value.path || value.reference, `context_refs[${index}].ref`);
92
+ const kind = value.kind || 'source';
93
+ if (!CONTEXT_KINDS.has(kind)) {
94
+ throw new ContextSpineError('VALIDATION_ERROR', `unsupported context kind: ${kind}`);
95
+ }
96
+ const reason = nonEmpty(value.reason, `context_refs[${index}].reason`);
97
+ if (value.digest !== undefined && !/^[0-9a-f]{64}$/.test(value.digest)) {
98
+ throw new ContextSpineError('VALIDATION_ERROR', `context_refs[${index}].digest must be sha256`);
99
+ }
100
+ return {
101
+ ref, kind, reason, required: value.required !== false,
102
+ expected_digest: value.digest || null,
103
+ };
104
+ });
105
+ }
106
+
107
+ function inspectRef(rootDir, role, value) {
108
+ if (value.kind === 'external' || /^[a-z][a-z0-9+.-]*:\/\//i.test(value.ref)) {
109
+ return { ...value, role, status: 'external', digest: value.expected_digest, estimated_tokens: 0 };
110
+ }
111
+ const file = localPath(rootDir, value.ref);
112
+ if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
113
+ return { ...value, role, status: 'missing', digest: null, estimated_tokens: 0 };
114
+ }
115
+ const content = fs.readFileSync(file);
116
+ const digest = crypto.createHash('sha256').update(content).digest('hex');
117
+ const status = value.expected_digest && value.expected_digest !== digest ? 'stale' : 'current';
118
+ return {
119
+ ...value, role, status, digest,
120
+ estimated_tokens: Math.max(1, Math.ceil(content.byteLength / 4)),
121
+ };
122
+ }
123
+
124
+ function normalizeExecutionContract(input, inherited, { taskId }) {
125
+ const source = input.execution_contract || inherited || null;
126
+ if (!taskId) return source;
127
+ if (!source || typeof source !== 'object' || Array.isArray(source)) return null;
128
+ const sliceKind = source.slice_kind || 'tracer_bullet';
129
+ if (!SLICE_KINDS.has(sliceKind)) {
130
+ throw new ContextSpineError('VALIDATION_ERROR', `unsupported slice kind: ${sliceKind}`);
131
+ }
132
+ const percent = Number(source.context_budget_percent ?? 40);
133
+ if (!Number.isInteger(percent) || percent < 1 || percent > 100) {
134
+ throw new ContextSpineError('VALIDATION_ERROR', 'context_budget_percent must be an integer from 1 to 100');
135
+ }
136
+ return {
137
+ slice_kind: sliceKind,
138
+ public_seam: typeof source.public_seam === 'string' ? source.public_seam.trim() : '',
139
+ verification_command: typeof source.verification_command === 'string'
140
+ ? source.verification_command.trim()
141
+ : '',
142
+ context_budget_percent: percent,
143
+ };
144
+ }
145
+
146
+ function defaultGate(role) {
147
+ return {
148
+ plan: 'planning', implement: 'implementation', check: 'verification', review: 'review',
149
+ }[role];
150
+ }
151
+
152
+ function deriveNextAction({ change, tasks, task, role, readiness, explicit }) {
153
+ if (readiness === 'blocked') return 'Resolve the context readiness blockers, then recompile change.context.';
154
+ if (explicit && String(explicit).trim()) return String(explicit).trim();
155
+ if (change.status === 'ready') return `Run ultra-deliver for change ${change.id}.`;
156
+ if (change.status === 'blocked') return `Resolve the blockers for change ${change.id}, then recompile context.`;
157
+ if (!task && tasks.length === 0) return `Create the first fresh-context tracer-bullet task for change ${change.id}.`;
158
+ if (task?.status === 'pending') return `Start task ${task.id} through ultra-dev.`;
159
+ if (task?.status === 'in_progress') {
160
+ if (role === 'check') return `Run the exact verification command for task ${task.id}.`;
161
+ if (role === 'review') return `Complete independent spec-fidelity and engineering-standards review for task ${task.id}.`;
162
+ return `Continue the approved vertical slice for task ${task.id}.`;
163
+ }
164
+ if (tasks.length > 0 && tasks.every((row) => ['completed', 'expanded'].includes(row.status))) {
165
+ return `Run ultra-test for change ${change.id}.`;
166
+ }
167
+ const pending = tasks.find((row) => row.status === 'pending');
168
+ return pending ? `Compile implement context for task ${pending.id}.` : `Inspect blockers for change ${change.id}.`;
169
+ }
170
+
171
+ function latestContext(db, changeId, taskId) {
172
+ const row = taskId
173
+ ? db.prepare(
174
+ 'SELECT context_json FROM context_snapshots WHERE change_id = ? AND task_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1',
175
+ ).get(changeId, taskId)
176
+ : db.prepare(
177
+ 'SELECT context_json FROM context_snapshots WHERE change_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1',
178
+ ).get(changeId);
179
+ return parseJson(row?.context_json, {});
180
+ }
181
+
182
+ function compileRoleContext(db, { input, change, tasks, rootDir }) {
183
+ const task = input.task_id ? tasks.find((row) => row.id === input.task_id) : null;
184
+ if (input.task_id && !task) {
185
+ throw new ContextSpineError('TASK_NOT_FOUND', `task ${input.task_id} is not linked to change ${change.id}`);
186
+ }
187
+ const inherited = latestContext(db, change.id, input.task_id || null);
188
+ const role = input.role || (input.task_id ? 'implement' : 'plan');
189
+ if (!ROLES.has(role)) throw new ContextSpineError('VALIDATION_ERROR', `unsupported context role: ${role}`);
190
+ const gate = input.gate || defaultGate(role);
191
+ if (!GATES.has(gate)) throw new ContextSpineError('VALIDATION_ERROR', `unsupported workflow gate: ${gate}`);
192
+
193
+ const refs = normalizeRefs(input, role, inherited.context?.items);
194
+ const items = refs.map((value) => inspectRef(rootDir, role, value));
195
+ const budget = normalizeBudget(input.budget, inherited.context?.budget);
196
+ const tokenEstimate = items.reduce((sum, item) => sum + item.estimated_tokens, 0);
197
+ const fileCount = items.filter((item) => item.status !== 'external').length;
198
+ const executionContract = normalizeExecutionContract(
199
+ input, inherited.execution_contract, { taskId: input.task_id },
200
+ );
201
+ const blockers = [];
202
+ for (const item of items) {
203
+ if (item.required && item.status === 'missing') blockers.push(`CONTEXT_REQUIRED_REF_MISSING:${item.ref}`);
204
+ if (item.required && item.status === 'stale') blockers.push(`CONTEXT_REQUIRED_REF_STALE:${item.ref}`);
205
+ }
206
+ if (fileCount > budget.max_files) blockers.push('CONTEXT_FILE_BUDGET_EXCEEDED');
207
+ if (tokenEstimate > budget.max_tokens) blockers.push('CONTEXT_TOKEN_BUDGET_EXCEEDED');
208
+ if (input.task_id) {
209
+ if (!executionContract) blockers.push('EXECUTION_CONTRACT_MISSING');
210
+ else {
211
+ if (!executionContract.public_seam) blockers.push('EXECUTION_PUBLIC_SEAM_MISSING');
212
+ if (!executionContract.verification_command) blockers.push('EXECUTION_VERIFICATION_COMMAND_MISSING');
213
+ if (executionContract.slice_kind !== 'expand_contract'
214
+ && executionContract.context_budget_percent > 40) {
215
+ blockers.push('EXECUTION_CONTEXT_BUDGET_EXCEEDED');
216
+ }
217
+ }
218
+ }
219
+ const readiness = blockers.length === 0 ? 'ready' : 'blocked';
220
+ const nextAction = deriveNextAction({
221
+ change, tasks, task, role, readiness, explicit: input.next_action,
222
+ });
223
+ return {
224
+ role,
225
+ gate,
226
+ next_action: nextAction,
227
+ readiness: { status: readiness, blockers },
228
+ context: { items, budget, token_estimate: tokenEstimate, file_count: fileCount },
229
+ execution_contract: executionContract,
230
+ selected_task: task,
231
+ resume: {
232
+ change_id: change.id,
233
+ task_id: task?.id || null,
234
+ task_status: task?.status || null,
235
+ session_id: task?.session_id || null,
236
+ role,
237
+ gate,
238
+ git_head: currentHead(rootDir),
239
+ },
240
+ };
241
+ }
242
+
243
+ function recommendedWorkflow(change, task, tasks, readiness) {
244
+ if (!change) return 'ultra-change';
245
+ if (readiness !== 'ready' || change.status === 'blocked') return 'ultra-change';
246
+ if (change.status === 'ready') return 'ultra-deliver';
247
+ if (task && ['pending', 'in_progress'].includes(task.status)) return 'ultra-dev';
248
+ if (tasks.length > 0 && tasks.every((row) => ['completed', 'expanded'].includes(row.status))) return 'ultra-test';
249
+ return 'ultra-change';
250
+ }
251
+
252
+ function readBreadcrumb(db, { id } = {}, { rootDir = process.cwd() } = {}) {
253
+ const change = id
254
+ ? db.prepare('SELECT * FROM changes WHERE id = ?').get(id)
255
+ : db.prepare(
256
+ "SELECT * FROM changes WHERE status IN ('active', 'blocked', 'ready') ORDER BY updated_at DESC LIMIT 1",
257
+ ).get();
258
+ if (id && !change) {
259
+ throw new ContextSpineError('CHANGE_NOT_FOUND', `change ${id} not found`);
260
+ }
261
+ if (!change) {
262
+ return {
263
+ change_id: null, task_id: null, role: 'plan', gate: 'alignment', readiness: 'ready',
264
+ next_action: 'Start daily work with ultra-change.', recommended_workflow: 'ultra-change',
265
+ context_manifest_path: null, context_manifest_hash: null, git_head: currentHead(rootDir),
266
+ };
267
+ }
268
+ const tasks = db.prepare('SELECT * FROM tasks WHERE change_id = ? ORDER BY created_at ASC').all(change.id);
269
+ const snapshot = db.prepare(
270
+ 'SELECT * FROM context_snapshots WHERE change_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1',
271
+ ).get(change.id);
272
+ const context = parseJson(snapshot?.context_json, {});
273
+ const taskId = snapshot?.task_id || tasks.find((row) => row.status === 'in_progress')?.id
274
+ || tasks.find((row) => row.status === 'pending')?.id || null;
275
+ const task = tasks.find((row) => row.id === taskId) || null;
276
+ const head = currentHead(rootDir);
277
+ const snapshotMissing = !snapshot;
278
+ const legacyContext = Boolean(snapshot && (!context.resume || !context.context || !context.readiness));
279
+ const headStale = Boolean(snapshot?.git_head && head && snapshot.git_head !== head);
280
+ const stateChanged = context.resume?.task_status !== undefined
281
+ && context.resume.task_status !== (task?.status || null);
282
+ const blockers = [];
283
+ if (snapshotMissing) blockers.push('CONTEXT_NOT_COMPILED');
284
+ else {
285
+ if (legacyContext) blockers.push('CONTEXT_SNAPSHOT_UPGRADE_REQUIRED');
286
+ if (headStale) blockers.push('CONTEXT_HEAD_STALE');
287
+ if (stateChanged) blockers.push('CONTEXT_TASK_STATE_STALE');
288
+ for (const blocker of parseJson(snapshot.blockers_json, [])) {
289
+ if (!blockers.includes(blocker)) blockers.push(blocker);
290
+ }
291
+ }
292
+ const readiness = blockers.length > 0 ? 'blocked' : snapshot.readiness;
293
+ let nextAction;
294
+ if (snapshotMissing) {
295
+ nextAction = `Compile change.context for change ${change.id} before continuing.`;
296
+ } else if (legacyContext) {
297
+ nextAction = 'Recompile change.context to upgrade this legacy context snapshot.';
298
+ } else if (headStale) {
299
+ nextAction = 'Git HEAD changed after context compilation; recompile change.context.';
300
+ } else if (stateChanged) {
301
+ nextAction = 'Task state changed after context compilation; recompile change.context.';
302
+ } else {
303
+ nextAction = deriveNextAction({
304
+ change, tasks, task, role: snapshot?.role || 'plan', readiness,
305
+ explicit: snapshot?.next_action,
306
+ });
307
+ }
308
+ return {
309
+ change_id: change.id,
310
+ change_status: change.status,
311
+ task_id: task?.id || null,
312
+ task_status: task?.status || null,
313
+ session_id: task?.session_id || null,
314
+ role: snapshot?.role || 'plan',
315
+ gate: snapshot?.gate || 'alignment',
316
+ readiness,
317
+ blockers,
318
+ next_action: nextAction,
319
+ recommended_workflow: recommendedWorkflow(change, task, tasks, readiness),
320
+ context_manifest_path: snapshot?.manifest_path || null,
321
+ context_manifest_hash: snapshot?.manifest_hash || null,
322
+ git_head: head,
323
+ };
324
+ }
325
+
326
+ module.exports = {
327
+ ContextSpineError,
328
+ compileRoleContext,
329
+ deriveNextAction,
330
+ readBreadcrumb,
331
+ };
@@ -0,0 +1,73 @@
1
+ 'use strict';
2
+
3
+ const test = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const fs = require('node:fs');
6
+ const os = require('node:os');
7
+ const path = require('node:path');
8
+
9
+ const { compileRoleContext, deriveNextAction, readBreadcrumb } = require('./context-spine.cjs');
10
+ const { initStateDb } = require('./state-db.cjs');
11
+ const { createChange } = require('./change-workflow.cjs');
12
+ const { createTask } = require('./state-ops.cjs');
13
+
14
+ test('deriveNextAction gives one state-specific action instead of a workflow dump', () => {
15
+ const change = { id: 'context-spine', status: 'active' };
16
+ const task = { id: 'task-1', status: 'in_progress' };
17
+ const action = deriveNextAction({
18
+ change, tasks: [task], task, role: 'review', readiness: 'ready', explicit: null,
19
+ });
20
+ assert.equal(
21
+ action,
22
+ 'Complete independent spec-fidelity and engineering-standards review for task task-1.',
23
+ );
24
+ assert.doesNotMatch(action, /plan.*implement.*check.*review/i);
25
+ });
26
+
27
+ test('deriveNextAction fails closed on blocked context readiness', () => {
28
+ const action = deriveNextAction({
29
+ change: { id: 'context-spine', status: 'active' },
30
+ tasks: [], task: null, role: 'plan', readiness: 'blocked', explicit: 'Ignore blockers.',
31
+ });
32
+ assert.match(action, /Resolve the context readiness blockers/);
33
+ assert.doesNotMatch(action, /Ignore blockers/);
34
+ });
35
+
36
+ test('a task is not plan-ready before its execution contract is compiled', () => {
37
+ const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-spine-plan-'));
38
+ const { db } = initStateDb(path.join(rootDir, '.ultra', 'state.db'));
39
+ try {
40
+ const { change } = createChange(db, {
41
+ id: 'planning-contract', title: 'Planning contract', kind: 'standard',
42
+ intent: 'Require an executable fresh-context slice before implementation.',
43
+ docs_impact: { status: 'none', rationale: 'test fixture only' },
44
+ }, { rootDir });
45
+ const task = createTask(db, {
46
+ id: 'task-plan', title: 'Plan one slice', type: 'feature', priority: 'P0',
47
+ change_id: change.id,
48
+ });
49
+
50
+ const context = compileRoleContext(db, {
51
+ input: { task_id: task.id, role: 'plan', gate: 'planning' },
52
+ change, tasks: [task], rootDir,
53
+ });
54
+ assert.equal(context.readiness.status, 'blocked');
55
+ assert.ok(context.readiness.blockers.includes('EXECUTION_CONTRACT_MISSING'));
56
+
57
+ const breadcrumb = readBreadcrumb(db, { id: change.id }, { rootDir });
58
+ assert.equal(breadcrumb.readiness, 'blocked');
59
+ assert.ok(breadcrumb.blockers.includes('CONTEXT_TASK_STATE_STALE'));
60
+ assert.throws(
61
+ () => readBreadcrumb(db, { id: 'missing-change' }, { rootDir }),
62
+ (error) => error.code === 'CHANGE_NOT_FOUND',
63
+ );
64
+
65
+ db.prepare("UPDATE context_snapshots SET context_json = '{}', readiness = 'ready'").run();
66
+ const migratedLegacy = readBreadcrumb(db, { id: change.id }, { rootDir });
67
+ assert.equal(migratedLegacy.readiness, 'blocked');
68
+ assert.ok(migratedLegacy.blockers.includes('CONTEXT_SNAPSHOT_UPGRADE_REQUIRED'));
69
+ } finally {
70
+ db.close();
71
+ fs.rmSync(rootDir, { recursive: true, force: true });
72
+ }
73
+ });