ultra-builder-pro-cli 0.12.1 → 0.14.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 (88) hide show
  1. package/.ultra-template/docs/research/README.md +21 -44
  2. package/.ultra-template/specs/discovery.md +10 -0
  3. package/CHANGELOG.md +95 -1
  4. package/README.md +31 -16
  5. package/adapters/_shared/codex-assets.cjs +23 -163
  6. package/adapters/_shared/handbook.cjs +2 -2
  7. package/adapters/_shared/runtime-assets.cjs +1 -0
  8. package/adapters/_shared/tests/runtime-assets.test.cjs +18 -0
  9. package/adapters/opencode.js +30 -120
  10. package/adapters/tests/claude.test.cjs +3 -2
  11. package/adapters/tests/codex-hook.test.cjs +34 -7
  12. package/adapters/tests/codex.test.cjs +21 -6
  13. package/adapters/tests/kimi-hook.test.cjs +34 -7
  14. package/adapters/tests/opencode.test.cjs +70 -84
  15. package/commands/ultra-init.md +1 -1
  16. package/docs/AGENT-CONTEXT.md +10 -6
  17. package/docs/ARCHITECTURE.md +40 -17
  18. package/docs/PLAN.zh-CN.md +5 -4
  19. package/docs/ROADMAP.md +2 -2
  20. package/docs/RUNTIME-COMPAT-MATRIX.md +4 -4
  21. package/docs/STATE-DB-ACCESS-POLICY.md +3 -3
  22. package/docs/USER-HANDBOOK-CONTRACT.md +5 -4
  23. package/hooks/active_task_context.py +22 -50
  24. package/hooks/context_spine.py +61 -299
  25. package/hooks/health_check.py +19 -17
  26. package/hooks/pre_stop_check.py +28 -33
  27. package/hooks/subagent_tracker.py +13 -19
  28. package/hooks/workflow_checkpoint.py +39 -47
  29. package/hooks/workflow_context.py +4 -9
  30. package/hooks/workflow_resume.py +48 -128
  31. package/mcp-server/breadcrumb.cjs +22 -0
  32. package/mcp-server/lib/archive-journal.cjs +192 -0
  33. package/mcp-server/lib/archive-journal.test.cjs +35 -0
  34. package/mcp-server/lib/baseline-workflow.cjs +404 -38
  35. package/mcp-server/lib/baseline-workflow.test.cjs +252 -0
  36. package/mcp-server/lib/change-workflow.cjs +249 -75
  37. package/mcp-server/lib/change-workflow.test.cjs +235 -1
  38. package/mcp-server/lib/context-spine.test.cjs +5 -0
  39. package/mcp-server/lib/doctor.cjs +14 -1
  40. package/mcp-server/lib/doctor.test.cjs +42 -4
  41. package/mcp-server/lib/init-project.cjs +292 -9
  42. package/mcp-server/lib/prd-parser.cjs +14 -41
  43. package/mcp-server/lib/prd-parser.test.cjs +49 -138
  44. package/mcp-server/lib/project-breadcrumb.cjs +139 -0
  45. package/mcp-server/lib/project-breadcrumb.test.cjs +112 -0
  46. package/mcp-server/lib/spec-learning.test.cjs +1 -1
  47. package/mcp-server/lib/state-authority.cjs +9 -3
  48. package/mcp-server/lib/state-db.cjs +154 -32
  49. package/mcp-server/lib/state-db.test.cjs +136 -4
  50. package/mcp-server/lib/state-ops.cjs +68 -6
  51. package/mcp-server/lib/task-expander.cjs +14 -37
  52. package/mcp-server/lib/task-expander.test.cjs +80 -164
  53. package/mcp-server/server.cjs +34 -37
  54. package/mcp-server/tests/init-project.test.cjs +162 -1
  55. package/mcp-server/tests/migrate.test.cjs +33 -0
  56. package/mcp-server/tests/server-contract.test.cjs +391 -17
  57. package/mcp-server/tests/state-db.test.cjs +14 -2
  58. package/mcp-server/tests/state-ops.test.cjs +108 -0
  59. package/package.json +2 -4
  60. package/skills/cc-collab/SKILL.md +2 -2
  61. package/skills/codex-collab/SKILL.md +2 -3
  62. package/skills/ultra-change/SKILL.md +18 -10
  63. package/skills/ultra-deliver/SKILL.md +13 -9
  64. package/skills/ultra-dev/SKILL.md +6 -4
  65. package/skills/ultra-doctor/SKILL.md +22 -7
  66. package/skills/ultra-init/SKILL.md +104 -59
  67. package/skills/ultra-init/references/brownfield-adoption.md +65 -37
  68. package/skills/ultra-plan/SKILL.md +20 -7
  69. package/skills/ultra-research/SKILL.md +12 -5
  70. package/skills/ultra-status/SKILL.md +6 -4
  71. package/skills/ultra-verify/SKILL.md +21 -30
  72. package/skills/ultra-verify/scripts/verify_wait.py +54 -54
  73. package/spec/cli-protocol.md +11 -1
  74. package/spec/mcp-tools.yaml +133 -19
  75. package/spec/schemas/state-db.sql +11 -0
  76. package/ultra-tools/cli.cjs +9 -5
  77. package/ultra-tools/commands/migrate.cjs +62 -36
  78. package/ultra-tools/commands/status.cjs +18 -9
  79. package/ultra-tools/commands/system.cjs +340 -6
  80. package/ultra-tools/commands/task.cjs +7 -1
  81. package/ultra-tools/status-command.test.cjs +29 -0
  82. package/ultra-tools/system-command.test.cjs +252 -1
  83. package/mcp-server/lib/llm-client.cjs +0 -157
  84. package/mcp-server/lib/llm-client.test.cjs +0 -156
  85. package/orchestrator/planner/expand-prompt.cjs +0 -62
  86. package/orchestrator/planner/expand-prompt.test.cjs +0 -52
  87. package/orchestrator/planner/prd-prompt.cjs +0 -44
  88. package/orchestrator/planner/prd-prompt.test.cjs +0 -30
@@ -1,171 +1,82 @@
1
1
  'use strict';
2
2
 
3
- // Test Double rationale: external LLM API — deterministic fixtures prove
4
- // parsing logic (prompt assembly, schema validation, id normalization)
5
- // without egress cost or key dependence. Integration coverage against a
6
- // real LLM lives behind an env gate and is exercised separately.
7
-
8
3
  const test = require('node:test');
9
4
  const assert = require('node:assert/strict');
10
5
 
11
6
  const { parsePrd, PrdParseError, TASK_LIST_SCHEMA } = require('./prd-parser.cjs');
12
7
 
13
- function fakeClient(jsonFn, { usage = { input_tokens: 100, output_tokens: 50 }, model = 'claude-sonnet-4-6', provider = 'anthropic' } = {}) {
14
- return {
15
- provider,
16
- model,
17
- async completeJson({ system, user }) {
18
- const json = typeof jsonFn === 'function' ? await jsonFn({ system, user }) : jsonFn;
19
- return { json, raw: JSON.stringify(json), usage, model, provider };
20
- },
21
- };
22
- }
23
-
24
- const HAPPY_TASKS = {
25
- tasks: [
26
- {
27
- id: 'task-1',
28
- title: 'Design auth schema',
29
- type: 'architecture',
30
- priority: 'P1',
31
- complexity: 5,
32
- deps: [],
33
- files_modified: ['src/auth/schema.ts'],
34
- },
35
- {
36
- id: 'task-2',
37
- title: 'Implement login endpoint',
38
- type: 'feature',
39
- priority: 'P1',
40
- complexity: 6,
41
- deps: ['task-1'],
42
- files_modified: ['src/auth/login.ts'],
43
- },
44
- ],
45
- };
46
-
47
- test('parsePrd: happy path normalizes tasks and returns usage metadata', async () => {
48
- const client = fakeClient(HAPPY_TASKS);
49
- const result = await parsePrd('Build login', { llmClient: client, tag: 'feat-auth' });
8
+ const HAPPY_TASKS = [
9
+ {
10
+ id: 'task-1',
11
+ title: 'Design auth schema',
12
+ type: 'architecture',
13
+ priority: 'P1',
14
+ complexity: 5,
15
+ deps: [],
16
+ files_modified: ['src/auth/schema.ts'],
17
+ },
18
+ {
19
+ id: 'task-2',
20
+ title: 'Implement login endpoint',
21
+ type: 'feature',
22
+ priority: 'P1',
23
+ complexity: 6,
24
+ deps: ['task-1'],
25
+ files_modified: ['src/auth/login.ts'],
26
+ },
27
+ ];
28
+
29
+ test('parsePrd validates and normalizes host-derived tasks without a provider client', () => {
30
+ const result = parsePrd(HAPPY_TASKS, { tag: 'feat-auth', changeId: 'auth-change' });
50
31
  assert.equal(result.tasks.length, 2);
51
- assert.equal(result.tasks[0].id, 'task-1');
52
32
  assert.equal(result.tasks[0].tag, 'feat-auth');
33
+ assert.equal(result.tasks[0].change_id, 'auth-change');
53
34
  assert.deepEqual(result.tasks[1].deps, ['task-1']);
54
- assert.equal(result.model, 'claude-sonnet-4-6');
55
- assert.equal(result.provider, 'anthropic');
56
- assert.equal(result.usage.input_tokens, 100);
57
- });
58
-
59
- test('parsePrd: empty text → NO_INPUT', async () => {
60
- await assert.rejects(
61
- parsePrd('', { llmClient: fakeClient(HAPPY_TASKS) }),
62
- (err) => err instanceof PrdParseError && err.code === 'NO_INPUT',
63
- );
64
- await assert.rejects(
65
- parsePrd(' ', { llmClient: fakeClient(HAPPY_TASKS) }),
66
- (err) => err instanceof PrdParseError && err.code === 'NO_INPUT',
67
- );
35
+ assert.equal(Object.hasOwn(result, 'model'), false);
36
+ assert.equal(Object.hasOwn(result, 'provider'), false);
37
+ assert.equal(Object.hasOwn(result, 'usage'), false);
68
38
  });
69
39
 
70
- test('parsePrd: missing llmClient → NO_LLM_CLIENT', async () => {
71
- await assert.rejects(
72
- parsePrd('Build something', {}),
73
- (err) => err instanceof PrdParseError && err.code === 'NO_LLM_CLIENT',
74
- );
40
+ test('parsePrd requires at least one host-derived task', () => {
41
+ for (const value of [undefined, null, [], 'not-an-array']) {
42
+ assert.throws(
43
+ () => parsePrd(value),
44
+ (err) => err instanceof PrdParseError && err.code === 'NO_INPUT',
45
+ );
46
+ }
75
47
  });
76
48
 
77
- test('parsePrd: llmClient error bubbles up as PARSE_FAILED', async () => {
78
- const brokenClient = {
79
- provider: 'anthropic',
80
- model: 'claude-sonnet-4-6',
81
- async completeJson() { throw new Error('upstream LLM 500'); },
82
- };
83
- await assert.rejects(
84
- parsePrd('Build something', { llmClient: brokenClient }),
85
- (err) => err instanceof PrdParseError && err.code === 'PARSE_FAILED' && /upstream LLM 500/.test(err.message),
86
- );
87
- });
88
-
89
- test('parsePrd: invalid JSON shape rejected — missing required "priority"', async () => {
90
- const bad = {
91
- tasks: [{
92
- id: 'task-1', title: 'Foo', type: 'feature', // priority missing
93
- complexity: 3, deps: [], files_modified: [],
94
- }],
95
- };
96
- await assert.rejects(
97
- parsePrd('Build Foo', { llmClient: fakeClient(bad) }),
49
+ test('parsePrd rejects an invalid task shape', () => {
50
+ assert.throws(
51
+ () => parsePrd([{
52
+ id: 'task-1', title: 'Missing priority', type: 'feature',
53
+ }]),
98
54
  (err) => err instanceof PrdParseError && err.code === 'PARSE_FAILED' && /priority/.test(err.message),
99
55
  );
100
56
  });
101
57
 
102
- test('parsePrd: invalid enum value rejected — bad type', async () => {
103
- const bad = {
104
- tasks: [{
105
- id: 'task-1', title: 'Foo', type: 'chore', priority: 'P1',
106
- }],
107
- };
108
- await assert.rejects(
109
- parsePrd('Build Foo', { llmClient: fakeClient(bad) }),
110
- (err) => err instanceof PrdParseError && err.code === 'PARSE_FAILED',
111
- );
112
- });
113
-
114
- test('parsePrd: duplicate task id rejected', async () => {
115
- const dup = {
116
- tasks: [
58
+ test('parsePrd rejects duplicate task ids', () => {
59
+ assert.throws(
60
+ () => parsePrd([
117
61
  { id: 'task-1', title: 'First copy', type: 'feature', priority: 'P2' },
118
62
  { id: 'task-1', title: 'Second copy', type: 'feature', priority: 'P2' },
119
- ],
120
- };
121
- await assert.rejects(
122
- parsePrd('Build dupes', { llmClient: fakeClient(dup) }),
63
+ ]),
123
64
  (err) => err instanceof PrdParseError && err.code === 'PARSE_FAILED' && /duplicate/i.test(err.message),
124
65
  );
125
66
  });
126
67
 
127
- test('parsePrd: missing optional fields are normalized to defaults', async () => {
128
- const minimal = {
129
- tasks: [{
130
- id: 'task-1', title: 'Minimal task', type: 'feature', priority: 'P3',
131
- }],
132
- };
133
- const result = await parsePrd('anything', { llmClient: fakeClient(minimal) });
68
+ test('parsePrd normalizes optional fields', () => {
69
+ const result = parsePrd([{
70
+ id: 'task-1', title: 'Minimal task', type: 'feature', priority: 'P3',
71
+ }]);
134
72
  assert.equal(result.tasks[0].complexity, null);
135
73
  assert.deepEqual(result.tasks[0].deps, []);
136
74
  assert.deepEqual(result.tasks[0].files_modified, []);
137
75
  assert.equal(result.tasks[0].tag, null);
76
+ assert.equal(result.tasks[0].change_id, null);
138
77
  });
139
78
 
140
- test('parsePrd: prompt contains PRD text passed to llmClient', async () => {
141
- let captured;
142
- const client = {
143
- provider: 'anthropic',
144
- model: 'claude-sonnet-4-6',
145
- async completeJson({ system, user }) {
146
- captured = { system, user };
147
- return {
148
- json: HAPPY_TASKS,
149
- raw: '',
150
- usage: {},
151
- model: 'claude-sonnet-4-6',
152
- provider: 'anthropic',
153
- };
154
- },
155
- };
156
- await parsePrd('Build the thing with acceptance criteria X', { llmClient: client });
157
- assert.match(captured.system, /engineering planner/i);
158
- assert.match(captured.user, /Build the thing with acceptance criteria X/);
159
- });
160
-
161
- test('parsePrd: empty tasks array rejected (schema requires minItems:1)', async () => {
162
- await assert.rejects(
163
- parsePrd('Build nothing', { llmClient: fakeClient({ tasks: [] }) }),
164
- (err) => err instanceof PrdParseError && err.code === 'PARSE_FAILED',
165
- );
166
- });
167
-
168
- test('TASK_LIST_SCHEMA: exported for downstream consumers', () => {
79
+ test('TASK_LIST_SCHEMA remains available to MCP contract consumers', () => {
169
80
  assert.equal(TASK_LIST_SCHEMA.required[0], 'tasks');
170
81
  assert.equal(TASK_LIST_SCHEMA.properties.tasks.minItems, 1);
171
82
  });
@@ -0,0 +1,139 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+
6
+ const Database = require('better-sqlite3');
7
+
8
+ const { EXPECTED_VERSION, REQUIRED_TABLES } = require('./state-db.cjs');
9
+ const { readBreadcrumb } = require('./context-spine.cjs');
10
+
11
+ function blockedBreadcrumb(code, {
12
+ nextAction,
13
+ workflow,
14
+ } = {}) {
15
+ return {
16
+ change_id: null,
17
+ task_id: null,
18
+ role: 'plan',
19
+ gate: 'alignment',
20
+ readiness: 'blocked',
21
+ blockers: [code],
22
+ warnings: [],
23
+ next_action: nextAction,
24
+ recommended_workflow: workflow,
25
+ context_manifest_path: null,
26
+ context_manifest_hash: null,
27
+ git_head: null,
28
+ baseline: null,
29
+ };
30
+ }
31
+
32
+ function migrationBreadcrumb(code) {
33
+ return blockedBreadcrumb(code, {
34
+ nextAction: 'Resume project initialization to migrate and re-establish the Ultra baseline.',
35
+ workflow: 'ultra-init',
36
+ });
37
+ }
38
+
39
+ function doctorBreadcrumb(code) {
40
+ return blockedBreadcrumb(code, {
41
+ nextAction: 'Run ultra-doctor to inspect and recover the authoritative Ultra state database.',
42
+ workflow: 'ultra-doctor',
43
+ });
44
+ }
45
+
46
+ function latestSchemaVersion(db) {
47
+ return db.prepare(
48
+ 'SELECT version FROM schema_version ORDER BY applied_at DESC, rowid DESC LIMIT 1',
49
+ ).get()?.version || null;
50
+ }
51
+
52
+ function tableNames(db) {
53
+ return new Set(db.prepare(
54
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'",
55
+ ).all().map((row) => row.name));
56
+ }
57
+
58
+ function readProjectBreadcrumb(rootDir) {
59
+ const root = path.resolve(rootDir || process.cwd());
60
+ const ultraDir = path.join(root, '.ultra');
61
+ if (!fs.existsSync(ultraDir)) return null;
62
+ const dbPath = path.join(ultraDir, 'state.db');
63
+ if (!fs.existsSync(dbPath)) {
64
+ return blockedBreadcrumb('STATE_DB_MISSING', {
65
+ nextAction: 'Initialize or resume this Ultra project through ultra-init.',
66
+ workflow: 'ultra-init',
67
+ });
68
+ }
69
+
70
+ let db;
71
+ try {
72
+ db = new Database(dbPath, { readonly: true, fileMustExist: true });
73
+ db.pragma('query_only = ON');
74
+ db.pragma('busy_timeout = 1000');
75
+ const tables = tableNames(db);
76
+ if (!tables.has('schema_version')) {
77
+ return migrationBreadcrumb('STATE_SCHEMA_MIGRATION_REQUIRED:unknown');
78
+ }
79
+ const version = latestSchemaVersion(db);
80
+ if (version !== EXPECTED_VERSION) {
81
+ return migrationBreadcrumb(`STATE_SCHEMA_MIGRATION_REQUIRED:${version || 'unknown'}`);
82
+ }
83
+ const missing = REQUIRED_TABLES.filter((name) => !tables.has(name));
84
+ if (missing.length > 0) {
85
+ return doctorBreadcrumb(`STATE_SCHEMA_INCOMPLETE:${missing.join(',')}`);
86
+ }
87
+ return readBreadcrumb(db, {}, { rootDir: root });
88
+ } catch (error) {
89
+ const code = error && typeof error.code === 'string' ? error.code : 'unknown';
90
+ return doctorBreadcrumb(`STATE_DB_UNREADABLE:${code}`);
91
+ } finally {
92
+ if (db) db.close();
93
+ }
94
+ }
95
+
96
+ function renderProjectBreadcrumb(rootDir, breadcrumb) {
97
+ if (!breadcrumb) return '';
98
+ const root = path.resolve(rootDir || process.cwd());
99
+ const baseline = breadcrumb.baseline || {};
100
+ const task = breadcrumb.task_id || 'none';
101
+ const taskStatus = breadcrumb.task_status || 'none';
102
+ const lines = breadcrumb.change_id ? [
103
+ '[Ultra context spine]',
104
+ `Project: ${root}`,
105
+ `Baseline: ${baseline.id || 'unknown'} (${baseline.mode || 'unknown'}/${baseline.status || 'unknown'})`,
106
+ `Change: ${breadcrumb.change_id} (${breadcrumb.change_status || 'active'})`,
107
+ `Task: ${task} (${taskStatus})`,
108
+ `Role: ${breadcrumb.role || 'plan'}`,
109
+ `Gate: ${breadcrumb.gate || 'alignment'}`,
110
+ `Readiness: ${breadcrumb.readiness || 'blocked'}`,
111
+ ] : [
112
+ '[Ultra baseline]',
113
+ `Project: ${root}`,
114
+ `Baseline: ${baseline.id || 'missing'} (${baseline.mode || 'unknown'}/${baseline.status || 'missing'})`,
115
+ `Readiness: ${breadcrumb.readiness || 'blocked'}`,
116
+ ];
117
+ if (Array.isArray(breadcrumb.blockers) && breadcrumb.blockers.length > 0) {
118
+ lines.push(`Blockers: ${breadcrumb.blockers.join(', ')}`);
119
+ }
120
+ if (Array.isArray(breadcrumb.warnings) && breadcrumb.warnings.length > 0) {
121
+ lines.push(`Warnings: ${breadcrumb.warnings.join(', ')}`);
122
+ }
123
+ lines.push(
124
+ `Next: ${breadcrumb.next_action || 'Inspect Ultra status.'}`,
125
+ `Route: ${breadcrumb.recommended_workflow || 'ultra-doctor'}`,
126
+ );
127
+ if (breadcrumb.context_manifest_path) {
128
+ lines.push(
129
+ `Context: ${breadcrumb.context_manifest_path} sha256=${breadcrumb.context_manifest_hash || 'unknown'}`,
130
+ );
131
+ }
132
+ lines.push('Authority: .ultra/state.db; JSON/Markdown remain projections or evidence artifacts.');
133
+ return lines.join('\n');
134
+ }
135
+
136
+ module.exports = {
137
+ readProjectBreadcrumb,
138
+ renderProjectBreadcrumb,
139
+ };
@@ -0,0 +1,112 @@
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
+ const Database = require('better-sqlite3');
9
+
10
+ const { initStateDb, closeStateDb } = require('./state-db.cjs');
11
+ const { createChange } = require('./change-workflow.cjs');
12
+ const { createTask } = require('./state-ops.cjs');
13
+ const { compileRoleContext, readBreadcrumb } = require('./context-spine.cjs');
14
+ const {
15
+ readProjectBreadcrumb,
16
+ renderProjectBreadcrumb,
17
+ } = require('./project-breadcrumb.cjs');
18
+
19
+ function tmpProject(prefix) {
20
+ const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
21
+ fs.mkdirSync(path.join(rootDir, '.ultra'), { recursive: true });
22
+ return rootDir;
23
+ }
24
+
25
+ test('project reader returns the exact canonical breadcrumb from state.db', () => {
26
+ const rootDir = tmpProject('ubp-project-breadcrumb-');
27
+ const state = initStateDb(path.join(rootDir, '.ultra', 'state.db'));
28
+ try {
29
+ state.db.prepare(
30
+ `INSERT INTO baselines
31
+ (id, project_name, mode, status, approved_by, approval_note, converged_at)
32
+ VALUES ('baseline-1', 'fixture', 'greenfield', 'ready', 'test', 'fixture', ?)`,
33
+ ).run(new Date().toISOString());
34
+ const { change } = createChange(state.db, {
35
+ id: 'db-change', title: 'Database change', kind: 'quick',
36
+ intent: 'Prove every host consumes one breadcrumb.',
37
+ docs_impact: { status: 'none', rationale: 'test fixture' },
38
+ }, { rootDir });
39
+ const task = createTask(state.db, {
40
+ id: 'db-task', title: 'Use one breadcrumb', type: 'bugfix', priority: 'P0',
41
+ change_id: change.id,
42
+ });
43
+ compileRoleContext(state.db, {
44
+ input: {
45
+ task_id: task.id,
46
+ role: 'implement',
47
+ gate: 'implementation',
48
+ execution_contract: {
49
+ slice_kind: 'tracer_bullet',
50
+ public_seam: 'all host injections',
51
+ verification_command: 'npm test',
52
+ },
53
+ next_action: 'Run the cross-host breadcrumb regression test.',
54
+ },
55
+ change,
56
+ tasks: [task],
57
+ rootDir,
58
+ });
59
+
60
+ const expected = readBreadcrumb(state.db, {}, { rootDir });
61
+ const actual = readProjectBreadcrumb(rootDir);
62
+ assert.deepEqual(actual, expected);
63
+ const rendered = renderProjectBreadcrumb(rootDir, actual);
64
+ assert.match(rendered, /Change: db-change/);
65
+ assert.match(rendered, /Task: db-task/);
66
+ assert.ok(rendered.includes(actual.next_action));
67
+ assert.match(rendered, /Authority: \.ultra\/state\.db/);
68
+ } finally {
69
+ closeStateDb(state.db);
70
+ fs.rmSync(rootDir, { recursive: true, force: true });
71
+ }
72
+ });
73
+
74
+ test('project reader routes a missing authority to ultra-init', () => {
75
+ const rootDir = tmpProject('ubp-project-missing-db-');
76
+ try {
77
+ const breadcrumb = readProjectBreadcrumb(rootDir);
78
+ assert.equal(breadcrumb.readiness, 'blocked');
79
+ assert.deepEqual(breadcrumb.blockers, ['STATE_DB_MISSING']);
80
+ assert.equal(breadcrumb.recommended_workflow, 'ultra-init');
81
+ } finally {
82
+ fs.rmSync(rootDir, { recursive: true, force: true });
83
+ }
84
+ });
85
+
86
+ test('project reader routes an old schema to ultra-init without consulting projections', () => {
87
+ const rootDir = tmpProject('ubp-project-old-schema-');
88
+ const db = new Database(path.join(rootDir, '.ultra', 'state.db'));
89
+ try {
90
+ db.exec(`
91
+ CREATE TABLE schema_version (
92
+ version TEXT PRIMARY KEY,
93
+ applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
94
+ );
95
+ INSERT INTO schema_version(version) VALUES ('10.0');
96
+ `);
97
+ } finally {
98
+ db.close();
99
+ }
100
+ fs.writeFileSync(path.join(rootDir, '.ultra', 'workflow-state.json'), JSON.stringify({
101
+ command: 'ultra-dev', task_id: 'projection-task', status: 'active',
102
+ }));
103
+ try {
104
+ const breadcrumb = readProjectBreadcrumb(rootDir);
105
+ assert.equal(breadcrumb.readiness, 'blocked');
106
+ assert.ok(breadcrumb.blockers.includes('STATE_SCHEMA_MIGRATION_REQUIRED:10.0'));
107
+ assert.equal(breadcrumb.recommended_workflow, 'ultra-init');
108
+ assert.doesNotMatch(JSON.stringify(breadcrumb), /projection-task|ultra-dev/);
109
+ } finally {
110
+ fs.rmSync(rootDir, { recursive: true, force: true });
111
+ }
112
+ });
@@ -23,7 +23,7 @@ function fixture() {
23
23
  db.prepare(
24
24
  `INSERT INTO baselines
25
25
  (id, project_name, mode, status, approved_by, approval_note, converged_at)
26
- VALUES ('test-baseline', 'fixture', 'migrated', 'ready', 'test', 'legacy fixture', ?)`,
26
+ VALUES ('test-baseline', 'fixture', 'greenfield', 'ready', 'test', 'accepted fixture', ?)`,
27
27
  ).run(new Date().toISOString());
28
28
  changes.createChange(db, {
29
29
  id: 'learning-change', title: 'Learning change', kind: 'quick',
@@ -51,11 +51,17 @@ function assertStateAuthority(db, rootDir) {
51
51
  projection_path: projection.path,
52
52
  projection_version: projection.version,
53
53
  };
54
- if (projection.version === '4.4') {
54
+ const migrationTargets = {
55
+ '4.4': '4.5',
56
+ '4.5': '12.0',
57
+ };
58
+ const migrationTarget = migrationTargets[projection.version];
59
+ if (migrationTarget) {
55
60
  throw new StateAuthorityError(
56
61
  'LEGACY_STATE_MIGRATION_REQUIRED',
57
- `state.db has no tasks but ${projection.path} contains ${projection.taskCount} v4.4 tasks; `
58
- + 'run ultra-tools migrate --from=4.4 --to=4.5 --source-dir <project-root> before using Ultra MCP tools',
62
+ `state.db has no tasks but ${projection.path} contains ${projection.taskCount} v${projection.version} tasks; `
63
+ + `run ultra-tools migrate --from=${projection.version} --to=${migrationTarget} `
64
+ + '--source-dir <project-root> before using Ultra MCP tools',
59
65
  details,
60
66
  );
61
67
  }