ultra-builder-pro-cli 0.9.0 → 0.10.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 (52) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +11 -6
  3. package/adapters/_shared/codex-assets.cjs +10 -0
  4. package/adapters/_shared/handbook.cjs +2 -0
  5. package/adapters/_shared/runtime-assets.cjs +1 -0
  6. package/adapters/_shared/tests/handbook.test.cjs +3 -0
  7. package/adapters/_shared/tests/runtime-assets.test.cjs +1 -0
  8. package/adapters/kimi.js +2 -2
  9. package/adapters/opencode.js +46 -17
  10. package/adapters/tests/claude.test.cjs +4 -1
  11. package/adapters/tests/codex.test.cjs +4 -3
  12. package/adapters/tests/kimi.test.cjs +6 -2
  13. package/adapters/tests/opencode.test.cjs +46 -3
  14. package/docs/AGENT-CONTEXT.md +34 -12
  15. package/docs/ARCHITECTURE.md +31 -5
  16. package/docs/PLAN.zh-CN.md +3 -2
  17. package/docs/ROADMAP.md +5 -4
  18. package/docs/RUNTIME-COMPAT-MATRIX.md +7 -5
  19. package/docs/USER-HANDBOOK-CONTRACT.md +9 -2
  20. package/hooks/README.md +6 -4
  21. package/hooks/active_task_context.py +14 -0
  22. package/hooks/context_spine.py +218 -0
  23. package/hooks/health_check.py +1 -1
  24. package/hooks/workflow_context.py +16 -123
  25. package/hooks/workflow_resume.py +21 -0
  26. package/mcp-server/lib/change-workflow.cjs +89 -11
  27. package/mcp-server/lib/change-workflow.test.cjs +202 -7
  28. package/mcp-server/lib/context-spine.cjs +331 -0
  29. package/mcp-server/lib/context-spine.test.cjs +73 -0
  30. package/mcp-server/lib/spec-learning.cjs +191 -0
  31. package/mcp-server/lib/spec-learning.test.cjs +62 -0
  32. package/mcp-server/lib/state-db.cjs +72 -10
  33. package/mcp-server/lib/state-db.test.cjs +7 -2
  34. package/mcp-server/server.cjs +31 -1
  35. package/mcp-server/tests/db-ops.test.cjs +3 -3
  36. package/mcp-server/tests/server-contract.test.cjs +74 -0
  37. package/mcp-server/tests/state-db.test.cjs +20 -3
  38. package/package.json +1 -1
  39. package/skills/learn/SKILL.md +1 -0
  40. package/skills/ultra-change/SKILL.md +91 -118
  41. package/skills/ultra-deliver/SKILL.md +93 -221
  42. package/skills/ultra-dev/SKILL.md +81 -310
  43. package/skills/ultra-doctor/SKILL.md +1 -0
  44. package/skills/ultra-init/SKILL.md +1 -0
  45. package/skills/ultra-plan/SKILL.md +72 -292
  46. package/skills/ultra-review/SKILL.md +79 -407
  47. package/skills/ultra-status/SKILL.md +55 -168
  48. package/skills/ultra-test/SKILL.md +64 -183
  49. package/skills/ultra-think/SKILL.md +1 -0
  50. package/spec/cli-protocol.md +3 -0
  51. package/spec/mcp-tools.yaml +133 -4
  52. package/spec/schemas/state-db.sql +31 -0
@@ -6,6 +6,8 @@ const path = require('node:path');
6
6
  const { spawnSync } = require('node:child_process');
7
7
 
8
8
  const ops = require('./state-ops.cjs');
9
+ const contextSpine = require('./context-spine.cjs');
10
+ const specLearning = require('./spec-learning.cjs');
9
11
 
10
12
  const CHANGE_ID = /^[a-zA-Z0-9_-]+$/;
11
13
  const PROVIDER_KINDS = new Set(['memory', 'code_graph']);
@@ -227,13 +229,16 @@ function compileContext(db, input, { rootDir = process.cwd() } = {}) {
227
229
  ? change.provider_refs
228
230
  : normalizeProviderRefs(input.provider_refs);
229
231
  const tasks = db.prepare(
230
- 'SELECT id, title, status, priority, stale, trace_to, context_file FROM tasks WHERE change_id = ? ORDER BY created_at ASC',
232
+ `SELECT id, title, type, status, priority, complexity, stale, trace_to,
233
+ context_file, files_modified, session_id, created_at, updated_at
234
+ FROM tasks WHERE change_id = ? ORDER BY created_at ASC`,
231
235
  ).all(change.id).map((task) => ({ ...task, stale: Boolean(task.stale) }));
232
236
  const specs = Array.isArray(input.spec_refs) ? input.spec_refs : [];
233
237
  const allowedPaths = Array.isArray(input.allowed_paths) ? input.allowed_paths : [];
234
238
  const head = gitHead(rootDir);
239
+ const spine = contextSpine.compileRoleContext(db, { input, change, tasks, rootDir });
235
240
  const manifest = {
236
- schema_version: '1.0',
241
+ schema_version: '2.0',
237
242
  generated_at: nowIso(),
238
243
  change: {
239
244
  id: change.id, title: change.title, kind: change.kind, status: change.status,
@@ -241,7 +246,15 @@ function compileContext(db, input, { rootDir = process.cwd() } = {}) {
241
246
  },
242
247
  git: { head },
243
248
  tasks,
249
+ selected_task: spine.selected_task,
244
250
  specs,
251
+ role: spine.role,
252
+ gate: spine.gate,
253
+ next_action: spine.next_action,
254
+ readiness: spine.readiness,
255
+ context: spine.context,
256
+ execution_contract: spine.execution_contract,
257
+ resume: spine.resume,
245
258
  providers,
246
259
  provider_boundary: 'metadata references only; memory and code graph content remain external',
247
260
  tool_policy: { allowed_paths: allowedPaths },
@@ -257,15 +270,24 @@ function compileContext(db, input, { rootDir = process.cwd() } = {}) {
257
270
  ops.tx(db, () => {
258
271
  db.prepare(
259
272
  `INSERT INTO context_snapshots
260
- (id, change_id, task_id, git_head, provider_refs_json, manifest_path, manifest_hash)
261
- VALUES (?, ?, ?, ?, ?, ?, ?)`,
273
+ (id, change_id, task_id, git_head, provider_refs_json, manifest_path, manifest_hash,
274
+ role, gate, next_action, readiness, blockers_json, context_json,
275
+ token_estimate, token_budget)
276
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
262
277
  ).run(
263
278
  `ctx-${crypto.randomUUID().slice(0, 12)}`, change.id, input.task_id || null,
264
279
  head, JSON.stringify(providers), relative, hash,
280
+ spine.role, spine.gate, spine.next_action, spine.readiness.status,
281
+ JSON.stringify(spine.readiness.blockers), JSON.stringify(spine),
282
+ spine.context.token_estimate, spine.context.budget.max_tokens,
265
283
  );
266
284
  upsertArtifact(db, {
267
285
  change_id: change.id, task_id: input.task_id || null, kind: 'context_manifest',
268
- artifactPath: relative, contentHash: hash, metadata: { git_head: head },
286
+ artifactPath: relative, contentHash: hash,
287
+ metadata: {
288
+ git_head: head, role: spine.role, gate: spine.gate,
289
+ readiness: spine.readiness.status, token_estimate: spine.context.token_estimate,
290
+ },
269
291
  });
270
292
  for (const task of tasks) {
271
293
  if (!task.trace_to) continue;
@@ -278,7 +300,11 @@ function compileContext(db, input, { rootDir = process.cwd() } = {}) {
278
300
  .run(JSON.stringify(providers), nowIso(), change.id);
279
301
  ops.appendEventInTx(db, {
280
302
  type: 'context_updated', change_id: change.id, task_id: input.task_id || null,
281
- payload: { manifest_path: relative, manifest_hash: hash, git_head: head },
303
+ payload: {
304
+ manifest_path: relative, manifest_hash: hash, git_head: head,
305
+ role: spine.role, gate: spine.gate, readiness: spine.readiness.status,
306
+ next_action: spine.next_action,
307
+ },
282
308
  });
283
309
  });
284
310
  return { manifest, context_manifest_path: manifestPath, manifest_hash: hash };
@@ -438,16 +464,43 @@ function filesUnder(dir) {
438
464
  return out;
439
465
  }
440
466
 
441
- function evidenceBlockers(change, evidence) {
467
+ function evidenceBlockers(change, evidence, tasks) {
442
468
  const blockers = [];
443
469
  const rows = Array.isArray(evidence) ? evidence : [];
444
470
  const byCategory = new Map(rows.map((row) => [row.category, row]));
445
- for (const category of REQUIRED_EVIDENCE[change.kind]) {
471
+ for (const category of REQUIRED_EVIDENCE[change.kind].filter((value) => value !== 'review')) {
446
472
  const row = byCategory.get(category);
447
473
  if (!row) blockers.push(`EVIDENCE_MISSING:${category}`);
448
474
  else if (!['pass', 'not_applicable'].includes(row.status)) blockers.push(`EVIDENCE_FAILED:${category}`);
449
475
  else if (!row.evidence || String(row.evidence).trim().length < 3) blockers.push(`EVIDENCE_EMPTY:${category}`);
450
476
  }
477
+ if (['standard', 'major'].includes(change.kind)) {
478
+ const reviews = rows.filter((row) => row.category === 'review');
479
+ for (const axis of ['spec_fidelity', 'engineering_standards']) {
480
+ const row = reviews.find((candidate) => candidate.axis === axis);
481
+ if (!row) blockers.push(`REVIEW_AXIS_MISSING:${axis}`);
482
+ else if (row.status !== 'pass') blockers.push(`REVIEW_AXIS_FAILED:${axis}`);
483
+ else if (!row.evidence || String(row.evidence).trim().length < 3) {
484
+ blockers.push(`REVIEW_AXIS_EMPTY:${axis}`);
485
+ }
486
+ }
487
+ }
488
+ const tests = byCategory.get('tests');
489
+ if (tests && ['standard', 'major', 'incident'].includes(change.kind)
490
+ && (!tests.seam || String(tests.seam).trim().length < 3)) {
491
+ blockers.push('TEST_SEAM_MISSING');
492
+ }
493
+ if (tests && (change.kind === 'incident' || tasks.some((task) => task.type === 'bugfix'))) {
494
+ const signal = tests.signal;
495
+ if (!signal) blockers.push('TEST_RED_SIGNAL_MISSING');
496
+ else {
497
+ if (!signal.command || String(signal.command).trim().length < 3) blockers.push('TEST_SIGNAL_COMMAND_MISSING');
498
+ if (!signal.expected_red || String(signal.expected_red).trim().length < 3) blockers.push('TEST_EXPECTED_RED_MISSING');
499
+ if (signal.observed_red !== true) blockers.push('TEST_RED_NOT_OBSERVED');
500
+ if (signal.observed_green !== true) blockers.push('TEST_GREEN_NOT_OBSERVED');
501
+ if (signal.deterministic !== true) blockers.push('TEST_SIGNAL_NOT_DETERMINISTIC');
502
+ }
503
+ }
451
504
  return blockers;
452
505
  }
453
506
 
@@ -457,11 +510,31 @@ function convergeChange(db, input, { rootDir = process.cwd() } = {}) {
457
510
  if (!['active', 'blocked'].includes(change.status)) {
458
511
  throw new ChangeWorkflowError('CHANGE_NOT_CONVERGEABLE', `change ${input.id} is ${change.status}`);
459
512
  }
460
- const blockers = new Set(evidenceBlockers(change, input.evidence));
461
- const tasks = db.prepare('SELECT id, status, stale FROM tasks WHERE change_id = ? ORDER BY id').all(change.id);
513
+ const tasks = db.prepare('SELECT id, type, status, stale FROM tasks WHERE change_id = ? ORDER BY id').all(change.id);
514
+ const blockers = new Set(evidenceBlockers(change, input.evidence, tasks));
462
515
  if (tasks.length === 0) blockers.add('NO_TASKS');
463
516
  if (tasks.some((task) => !['completed', 'expanded'].includes(task.status))) blockers.add('TASKS_INCOMPLETE');
464
517
  if (tasks.some((task) => Boolean(task.stale))) blockers.add('TASK_CONTEXT_STALE');
518
+ for (const task of tasks) {
519
+ const snapshot = db.prepare(
520
+ `SELECT readiness, context_json FROM context_snapshots
521
+ WHERE change_id = ? AND task_id = ?
522
+ ORDER BY created_at DESC, rowid DESC LIMIT 1`,
523
+ ).get(change.id, task.id);
524
+ if (!snapshot) {
525
+ blockers.add(`TASK_CONTEXT_MISSING:${task.id}`);
526
+ continue;
527
+ }
528
+ const context = parseJson(snapshot.context_json, 'context_snapshots.context_json');
529
+ if (snapshot.readiness !== 'ready') blockers.add(`TASK_CONTEXT_NOT_READY:${task.id}`);
530
+ if (!context.execution_contract?.public_seam) blockers.add(`TASK_SEAM_MISSING:${task.id}`);
531
+ if (!context.execution_contract?.verification_command) blockers.add(`TASK_VERIFICATION_MISSING:${task.id}`);
532
+ }
533
+
534
+ for (const candidate of specLearning.listSpecLearning(db, change.id)) {
535
+ if (candidate.status === 'proposed') blockers.add(`SPEC_LEARNING_UNRESOLVED:${candidate.id}`);
536
+ if (candidate.status === 'approved') blockers.add(`SPEC_LEARNING_NOT_APPLIED:${candidate.id}`);
537
+ }
465
538
 
466
539
  if (change.docs_impact.status === 'unknown') blockers.add('DOCS_IMPACT_UNKNOWN');
467
540
  if (change.docs_impact.status === 'none' && (!change.docs_impact.rationale || change.docs_impact.rationale.length < 3)) {
@@ -513,7 +586,8 @@ function convergeChange(db, input, { rootDir = process.cwd() } = {}) {
513
586
  const lines = [
514
587
  `# Verification: ${change.title}`, '',
515
588
  ...input.evidence.flatMap((row) => [
516
- `## ${row.category}`, '', `Status: **${row.status}**`, '', String(row.evidence).trim(), '',
589
+ `## ${row.category}${row.axis ? `:${row.axis}` : ''}`, '',
590
+ `Status: **${row.status}**`, '', String(row.evidence).trim(), '',
517
591
  ]),
518
592
  ];
519
593
  fs.writeFileSync(verificationPath, `${lines.join('\n')}\n`);
@@ -618,6 +692,10 @@ module.exports = {
618
692
  listChanges,
619
693
  updateChange,
620
694
  compileContext,
695
+ readBreadcrumb: contextSpine.readBreadcrumb,
696
+ proposeSpecLearning: specLearning.proposeSpecLearning,
697
+ resolveSpecLearning: specLearning.resolveSpecLearning,
698
+ listSpecLearning: specLearning.listSpecLearning,
621
699
  convergeChange,
622
700
  archiveChange,
623
701
  normalizeDocsImpact,
@@ -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
+ });