ultra-builder-pro-cli 0.5.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 (206) hide show
  1. package/.ultra-template/delivery-report.json +9 -0
  2. package/.ultra-template/docs/research/README.md +53 -0
  3. package/.ultra-template/specs/architecture.md +250 -0
  4. package/.ultra-template/specs/discovery.md +248 -0
  5. package/.ultra-template/specs/product.md +223 -0
  6. package/.ultra-template/tasks/contexts/TEMPLATE.md +57 -0
  7. package/.ultra-template/tasks/tasks.json +33 -0
  8. package/.ultra-template/test-report.json +8 -0
  9. package/CHANGELOG.md +194 -0
  10. package/LICENSE +21 -0
  11. package/README.md +187 -0
  12. package/adapters/_shared/codex-assets.cjs +1128 -0
  13. package/adapters/_shared/codex-ultra-tools-entry.cjs +11 -0
  14. package/adapters/_shared/file-ops.cjs +115 -0
  15. package/adapters/_shared/frontmatter.cjs +52 -0
  16. package/adapters/_shared/handbook.cjs +144 -0
  17. package/adapters/_shared/md-to-toml.cjs +54 -0
  18. package/adapters/_shared/path-rewrite.cjs +79 -0
  19. package/adapters/_shared/rtk-detect.cjs +86 -0
  20. package/adapters/_shared/runtime-assets.cjs +70 -0
  21. package/adapters/_shared/settings-merge.cjs +76 -0
  22. package/adapters/_shared/tests/file-ops.test.cjs +54 -0
  23. package/adapters/_shared/tests/frontmatter.test.cjs +47 -0
  24. package/adapters/_shared/tests/handbook.test.cjs +86 -0
  25. package/adapters/_shared/tests/md-to-toml.test.cjs +48 -0
  26. package/adapters/_shared/tests/path-rewrite.test.cjs +60 -0
  27. package/adapters/_shared/tests/resolve-target.test.cjs +85 -0
  28. package/adapters/_shared/tests/rtk-detect.test.cjs +116 -0
  29. package/adapters/_shared/tests/runtime-assets.test.cjs +107 -0
  30. package/adapters/_shared/tests/settings-merge.test.cjs +85 -0
  31. package/adapters/_shared/tests/validate.test.cjs +30 -0
  32. package/adapters/_shared/validate.cjs +24 -0
  33. package/adapters/claude.js +182 -0
  34. package/adapters/codex.js +385 -0
  35. package/adapters/gemini.js +204 -0
  36. package/adapters/opencode.js +506 -0
  37. package/adapters/tests/claude.test.cjs +117 -0
  38. package/adapters/tests/codex-hook.test.cjs +90 -0
  39. package/adapters/tests/codex.test.cjs +356 -0
  40. package/adapters/tests/gemini.test.cjs +112 -0
  41. package/adapters/tests/opencode.test.cjs +246 -0
  42. package/agents/code-reviewer.md +102 -0
  43. package/agents/debugger.md +127 -0
  44. package/agents/review-code.md +125 -0
  45. package/agents/review-comments.md +98 -0
  46. package/agents/review-coordinator.md +146 -0
  47. package/agents/review-design.md +133 -0
  48. package/agents/review-errors.md +87 -0
  49. package/agents/review-tests.md +93 -0
  50. package/agents/tdd-runner.md +92 -0
  51. package/bin/handbook.js +54 -0
  52. package/bin/install.js +254 -0
  53. package/bin/orchestrator.js +214 -0
  54. package/commands/learn.md +41 -0
  55. package/commands/ultra-deliver.md +45 -0
  56. package/commands/ultra-dev.md +50 -0
  57. package/commands/ultra-init.md +51 -0
  58. package/commands/ultra-plan.md +49 -0
  59. package/commands/ultra-research.md +59 -0
  60. package/commands/ultra-status.md +46 -0
  61. package/commands/ultra-test.md +48 -0
  62. package/commands/ultra-think.md +45 -0
  63. package/docs/AGENT-CONTEXT.md +143 -0
  64. package/docs/ARCHITECTURE.md +197 -0
  65. package/docs/COMMIT-HASH-BACKFILL.md +101 -0
  66. package/docs/LEGACY-HERMES.md +1175 -0
  67. package/docs/PLAN.zh-CN.md +1683 -0
  68. package/docs/ROADMAP.md +102 -0
  69. package/docs/RUNTIME-COMPAT-MATRIX.md +98 -0
  70. package/docs/STATE-DB-ACCESS-POLICY.md +109 -0
  71. package/docs/USER-HANDBOOK-CONTRACT.md +61 -0
  72. package/hooks/README.md +24 -0
  73. package/hooks/active_task_context.py +44 -0
  74. package/hooks/adapters/codex.py +302 -0
  75. package/hooks/health_check.py +61 -0
  76. package/hooks/pre_stop_check.py +56 -0
  77. package/hooks/subagent_tracker.py +116 -0
  78. package/hooks/workflow_checkpoint.py +56 -0
  79. package/hooks/workflow_context.py +63 -0
  80. package/hooks/workflow_resume.py +50 -0
  81. package/mcp-server/lib/init-project.cjs +162 -0
  82. package/mcp-server/lib/llm-client.cjs +157 -0
  83. package/mcp-server/lib/llm-client.test.cjs +156 -0
  84. package/mcp-server/lib/plan-store.cjs +96 -0
  85. package/mcp-server/lib/plan-store.test.cjs +132 -0
  86. package/mcp-server/lib/prd-parser.cjs +110 -0
  87. package/mcp-server/lib/prd-parser.test.cjs +171 -0
  88. package/mcp-server/lib/pricing.cjs +53 -0
  89. package/mcp-server/lib/projector.cjs +158 -0
  90. package/mcp-server/lib/state-db.cjs +113 -0
  91. package/mcp-server/lib/state-db.test.cjs +116 -0
  92. package/mcp-server/lib/state-ops.cjs +725 -0
  93. package/mcp-server/lib/task-expander.cjs +123 -0
  94. package/mcp-server/lib/task-expander.test.cjs +253 -0
  95. package/mcp-server/lib/telemetry.cjs +72 -0
  96. package/mcp-server/lib/topo.cjs +131 -0
  97. package/mcp-server/lib/topo.test.cjs +158 -0
  98. package/mcp-server/server.cjs +482 -0
  99. package/mcp-server/tests/circuit-breaker.test.cjs +125 -0
  100. package/mcp-server/tests/commit-hash-flow.test.cjs +117 -0
  101. package/mcp-server/tests/concurrency-update.test.cjs +96 -0
  102. package/mcp-server/tests/concurrency.test.cjs +112 -0
  103. package/mcp-server/tests/db-ops.test.cjs +117 -0
  104. package/mcp-server/tests/init-project.test.cjs +141 -0
  105. package/mcp-server/tests/migrate.test.cjs +144 -0
  106. package/mcp-server/tests/projector.test.cjs +130 -0
  107. package/mcp-server/tests/server-contract.test.cjs +750 -0
  108. package/mcp-server/tests/staleness.test.cjs +190 -0
  109. package/mcp-server/tests/state-ops.test.cjs +286 -0
  110. package/mcp-server/tests/tagged-tasks.test.cjs +163 -0
  111. package/mcp-server/tests/telemetry.test.cjs +205 -0
  112. package/orchestrator/auto-merge.cjs +109 -0
  113. package/orchestrator/code-graph-watcher.cjs +121 -0
  114. package/orchestrator/daemon.cjs +112 -0
  115. package/orchestrator/dispatch-rules.cjs +93 -0
  116. package/orchestrator/parallel-orchestrator.cjs +195 -0
  117. package/orchestrator/planner/expand-prompt.cjs +62 -0
  118. package/orchestrator/planner/expand-prompt.test.cjs +52 -0
  119. package/orchestrator/planner/plan-builder.cjs +116 -0
  120. package/orchestrator/planner/plan-builder.test.cjs +165 -0
  121. package/orchestrator/planner/prd-prompt.cjs +44 -0
  122. package/orchestrator/planner/prd-prompt.test.cjs +30 -0
  123. package/orchestrator/recovery.cjs +70 -0
  124. package/orchestrator/session-runner.cjs +224 -0
  125. package/orchestrator/skill-miner.cjs +133 -0
  126. package/orchestrator/tests/auto-merge.test.cjs +216 -0
  127. package/orchestrator/tests/code-graph-watcher.test.cjs +183 -0
  128. package/orchestrator/tests/daemon.test.cjs +260 -0
  129. package/orchestrator/tests/dispatch-rules.test.cjs +122 -0
  130. package/orchestrator/tests/parallel-orchestrator.test.cjs +233 -0
  131. package/orchestrator/tests/phase-8b-gate.test.cjs +183 -0
  132. package/orchestrator/tests/recovery.test.cjs +190 -0
  133. package/orchestrator/tests/session-runner.test.cjs +314 -0
  134. package/orchestrator/tests/skill-miner.test.cjs +147 -0
  135. package/orchestrator/tests/worktree-manager.test.cjs +148 -0
  136. package/orchestrator/worktree-manager.cjs +131 -0
  137. package/output-styles/coding-vibes.md +67 -0
  138. package/output-styles/structural-thinking.md +61 -0
  139. package/package.json +109 -0
  140. package/settings.json +8 -0
  141. package/skills/cc-collab/SKILL.md +41 -0
  142. package/skills/code-review-expert/SKILL.md +118 -0
  143. package/skills/code-review-expert/references/code-quality-checklist.md +112 -0
  144. package/skills/code-review-expert/references/integration-checklist.md +46 -0
  145. package/skills/code-review-expert/references/removal-plan.md +52 -0
  146. package/skills/code-review-expert/references/security-checklist.md +100 -0
  147. package/skills/code-review-expert/references/solid-checklist.md +65 -0
  148. package/skills/codex-collab/SKILL.md +52 -0
  149. package/skills/codex-collab/evals/evals.json +40 -0
  150. package/skills/gemini-collab/SKILL.md +51 -0
  151. package/skills/gemini-collab/evals/evals.json +42 -0
  152. package/skills/integration-rules/SKILL.md +71 -0
  153. package/skills/learn/SKILL.md +117 -0
  154. package/skills/security-rules/SKILL.md +60 -0
  155. package/skills/testing-rules/SKILL.md +56 -0
  156. package/skills/ultra-deliver/SKILL.md +171 -0
  157. package/skills/ultra-dev/SKILL.md +324 -0
  158. package/skills/ultra-init/SKILL.md +148 -0
  159. package/skills/ultra-plan/SKILL.md +314 -0
  160. package/skills/ultra-research/SKILL.md +156 -0
  161. package/skills/ultra-research/steps/step-00-problem-validation.md +154 -0
  162. package/skills/ultra-research/steps/step-01-opportunity-discovery.md +172 -0
  163. package/skills/ultra-research/steps/step-02-market-assessment.md +195 -0
  164. package/skills/ultra-research/steps/step-03-competitive-landscape.md +197 -0
  165. package/skills/ultra-research/steps/step-04-product-strategy.md +196 -0
  166. package/skills/ultra-research/steps/step-05-assumptions-validation.md +234 -0
  167. package/skills/ultra-research/steps/step-10-user-personas.md +169 -0
  168. package/skills/ultra-research/steps/step-11-user-scenarios.md +181 -0
  169. package/skills/ultra-research/steps/step-20-user-stories.md +156 -0
  170. package/skills/ultra-research/steps/step-21-features-scope.md +157 -0
  171. package/skills/ultra-research/steps/step-22-success-metrics.md +195 -0
  172. package/skills/ultra-research/steps/step-30-architecture-context.md +154 -0
  173. package/skills/ultra-research/steps/step-31-solution-strategy.md +172 -0
  174. package/skills/ultra-research/steps/step-32-building-blocks.md +188 -0
  175. package/skills/ultra-research/steps/step-40-deployment.md +123 -0
  176. package/skills/ultra-research/steps/step-41-quality-risks.md +135 -0
  177. package/skills/ultra-research/steps/step-99-synthesis.md +188 -0
  178. package/skills/ultra-review/SKILL.md +437 -0
  179. package/skills/ultra-review/references/unified-schema.md +127 -0
  180. package/skills/ultra-review/scripts/review_verdict_update.py +99 -0
  181. package/skills/ultra-review/scripts/review_wait.py +120 -0
  182. package/skills/ultra-status/SKILL.md +162 -0
  183. package/skills/ultra-test/SKILL.md +195 -0
  184. package/skills/ultra-think/SKILL.md +109 -0
  185. package/skills/ultra-verify/SKILL.md +111 -0
  186. package/skills/ultra-verify/evals/evals.json +51 -0
  187. package/skills/ultra-verify/references/confidence-system.md +38 -0
  188. package/skills/ultra-verify/references/cross-verify-modes.md +73 -0
  189. package/skills/ultra-verify/scripts/verify_wait.py +131 -0
  190. package/spec/cli-protocol.md +118 -0
  191. package/spec/mcp-tools.yaml +658 -0
  192. package/spec/schemas/command-manifest.schema.json +47 -0
  193. package/spec/schemas/context-file.v4.5.schema.json +54 -0
  194. package/spec/schemas/mcp-tools.schema.json +136 -0
  195. package/spec/schemas/skill-manifest.schema.json +94 -0
  196. package/spec/schemas/state-db.sql +154 -0
  197. package/spec/schemas/tasks.v4.5.schema.json +66 -0
  198. package/ultra-tools/cli.cjs +121 -0
  199. package/ultra-tools/commands/db.cjs +175 -0
  200. package/ultra-tools/commands/legacy-memory.cjs +159 -0
  201. package/ultra-tools/commands/migrate.cjs +354 -0
  202. package/ultra-tools/commands/session.cjs +180 -0
  203. package/ultra-tools/commands/status.cjs +158 -0
  204. package/ultra-tools/commands/task.cjs +95 -0
  205. package/ultra-tools/legacy-memory.test.cjs +80 -0
  206. package/ultra-tools/status-command.test.cjs +132 -0
@@ -0,0 +1,195 @@
1
+ 'use strict';
2
+
3
+ // Phase 8B.2 — Parallel orchestrator.
4
+ //
5
+ // Consumes an execution-plan.json (built by Phase 8A.4a plan-builder) and
6
+ // drives per-wave session spawning. Each wave is either parallel (batch
7
+ // Promise.all on proc exit) or serial (await per task) based on the
8
+ // file-overlap detection already done by plan-builder.
9
+ //
10
+ // Responsibilities:
11
+ // • load plan (object or path)
12
+ // • for each wave: evaluate dispatch-rules per task → spawn sessions via
13
+ // session-runner → wait for exit → flip session status → task status
14
+ // • emit wave_started / wave_completed / plan_completed events
15
+ //
16
+ // Not responsible for:
17
+ // • circuit breaker (Phase 5.2 recordTaskFailure owns that path)
18
+ // • worktree lifecycle beyond what session-runner already does
19
+ // • merge back (Phase 8B.4 layers on top of closeSession)
20
+
21
+ const fs = require('node:fs');
22
+
23
+ const planStore = require('../mcp-server/lib/plan-store.cjs');
24
+ const ops = require('../mcp-server/lib/state-ops.cjs');
25
+ const runner = require('./session-runner.cjs');
26
+ const dispatchRules = require('./dispatch-rules.cjs');
27
+
28
+ function resolvePlan({ plan, planPath, repoRoot }) {
29
+ if (plan) return plan;
30
+ if (planPath) return JSON.parse(fs.readFileSync(planPath, 'utf8'));
31
+ const loaded = planStore.loadPlanArtifact(repoRoot);
32
+ if (!loaded) {
33
+ const err = new Error('runPlan: no plan found (pass plan or planPath, or pre-write artifact)');
34
+ err.code = 'PLAN_NOT_FOUND';
35
+ throw err;
36
+ }
37
+ return loaded;
38
+ }
39
+
40
+ function awaitExit(proc) {
41
+ return new Promise((resolve) => {
42
+ if (!proc) { resolve({ code: 0, signal: null }); return; }
43
+ if (proc.exitCode !== null && proc.exitCode !== undefined) {
44
+ resolve({ code: proc.exitCode, signal: proc.signalCode || null });
45
+ return;
46
+ }
47
+ proc.on('exit', (code, signal) => resolve({ code: code === null ? -1 : code, signal }));
48
+ });
49
+ }
50
+
51
+ function finalizeSession(db, handle, code, ctx = {}) {
52
+ const sessionStatus = code === 0 ? 'completed' : 'crashed';
53
+ // Task status: success → completed; failure → revert to pending so the
54
+ // recovery layer (Phase 5.1) / circuit breaker can take over next tick.
55
+ const taskStatus = code === 0 ? 'completed' : 'pending';
56
+ try { ops.patchTask(db, handle.task_id, { status: taskStatus }); }
57
+ catch (err) {
58
+ process.stderr.write(`parallel-orchestrator: patchTask(${handle.task_id}→${taskStatus}) failed: ${err.message}\n`);
59
+ }
60
+ // closeSession flips session row status, removes worktree, and (opt-in)
61
+ // runs auto-merge. For crashed sessions we skip auto-merge — there's
62
+ // nothing to merge back from a broken run.
63
+ let merge;
64
+ try {
65
+ const closeResult = runner.closeSession(
66
+ { db, repoRoot: ctx.repoRoot, sid: handle.sid },
67
+ {
68
+ status: sessionStatus,
69
+ autoMerge: !!ctx.autoMerge && sessionStatus === 'completed',
70
+ mergeBaseBranch: ctx.mergeBaseBranch || 'main',
71
+ },
72
+ );
73
+ merge = closeResult.merge;
74
+ } catch (err) {
75
+ process.stderr.write(`parallel-orchestrator: closeSession(${handle.sid}) failed: ${err.message}\n`);
76
+ }
77
+ return { sid: handle.sid, task_id: handle.task_id, code, status: sessionStatus, merge };
78
+ }
79
+
80
+ async function runTask(db, repoRoot, task, wave, ctx) {
81
+ const { runtimes, command, commandArgs, commandArgsFor, onError } = ctx;
82
+ const decision = dispatchRules.evaluate({
83
+ task,
84
+ deps_ready: true, // topo order guarantees all deps are in prior waves
85
+ available_runtimes: runtimes,
86
+ breaker_state: 'ok', // admissionCheck below enforces the real gate
87
+ wave,
88
+ });
89
+ if (decision.action !== 'spawn_agent') {
90
+ if (onError) onError(new Error(`task ${task.id}: deferred/blocked by rule ${decision.rule_id}`));
91
+ return { sid: null, task_id: task.id, code: -2, status: decision.action };
92
+ }
93
+
94
+ const args = commandArgsFor ? commandArgsFor(task) : commandArgs;
95
+ let handle;
96
+ try {
97
+ handle = runner.spawnSession({
98
+ db, repoRoot,
99
+ task_id: task.id,
100
+ runtime: decision.runtime,
101
+ command, args,
102
+ });
103
+ handle.task_id = task.id;
104
+ } catch (err) {
105
+ if (onError) onError(err);
106
+ return { sid: null, task_id: task.id, code: -3, status: 'spawn_failed' };
107
+ }
108
+
109
+ // Required transition: pending → in_progress before the child's exit may
110
+ // flip it to completed. STATUS_TRANSITIONS forbids pending → completed.
111
+ try { ops.patchTask(db, task.id, { status: 'in_progress' }); }
112
+ catch (err) {
113
+ process.stderr.write(`parallel-orchestrator: patchTask(${task.id}→in_progress) failed: ${err.message}\n`);
114
+ }
115
+
116
+ const { code } = await awaitExit(handle.process);
117
+ return finalizeSession(db, handle, code, { ...ctx, repoRoot });
118
+ }
119
+
120
+ async function runWave(db, repoRoot, wave, ctx) {
121
+ ops.appendEvent(db, {
122
+ type: 'wave_started',
123
+ payload: { wave_id: wave.id, tasks: wave.tasks, parallel: wave.parallel },
124
+ });
125
+
126
+ const tasks = wave.tasks
127
+ .map((id) => ops.readTask(db, id))
128
+ .filter((t) => {
129
+ if (!t && ctx.onError) ctx.onError(new Error(`task not found in db (skipped)`));
130
+ return !!t;
131
+ });
132
+
133
+ let results;
134
+ if (wave.parallel && tasks.length > 1) {
135
+ results = await Promise.all(tasks.map((task) => runTask(db, repoRoot, task, wave, ctx)));
136
+ } else {
137
+ results = [];
138
+ for (const task of tasks) {
139
+ results.push(await runTask(db, repoRoot, task, wave, ctx));
140
+ }
141
+ }
142
+
143
+ ops.appendEvent(db, {
144
+ type: 'wave_completed',
145
+ payload: {
146
+ wave_id: wave.id,
147
+ completed: results.filter((r) => r.status === 'completed').length,
148
+ crashed: results.filter((r) => r.status === 'crashed').length,
149
+ deferred: results.filter((r) => r.status === 'defer' || r.status === 'block').length,
150
+ },
151
+ });
152
+ return results;
153
+ }
154
+
155
+ async function runPlan({
156
+ db, repoRoot, plan, planPath,
157
+ runtimes,
158
+ command = null, commandArgs = [], commandArgsFor = null,
159
+ autoMerge = false, mergeBaseBranch = 'main',
160
+ onError = null,
161
+ }) {
162
+ if (!db) throw new Error('runPlan: db required');
163
+ if (!repoRoot) throw new Error('runPlan: repoRoot required');
164
+ if (!Array.isArray(runtimes) || runtimes.length === 0) {
165
+ throw new Error('runPlan: runtimes array required');
166
+ }
167
+ const resolved = resolvePlan({ plan, planPath, repoRoot });
168
+
169
+ const ctx = {
170
+ runtimes, command, commandArgs, commandArgsFor, onError,
171
+ autoMerge, mergeBaseBranch,
172
+ };
173
+ const allResults = [];
174
+ for (const wave of resolved.waves) {
175
+ const results = await runWave(db, repoRoot, wave, ctx);
176
+ allResults.push(...results);
177
+ }
178
+
179
+ ops.appendEvent(db, {
180
+ type: 'plan_completed',
181
+ payload: {
182
+ waves: resolved.waves.length,
183
+ completed: allResults.filter((r) => r.status === 'completed').length,
184
+ crashed: allResults.filter((r) => r.status === 'crashed').length,
185
+ },
186
+ });
187
+
188
+ return { waves: resolved.waves.length, results: allResults };
189
+ }
190
+
191
+ module.exports = {
192
+ runPlan,
193
+ // exposed for targeted tests
194
+ _internal: { runWave, runTask, resolvePlan, awaitExit, finalizeSession },
195
+ };
@@ -0,0 +1,62 @@
1
+ 'use strict';
2
+
3
+ // Phase 8A.3 — Prompt templates for task → subtasks expansion.
4
+ //
5
+ // Parent context (title, type, priority, complexity, files_modified) is
6
+ // serialized into the user prompt so the LLM can produce children aligned
7
+ // with the parent's shape. sub_count defaults to 3 when omitted.
8
+
9
+ const DEFAULT_SUB_COUNT = 3;
10
+
11
+ function buildExpandSystemPrompt({ sub_count = DEFAULT_SUB_COUNT } = {}) {
12
+ return `You are an engineering task decomposer. Break a single complex task into ${sub_count} smaller subtasks that together deliver the parent's scope.
13
+
14
+ Output STRICT JSON matching this shape — nothing else, no markdown fences, no prose:
15
+
16
+ {
17
+ "children": [
18
+ {
19
+ "id": "child-1",
20
+ "title": "Imperative short title (3-80 chars)",
21
+ "type": "architecture | feature | bugfix",
22
+ "priority": "P0 | P1 | P2 | P3",
23
+ "complexity": 1-10,
24
+ "deps": ["child-2"],
25
+ "files_modified": ["src/path/to/file.ts"]
26
+ }
27
+ ]
28
+ }
29
+
30
+ Rules:
31
+ - Exactly ${sub_count} children.
32
+ - Each id matches /^[a-zA-Z0-9_\\-]+$/ and is unique in the list.
33
+ - "deps" references siblings in the same list; omit or use [] when none.
34
+ - "files_modified" overlaps with the parent's files_modified where possible.
35
+ - Each child's complexity is < parent's complexity; sum should approximate the parent.
36
+ - Return JSON only.`;
37
+ }
38
+
39
+ function buildExpandUserPrompt(parent) {
40
+ const files = Array.isArray(parent.files_modified) ? parent.files_modified : [];
41
+ const deps = Array.isArray(parent.deps) ? parent.deps : [];
42
+ return [
43
+ 'Parent task to decompose:',
44
+ '',
45
+ `- id: ${parent.id}`,
46
+ `- title: ${parent.title}`,
47
+ `- type: ${parent.type}`,
48
+ `- priority: ${parent.priority}`,
49
+ parent.complexity != null ? `- complexity: ${parent.complexity}` : null,
50
+ files.length ? `- files_modified: ${JSON.stringify(files)}` : null,
51
+ deps.length ? `- existing deps: ${JSON.stringify(deps)}` : null,
52
+ parent.trace_to ? `- trace_to: ${parent.trace_to}` : null,
53
+ '',
54
+ 'Return the children list as JSON following the system schema.',
55
+ ].filter((l) => l !== null).join('\n');
56
+ }
57
+
58
+ module.exports = {
59
+ buildExpandSystemPrompt,
60
+ buildExpandUserPrompt,
61
+ DEFAULT_SUB_COUNT,
62
+ };
@@ -0,0 +1,52 @@
1
+ 'use strict';
2
+
3
+ const test = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+
6
+ const {
7
+ buildExpandSystemPrompt,
8
+ buildExpandUserPrompt,
9
+ DEFAULT_SUB_COUNT,
10
+ } = require('./expand-prompt.cjs');
11
+
12
+ test('buildExpandSystemPrompt: mentions sub_count explicitly', () => {
13
+ const s = buildExpandSystemPrompt({ sub_count: 4 });
14
+ assert.match(s, /Exactly 4 children/);
15
+ assert.match(s, /STRICT JSON/);
16
+ });
17
+
18
+ test('buildExpandSystemPrompt: falls back to DEFAULT_SUB_COUNT when omitted', () => {
19
+ const s = buildExpandSystemPrompt({});
20
+ assert.match(s, new RegExp(`Exactly ${DEFAULT_SUB_COUNT} children`));
21
+ });
22
+
23
+ test('buildExpandUserPrompt: serializes parent fields into prompt body', () => {
24
+ const parent = {
25
+ id: 'parent-1',
26
+ title: 'Build search feature',
27
+ type: 'feature',
28
+ priority: 'P1',
29
+ complexity: 8,
30
+ files_modified: ['src/search/index.ts', 'src/search/query.ts'],
31
+ deps: ['infra-1'],
32
+ trace_to: 'spec §3.2',
33
+ };
34
+ const body = buildExpandUserPrompt(parent);
35
+ assert.match(body, /parent-1/);
36
+ assert.match(body, /Build search feature/);
37
+ assert.match(body, /complexity: 8/);
38
+ assert.match(body, /src\/search\/index\.ts/);
39
+ assert.match(body, /infra-1/);
40
+ assert.match(body, /spec §3\.2/);
41
+ });
42
+
43
+ test('buildExpandUserPrompt: omits empty optional fields', () => {
44
+ const parent = {
45
+ id: 'p', title: 'Tiny task', type: 'feature', priority: 'P3',
46
+ complexity: null, files_modified: [], deps: [],
47
+ };
48
+ const body = buildExpandUserPrompt(parent);
49
+ assert.doesNotMatch(body, /files_modified/);
50
+ assert.doesNotMatch(body, /existing deps/);
51
+ assert.doesNotMatch(body, /complexity:/);
52
+ });
@@ -0,0 +1,116 @@
1
+ 'use strict';
2
+
3
+ // Phase 8A.4a — Execution plan builder (Functional Core, no IO).
4
+ //
5
+ // buildPlan(tasks) → { waves, ownership_forecast, conflict_surface,
6
+ // estimated_cost_usd, estimated_duration_min, cycles }.
7
+ // Composes topo.computeWaves + pricing.computeCost with a file-overlap
8
+ // conflict detector. Downstream 8A.4b writes this object to
9
+ // .ultra/execution-plan.json.
10
+
11
+ const { computeWaves } = require('../../mcp-server/lib/topo.cjs');
12
+ const { computeCost } = require('../../mcp-server/lib/pricing.cjs');
13
+
14
+ const DEFAULT_COMPLEXITY = 3;
15
+ const TOKENS_PER_COMPLEXITY = Object.freeze({ input: 5000, output: 2000 });
16
+ const DURATION_MIN_PER_COMPLEXITY = 5;
17
+
18
+ function buildPlan(tasks, { runtime = 'claude', model = null } = {}) {
19
+ if (!Array.isArray(tasks)) {
20
+ throw new TypeError('buildPlan: tasks must be an array');
21
+ }
22
+
23
+ const graph = tasks.map((t) => ({
24
+ id: t.id,
25
+ deps: Array.isArray(t.deps) ? t.deps : [],
26
+ }));
27
+ const { waves, cycles } = computeWaves(graph);
28
+
29
+ const byId = new Map(tasks.map((t) => [t.id, t]));
30
+
31
+ const conflict_surface = [];
32
+ const wavePayload = waves.map((waveTasks, idx) => {
33
+ const pairs = listWaveConflicts(waveTasks, byId);
34
+ for (const p of pairs) {
35
+ conflict_surface.push({
36
+ files: p.files,
37
+ tasks: p.tasks,
38
+ recommend: 'sequentialize',
39
+ });
40
+ }
41
+ const hasConflict = pairs.length > 0;
42
+ const entry = {
43
+ id: idx + 1,
44
+ tasks: waveTasks,
45
+ parallel: !hasConflict && waveTasks.length > 1,
46
+ };
47
+ if (hasConflict) {
48
+ const sharedFiles = [...new Set(pairs.flatMap((p) => p.files))];
49
+ entry.reason = `shared files: ${sharedFiles.join(', ')}`;
50
+ }
51
+ return entry;
52
+ });
53
+
54
+ const ownership_forecast = {};
55
+ for (const t of tasks) {
56
+ ownership_forecast[t.id] = Array.isArray(t.files_modified) ? t.files_modified : [];
57
+ }
58
+
59
+ const estimated_cost_usd = round4(
60
+ tasks.reduce((sum, t) => {
61
+ const complexity = Number(t.complexity || DEFAULT_COMPLEXITY);
62
+ const ti = complexity * TOKENS_PER_COMPLEXITY.input;
63
+ const to = complexity * TOKENS_PER_COMPLEXITY.output;
64
+ return sum + (computeCost(runtime, model, ti, to) || 0);
65
+ }, 0),
66
+ );
67
+
68
+ const estimated_duration_min = wavePayload.reduce((acc, wv) => {
69
+ const complexities = wv.tasks.map(
70
+ (id) => Number(byId.get(id)?.complexity || DEFAULT_COMPLEXITY),
71
+ );
72
+ if (wv.parallel) {
73
+ return acc + Math.max(0, ...complexities) * DURATION_MIN_PER_COMPLEXITY;
74
+ }
75
+ return acc + complexities.reduce((a, b) => a + b, 0) * DURATION_MIN_PER_COMPLEXITY;
76
+ }, 0);
77
+
78
+ return {
79
+ waves: wavePayload,
80
+ ownership_forecast,
81
+ conflict_surface,
82
+ estimated_cost_usd,
83
+ estimated_duration_min,
84
+ cycles,
85
+ };
86
+ }
87
+
88
+ function listWaveConflicts(waveTasks, byId) {
89
+ const conflicts = [];
90
+ for (let i = 0; i < waveTasks.length; i++) {
91
+ for (let j = i + 1; j < waveTasks.length; j++) {
92
+ const a = waveTasks[i];
93
+ const b = waveTasks[j];
94
+ const aFiles = new Set(byId.get(a)?.files_modified || []);
95
+ const bFiles = new Set(byId.get(b)?.files_modified || []);
96
+ const overlap = [];
97
+ for (const f of aFiles) if (bFiles.has(f)) overlap.push(f);
98
+ if (overlap.length > 0) {
99
+ conflicts.push({ files: overlap, tasks: [a, b] });
100
+ }
101
+ }
102
+ }
103
+ return conflicts;
104
+ }
105
+
106
+ function round4(n) {
107
+ return Math.round(n * 10000) / 10000;
108
+ }
109
+
110
+ module.exports = {
111
+ buildPlan,
112
+ listWaveConflicts,
113
+ DEFAULT_COMPLEXITY,
114
+ TOKENS_PER_COMPLEXITY,
115
+ DURATION_MIN_PER_COMPLEXITY,
116
+ };
@@ -0,0 +1,165 @@
1
+ 'use strict';
2
+
3
+ const test = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+
6
+ const { buildPlan, listWaveConflicts } = require('./plan-builder.cjs');
7
+
8
+ function mk(id, deps = [], files = [], complexity = 3) {
9
+ return { id, deps, files_modified: files, complexity };
10
+ }
11
+
12
+ test('buildPlan fixture 1: 5 tasks, no file overlap → 2 waves, no conflicts', () => {
13
+ const tasks = [
14
+ mk('a', [], ['src/a.ts'], 2),
15
+ mk('b', ['a'], ['src/b.ts'], 3),
16
+ mk('c', ['a'], ['src/c.ts'], 4),
17
+ mk('d', ['b', 'c'], ['src/d.ts'], 5),
18
+ mk('e', [], ['src/e.ts'], 2),
19
+ ];
20
+ const plan = buildPlan(tasks);
21
+ assert.equal(plan.waves.length, 3);
22
+ assert.equal(plan.conflict_surface.length, 0);
23
+ // wave 1 should contain a+e, both parallel-safe
24
+ assert.deepEqual(new Set(plan.waves[0].tasks), new Set(['a', 'e']));
25
+ assert.equal(plan.waves[0].parallel, true);
26
+ assert.deepEqual(plan.cycles, []);
27
+ assert.ok(plan.estimated_cost_usd > 0);
28
+ assert.ok(plan.estimated_duration_min > 0);
29
+ });
30
+
31
+ test('buildPlan fixture 2: same wave, shared file → conflict_surface + parallel=false', () => {
32
+ const tasks = [
33
+ mk('a', []),
34
+ mk('b', ['a'], ['src/utils.ts']),
35
+ mk('c', ['a'], ['src/utils.ts']),
36
+ ];
37
+ const plan = buildPlan(tasks);
38
+ assert.equal(plan.waves.length, 2);
39
+ const w2 = plan.waves[1];
40
+ assert.equal(w2.parallel, false);
41
+ assert.match(w2.reason, /shared files: src\/utils\.ts/);
42
+
43
+ assert.equal(plan.conflict_surface.length, 1);
44
+ const c = plan.conflict_surface[0];
45
+ assert.deepEqual(c.files, ['src/utils.ts']);
46
+ assert.deepEqual(new Set(c.tasks), new Set(['b', 'c']));
47
+ assert.equal(c.recommend, 'sequentialize');
48
+ });
49
+
50
+ test('buildPlan fixture 3: cross-wave same file → NOT a conflict', () => {
51
+ const tasks = [
52
+ mk('a', [], ['src/shared.ts']),
53
+ mk('b', ['a'], ['src/shared.ts']),
54
+ ];
55
+ const plan = buildPlan(tasks);
56
+ assert.equal(plan.conflict_surface.length, 0);
57
+ // wave 1 has only `a`, not parallel; wave 2 has only `b`, not parallel
58
+ for (const w of plan.waves) {
59
+ assert.equal(w.parallel, false);
60
+ assert.ok(!w.reason);
61
+ }
62
+ });
63
+
64
+ test('buildPlan fixture 4: empty tasks → empty plan, non-negative numbers', () => {
65
+ const plan = buildPlan([]);
66
+ assert.deepEqual(plan.waves, []);
67
+ assert.deepEqual(plan.conflict_surface, []);
68
+ assert.deepEqual(plan.ownership_forecast, {});
69
+ assert.equal(plan.estimated_cost_usd, 0);
70
+ assert.equal(plan.estimated_duration_min, 0);
71
+ assert.deepEqual(plan.cycles, []);
72
+ });
73
+
74
+ test('buildPlan fixture 5: cost + duration math (parallel vs serial waves)', () => {
75
+ // wave 1 (parallel): a complexity=2, b complexity=4 → max = 4 * 5 = 20 min
76
+ // wave 2 (1 task, not parallel): c complexity=6 → 6 * 5 = 30 min
77
+ // total duration = 50 min
78
+ const tasks = [
79
+ mk('a', [], ['src/a.ts'], 2),
80
+ mk('b', [], ['src/b.ts'], 4),
81
+ mk('c', ['a', 'b'], ['src/c.ts'], 6),
82
+ ];
83
+ const plan = buildPlan(tasks);
84
+ assert.equal(plan.estimated_duration_min, 50);
85
+ // cost: 3 tasks × sum(complexity)=12 × (5000*3e-6 + 2000*15e-6) per complexity unit
86
+ // = 12 × (0.015 + 0.030) = 12 × 0.045 = 0.54 usd
87
+ assert.ok(Math.abs(plan.estimated_cost_usd - 0.54) < 0.001);
88
+ });
89
+
90
+ test('buildPlan: serial wave (with conflict) sums complexities instead of max', () => {
91
+ // both tasks in same wave share a file → wave is serial
92
+ // wave 1: a(complexity=3), b(complexity=3) → (3+3) * 5 = 30 min
93
+ const tasks = [
94
+ mk('a', [], ['src/utils.ts'], 3),
95
+ mk('b', [], ['src/utils.ts'], 3),
96
+ ];
97
+ const plan = buildPlan(tasks);
98
+ assert.equal(plan.waves[0].parallel, false);
99
+ assert.equal(plan.estimated_duration_min, 30);
100
+ });
101
+
102
+ test('buildPlan: ownership_forecast captures per-task files_modified verbatim', () => {
103
+ const tasks = [
104
+ mk('a', [], ['src/a.ts', 'src/a.util.ts']),
105
+ mk('b', [], ['src/b.ts']),
106
+ ];
107
+ const plan = buildPlan(tasks);
108
+ assert.deepEqual(plan.ownership_forecast, {
109
+ a: ['src/a.ts', 'src/a.util.ts'],
110
+ b: ['src/b.ts'],
111
+ });
112
+ });
113
+
114
+ test('buildPlan: cycle in task graph surfaced in plan.cycles', () => {
115
+ const tasks = [
116
+ mk('x', ['y']),
117
+ mk('y', ['x']),
118
+ ];
119
+ const plan = buildPlan(tasks);
120
+ assert.deepEqual(plan.waves, []);
121
+ assert.equal(plan.cycles.length, 1);
122
+ assert.deepEqual(new Set(plan.cycles[0]), new Set(['x', 'y']));
123
+ });
124
+
125
+ test('buildPlan: missing complexity defaults to DEFAULT_COMPLEXITY for cost calc', () => {
126
+ const tasks = [{ id: 'a', deps: [], files_modified: [] }];
127
+ const plan = buildPlan(tasks);
128
+ // complexity 3 → 15k in, 6k out → cost > 0
129
+ assert.ok(plan.estimated_cost_usd > 0);
130
+ assert.equal(plan.estimated_duration_min, 15); // 3 * 5 (single-task wave, not parallel)
131
+ });
132
+
133
+ test('buildPlan: non-array input → TypeError', () => {
134
+ assert.throws(() => buildPlan(null), TypeError);
135
+ assert.throws(() => buildPlan({}), TypeError);
136
+ });
137
+
138
+ test('listWaveConflicts: no overlap → empty', () => {
139
+ const byId = new Map([
140
+ ['a', { id: 'a', files_modified: ['x.ts'] }],
141
+ ['b', { id: 'b', files_modified: ['y.ts'] }],
142
+ ]);
143
+ assert.deepEqual(listWaveConflicts(['a', 'b'], byId), []);
144
+ });
145
+
146
+ test('listWaveConflicts: pair overlap reported once', () => {
147
+ const byId = new Map([
148
+ ['a', { id: 'a', files_modified: ['shared.ts', 'x.ts'] }],
149
+ ['b', { id: 'b', files_modified: ['shared.ts'] }],
150
+ ]);
151
+ const out = listWaveConflicts(['a', 'b'], byId);
152
+ assert.equal(out.length, 1);
153
+ assert.deepEqual(out[0].files, ['shared.ts']);
154
+ assert.deepEqual(out[0].tasks, ['a', 'b']);
155
+ });
156
+
157
+ test('listWaveConflicts: three-way overlap yields C(3,2)=3 pairs', () => {
158
+ const byId = new Map([
159
+ ['a', { id: 'a', files_modified: ['x.ts'] }],
160
+ ['b', { id: 'b', files_modified: ['x.ts'] }],
161
+ ['c', { id: 'c', files_modified: ['x.ts'] }],
162
+ ]);
163
+ const out = listWaveConflicts(['a', 'b', 'c'], byId);
164
+ assert.equal(out.length, 3);
165
+ });
@@ -0,0 +1,44 @@
1
+ 'use strict';
2
+
3
+ // Phase 8A.1 — Prompt templates for PRD → task-list parsing.
4
+ //
5
+ // Kept in a dedicated module so downstream evaluation (fixture regression,
6
+ // prompt A/B testing) can diff the template against commits without touching
7
+ // parser logic.
8
+
9
+ const SYSTEM_PROMPT = `You are an engineering planner. Decompose a product requirement document (PRD) into a topologically ordered task list that a coding agent can execute sequentially or in parallel.
10
+
11
+ Output STRICT JSON matching this shape — nothing else, no markdown fences, no prose:
12
+
13
+ {
14
+ "tasks": [
15
+ {
16
+ "id": "task-1",
17
+ "title": "Imperative short title (3-80 chars)",
18
+ "type": "architecture | feature | bugfix",
19
+ "priority": "P0 | P1 | P2 | P3",
20
+ "complexity": 1-10,
21
+ "deps": ["task-2"],
22
+ "files_modified": ["src/path/to/file.ts"]
23
+ }
24
+ ]
25
+ }
26
+
27
+ Rules:
28
+ - Every task id matches /^task-[0-9]+$/ and is unique in the list.
29
+ - "deps" references other ids in the same list; omit or use [] when none.
30
+ - "files_modified" is your best guess of affected paths; use [] when unsure.
31
+ - "complexity" 1 = trivial, 10 = multi-day. Tasks at 7+ will be auto-expanded later.
32
+ - "priority" — P0 blocks other work, P1 critical path, P2 standard, P3 nice-to-have.
33
+ - "type" — architecture for design/scaffolding, feature for new capability, bugfix for correcting existing behaviour.
34
+ - Return JSON only. No surrounding text, no trailing explanation.`;
35
+
36
+ function buildSystemPrompt() {
37
+ return SYSTEM_PROMPT;
38
+ }
39
+
40
+ function buildUserPrompt(prdText) {
41
+ return `PRD:\n\n${prdText}\n\nReturn the task list as JSON following the system schema.`;
42
+ }
43
+
44
+ module.exports = { SYSTEM_PROMPT, buildSystemPrompt, buildUserPrompt };
@@ -0,0 +1,30 @@
1
+ 'use strict';
2
+
3
+ const test = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+
6
+ const {
7
+ SYSTEM_PROMPT,
8
+ buildSystemPrompt,
9
+ buildUserPrompt,
10
+ } = require('./prd-prompt.cjs');
11
+
12
+ test('buildSystemPrompt returns the module-level template', () => {
13
+ assert.equal(buildSystemPrompt(), SYSTEM_PROMPT);
14
+ assert.match(SYSTEM_PROMPT, /engineering planner/i);
15
+ assert.match(SYSTEM_PROMPT, /STRICT JSON/);
16
+ assert.match(SYSTEM_PROMPT, /\/\^task-\[0-9\]\+\$\//);
17
+ });
18
+
19
+ test('buildUserPrompt embeds PRD text verbatim', () => {
20
+ const prd = 'As a user I want to search products by keyword';
21
+ const out = buildUserPrompt(prd);
22
+ assert.ok(out.includes(prd), 'user prompt must contain the PRD text');
23
+ assert.match(out, /JSON/);
24
+ });
25
+
26
+ test('buildUserPrompt tolerates long PRD text without truncation', () => {
27
+ const long = 'line\n'.repeat(500);
28
+ const out = buildUserPrompt(long);
29
+ assert.ok(out.includes(long));
30
+ });