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.
Files changed (45) hide show
  1. package/CHANGELOG.md +43 -1
  2. package/README.md +7 -8
  3. package/adapters/_shared/codex-assets.cjs +23 -163
  4. package/adapters/_shared/runtime-assets.cjs +1 -0
  5. package/adapters/_shared/tests/runtime-assets.test.cjs +18 -0
  6. package/adapters/opencode.js +30 -120
  7. package/adapters/tests/claude.test.cjs +3 -2
  8. package/adapters/tests/codex-hook.test.cjs +34 -7
  9. package/adapters/tests/codex.test.cjs +13 -6
  10. package/adapters/tests/kimi-hook.test.cjs +34 -7
  11. package/adapters/tests/opencode.test.cjs +70 -84
  12. package/docs/AGENT-CONTEXT.md +6 -3
  13. package/docs/ARCHITECTURE.md +8 -1
  14. package/docs/ROADMAP.md +1 -1
  15. package/docs/RUNTIME-COMPAT-MATRIX.md +4 -4
  16. package/hooks/active_task_context.py +22 -50
  17. package/hooks/context_spine.py +61 -332
  18. package/hooks/health_check.py +19 -17
  19. package/hooks/pre_stop_check.py +28 -33
  20. package/hooks/subagent_tracker.py +13 -19
  21. package/hooks/workflow_checkpoint.py +39 -47
  22. package/hooks/workflow_context.py +4 -9
  23. package/hooks/workflow_resume.py +48 -128
  24. package/mcp-server/breadcrumb.cjs +22 -0
  25. package/mcp-server/lib/prd-parser.cjs +14 -41
  26. package/mcp-server/lib/prd-parser.test.cjs +49 -138
  27. package/mcp-server/lib/project-breadcrumb.cjs +139 -0
  28. package/mcp-server/lib/project-breadcrumb.test.cjs +112 -0
  29. package/mcp-server/lib/task-expander.cjs +9 -38
  30. package/mcp-server/lib/task-expander.test.cjs +54 -209
  31. package/mcp-server/server.cjs +21 -35
  32. package/mcp-server/tests/server-contract.test.cjs +41 -45
  33. package/package.json +2 -4
  34. package/skills/cc-collab/SKILL.md +2 -2
  35. package/skills/codex-collab/SKILL.md +2 -3
  36. package/skills/ultra-plan/SKILL.md +15 -5
  37. package/skills/ultra-verify/SKILL.md +21 -30
  38. package/skills/ultra-verify/scripts/verify_wait.py +54 -54
  39. package/spec/mcp-tools.yaml +38 -10
  40. package/mcp-server/lib/llm-client.cjs +0 -157
  41. package/mcp-server/lib/llm-client.test.cjs +0 -156
  42. package/orchestrator/planner/expand-prompt.cjs +0 -62
  43. package/orchestrator/planner/expand-prompt.test.cjs +0 -52
  44. package/orchestrator/planner/prd-prompt.cjs +0 -44
  45. 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
+ });
@@ -1,21 +1,11 @@
1
1
  'use strict';
2
2
 
3
- // Phase 8A.3 — Expand a complex task into subtasks via injected llmClient.
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
- async function expandTask(db, {
56
- id, sub_count, strategy = 'llm', llmClient, rootDir = process.cwd(),
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 (!llmClient || typeof llmClient.completeJson !== 'function') {
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
- `LLM output failed schema: ${ajv.errorsText(validateChildren.errors)}`,
59
+ `child task graph failed schema: ${ajv.errorsText(validateChildren.errors)}`,
89
60
  );
90
61
  }
91
62
 
92
63
  const seen = new Set();
93
- const children = reply.json.children.map((c) => {
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 children) {
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
  }