ultra-builder-pro-cli 0.13.0 → 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.
- package/CHANGELOG.md +43 -1
- package/README.md +7 -8
- package/adapters/_shared/codex-assets.cjs +23 -163
- package/adapters/_shared/runtime-assets.cjs +1 -0
- package/adapters/_shared/tests/runtime-assets.test.cjs +18 -0
- package/adapters/opencode.js +30 -120
- package/adapters/tests/claude.test.cjs +3 -2
- package/adapters/tests/codex-hook.test.cjs +34 -7
- package/adapters/tests/codex.test.cjs +13 -6
- package/adapters/tests/kimi-hook.test.cjs +34 -7
- package/adapters/tests/opencode.test.cjs +70 -84
- package/docs/AGENT-CONTEXT.md +6 -3
- package/docs/ARCHITECTURE.md +8 -1
- package/docs/ROADMAP.md +1 -1
- package/docs/RUNTIME-COMPAT-MATRIX.md +4 -4
- package/hooks/active_task_context.py +22 -50
- package/hooks/context_spine.py +61 -332
- package/hooks/health_check.py +19 -17
- package/hooks/pre_stop_check.py +28 -33
- package/hooks/subagent_tracker.py +13 -19
- package/hooks/workflow_checkpoint.py +39 -47
- package/hooks/workflow_context.py +4 -9
- package/hooks/workflow_resume.py +48 -128
- package/mcp-server/breadcrumb.cjs +22 -0
- package/mcp-server/lib/prd-parser.cjs +14 -41
- package/mcp-server/lib/prd-parser.test.cjs +49 -138
- package/mcp-server/lib/project-breadcrumb.cjs +139 -0
- package/mcp-server/lib/project-breadcrumb.test.cjs +112 -0
- package/mcp-server/lib/task-expander.cjs +9 -38
- package/mcp-server/lib/task-expander.test.cjs +54 -209
- package/mcp-server/server.cjs +21 -35
- package/mcp-server/tests/server-contract.test.cjs +41 -45
- package/package.json +2 -4
- package/skills/cc-collab/SKILL.md +2 -2
- package/skills/codex-collab/SKILL.md +2 -3
- package/skills/ultra-plan/SKILL.md +15 -5
- package/skills/ultra-verify/SKILL.md +21 -30
- package/skills/ultra-verify/scripts/verify_wait.py +54 -54
- package/spec/mcp-tools.yaml +38 -10
- package/mcp-server/lib/llm-client.cjs +0 -157
- package/mcp-server/lib/llm-client.test.cjs +0 -156
- package/orchestrator/planner/expand-prompt.cjs +0 -62
- package/orchestrator/planner/expand-prompt.test.cjs +0 -52
- package/orchestrator/planner/prd-prompt.cjs +0 -44
- 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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
55
|
-
assert.equal(result
|
|
56
|
-
assert.equal(result
|
|
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
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
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('
|
|
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
|
+
});
|
|
@@ -1,21 +1,11 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// expandTask(db, { id, sub_count, strategy, llmClient }) reads the parent,
|
|
6
|
-
// asks the LLM for children, and persists them atomically (children rows
|
|
7
|
-
// with parent_id set, parent status → 'expanded'). The status transition
|
|
8
|
-
// automatically emits task_expanded via state-ops' status→event map.
|
|
9
|
-
// Strategy 'manual' is reserved for future CLI interactive flows.
|
|
3
|
+
// Persist host-derived child tasks atomically. Model judgment stays with the
|
|
4
|
+
// current host; this module validates ownership and the database contract.
|
|
10
5
|
|
|
11
6
|
const Ajv = require('ajv/dist/2020');
|
|
12
7
|
const ops = require('./state-ops.cjs');
|
|
13
8
|
const changes = require('./change-workflow.cjs');
|
|
14
|
-
const {
|
|
15
|
-
buildExpandSystemPrompt,
|
|
16
|
-
buildExpandUserPrompt,
|
|
17
|
-
} = require('../../orchestrator/planner/expand-prompt.cjs');
|
|
18
|
-
|
|
19
9
|
const CHILDREN_SHAPE = Object.freeze({
|
|
20
10
|
type: 'object',
|
|
21
11
|
required: ['children'],
|
|
@@ -52,8 +42,8 @@ class TaskExpandError extends Error {
|
|
|
52
42
|
}
|
|
53
43
|
}
|
|
54
44
|
|
|
55
|
-
|
|
56
|
-
id,
|
|
45
|
+
function expandTask(db, {
|
|
46
|
+
id, children, rootDir = process.cwd(),
|
|
57
47
|
} = {}) {
|
|
58
48
|
if (!id) throw new TaskExpandError('VALIDATION_ERROR', 'id required');
|
|
59
49
|
|
|
@@ -62,35 +52,16 @@ async function expandTask(db, {
|
|
|
62
52
|
if (parent.status === 'expanded') {
|
|
63
53
|
throw new TaskExpandError('ALREADY_EXPANDED', `task ${id} is already expanded`);
|
|
64
54
|
}
|
|
65
|
-
if (strategy === 'manual') {
|
|
66
|
-
throw new TaskExpandError('NOT_IMPLEMENTED', 'strategy "manual" not implemented; use "llm"');
|
|
67
|
-
}
|
|
68
|
-
if (strategy !== 'llm') {
|
|
69
|
-
throw new TaskExpandError('VALIDATION_ERROR', `unknown strategy "${strategy}"`);
|
|
70
|
-
}
|
|
71
55
|
changes.assertTaskCreationAllowed(db, { change_id: parent.change_id }, { rootDir });
|
|
72
|
-
if (!
|
|
73
|
-
throw new TaskExpandError('NO_LLM_CLIENT', 'llmClient.completeJson is required');
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const system = buildExpandSystemPrompt({ sub_count });
|
|
77
|
-
const user = buildExpandUserPrompt(parent);
|
|
78
|
-
|
|
79
|
-
let reply;
|
|
80
|
-
try {
|
|
81
|
-
reply = await llmClient.completeJson({ system, user });
|
|
82
|
-
} catch (err) {
|
|
83
|
-
throw new TaskExpandError('LLM_CALL_FAILED', `LLM call failed: ${err.message}`, err);
|
|
84
|
-
}
|
|
85
|
-
if (!validateChildren(reply.json)) {
|
|
56
|
+
if (!validateChildren({ children })) {
|
|
86
57
|
throw new TaskExpandError(
|
|
87
58
|
'INVALID_OUTPUT',
|
|
88
|
-
`
|
|
59
|
+
`child task graph failed schema: ${ajv.errorsText(validateChildren.errors)}`,
|
|
89
60
|
);
|
|
90
61
|
}
|
|
91
62
|
|
|
92
63
|
const seen = new Set();
|
|
93
|
-
const
|
|
64
|
+
const normalized = children.map((c) => {
|
|
94
65
|
if (seen.has(c.id)) {
|
|
95
66
|
throw new TaskExpandError('INVALID_OUTPUT', `duplicate child id "${c.id}"`);
|
|
96
67
|
}
|
|
@@ -111,13 +82,13 @@ async function expandTask(db, {
|
|
|
111
82
|
|
|
112
83
|
return ops.tx(db, () => {
|
|
113
84
|
changes.assertTaskCreationAllowed(db, { change_id: parent.change_id }, { rootDir });
|
|
114
|
-
for (const child of
|
|
85
|
+
for (const child of normalized) {
|
|
115
86
|
ops.createTask(db, child);
|
|
116
87
|
}
|
|
117
88
|
ops.patchTask(db, parent.id, { status: 'expanded' });
|
|
118
89
|
return {
|
|
119
90
|
parent_id: parent.id,
|
|
120
|
-
children,
|
|
91
|
+
children: normalized,
|
|
121
92
|
};
|
|
122
93
|
});
|
|
123
94
|
}
|