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,123 @@
1
+ 'use strict';
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.
10
+
11
+ const Ajv = require('ajv/dist/2020');
12
+ const ops = require('./state-ops.cjs');
13
+ const {
14
+ buildExpandSystemPrompt,
15
+ buildExpandUserPrompt,
16
+ } = require('../../orchestrator/planner/expand-prompt.cjs');
17
+
18
+ const CHILDREN_SHAPE = Object.freeze({
19
+ type: 'object',
20
+ required: ['children'],
21
+ properties: {
22
+ children: {
23
+ type: 'array',
24
+ minItems: 1,
25
+ items: {
26
+ type: 'object',
27
+ required: ['id', 'title', 'type', 'priority'],
28
+ properties: {
29
+ id: { type: 'string', pattern: '^[a-zA-Z0-9_\\-]+$' },
30
+ title: { type: 'string', minLength: 3, maxLength: 200 },
31
+ type: { type: 'string', enum: ['architecture', 'feature', 'bugfix'] },
32
+ priority: { type: 'string', enum: ['P0', 'P1', 'P2', 'P3'] },
33
+ complexity: { type: 'integer', minimum: 1, maximum: 10 },
34
+ deps: { type: 'array', items: { type: 'string' } },
35
+ files_modified: { type: 'array', items: { type: 'string' } },
36
+ },
37
+ },
38
+ },
39
+ },
40
+ });
41
+
42
+ const ajv = new Ajv({ allErrors: true, strict: false });
43
+ const validateChildren = ajv.compile(CHILDREN_SHAPE);
44
+
45
+ class TaskExpandError extends Error {
46
+ constructor(code, message, cause) {
47
+ super(message);
48
+ this.name = 'TaskExpandError';
49
+ this.code = code;
50
+ if (cause) this.cause = cause;
51
+ }
52
+ }
53
+
54
+ async function expandTask(db, { id, sub_count, strategy = 'llm', llmClient } = {}) {
55
+ if (!id) throw new TaskExpandError('VALIDATION_ERROR', 'id required');
56
+
57
+ const parent = ops.readTask(db, id);
58
+ if (!parent) throw new TaskExpandError('TASK_NOT_FOUND', `no task ${id}`);
59
+ if (parent.status === 'expanded') {
60
+ throw new TaskExpandError('ALREADY_EXPANDED', `task ${id} is already expanded`);
61
+ }
62
+ if (strategy === 'manual') {
63
+ throw new TaskExpandError('NOT_IMPLEMENTED', 'strategy "manual" not implemented; use "llm"');
64
+ }
65
+ if (strategy !== 'llm') {
66
+ throw new TaskExpandError('VALIDATION_ERROR', `unknown strategy "${strategy}"`);
67
+ }
68
+ if (!llmClient || typeof llmClient.completeJson !== 'function') {
69
+ throw new TaskExpandError('NO_LLM_CLIENT', 'llmClient.completeJson is required');
70
+ }
71
+
72
+ const system = buildExpandSystemPrompt({ sub_count });
73
+ const user = buildExpandUserPrompt(parent);
74
+
75
+ let reply;
76
+ try {
77
+ reply = await llmClient.completeJson({ system, user });
78
+ } catch (err) {
79
+ throw new TaskExpandError('LLM_CALL_FAILED', `LLM call failed: ${err.message}`, err);
80
+ }
81
+ if (!validateChildren(reply.json)) {
82
+ throw new TaskExpandError(
83
+ 'INVALID_OUTPUT',
84
+ `LLM output failed schema: ${ajv.errorsText(validateChildren.errors)}`,
85
+ );
86
+ }
87
+
88
+ const seen = new Set();
89
+ const children = reply.json.children.map((c) => {
90
+ if (seen.has(c.id)) {
91
+ throw new TaskExpandError('INVALID_OUTPUT', `duplicate child id "${c.id}"`);
92
+ }
93
+ seen.add(c.id);
94
+ return {
95
+ id: c.id,
96
+ title: c.title,
97
+ type: c.type,
98
+ priority: c.priority,
99
+ complexity: c.complexity ?? null,
100
+ deps: Array.isArray(c.deps) ? c.deps : [],
101
+ files_modified: Array.isArray(c.files_modified) ? c.files_modified : [],
102
+ parent_id: parent.id,
103
+ tag: parent.tag,
104
+ };
105
+ });
106
+
107
+ return ops.tx(db, () => {
108
+ for (const child of children) {
109
+ ops.createTask(db, child);
110
+ }
111
+ ops.patchTask(db, parent.id, { status: 'expanded' });
112
+ return {
113
+ parent_id: parent.id,
114
+ children,
115
+ };
116
+ });
117
+ }
118
+
119
+ module.exports = {
120
+ expandTask,
121
+ TaskExpandError,
122
+ CHILDREN_SHAPE,
123
+ };
@@ -0,0 +1,253 @@
1
+ 'use strict';
2
+
3
+ // Integration tests for task-expander — real SQLite (Testcontainers-style
4
+ // in-process) so parent_id column + status transition + task_expanded event
5
+ // are exercised end-to-end. The only Test Double is the LLM client, which
6
+ // we inject with deterministic child fixtures.
7
+
8
+ const test = require('node:test');
9
+ const assert = require('node:assert/strict');
10
+ const fs = require('node:fs');
11
+ const os = require('node:os');
12
+ const path = require('node:path');
13
+
14
+ const { initStateDb, closeStateDb } = require('./state-db.cjs');
15
+ const ops = require('./state-ops.cjs');
16
+ const { expandTask, TaskExpandError } = require('./task-expander.cjs');
17
+
18
+ function tmpDb() {
19
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-expand-'));
20
+ const { db } = initStateDb(path.join(dir, 'state.db'));
21
+ return { dir, db };
22
+ }
23
+
24
+ function cleanup({ dir, db }) {
25
+ closeStateDb(db);
26
+ fs.rmSync(dir, { recursive: true, force: true });
27
+ }
28
+
29
+ function fakeLlm(childrenJson, { shouldFail = false, usage = {} } = {}) {
30
+ return {
31
+ provider: 'anthropic',
32
+ model: 'claude-sonnet-4-6',
33
+ async completeJson() {
34
+ if (shouldFail) throw new Error('upstream LLM 500');
35
+ return {
36
+ json: { children: childrenJson },
37
+ raw: JSON.stringify({ children: childrenJson }),
38
+ usage,
39
+ model: 'claude-sonnet-4-6',
40
+ provider: 'anthropic',
41
+ };
42
+ },
43
+ };
44
+ }
45
+
46
+ function seedParent(db, overrides = {}) {
47
+ return ops.createTask(db, {
48
+ id: 'parent-1',
49
+ title: 'Build authentication module',
50
+ type: 'feature',
51
+ priority: 'P1',
52
+ complexity: 9,
53
+ files_modified: ['src/auth/index.ts'],
54
+ ...overrides,
55
+ });
56
+ }
57
+
58
+ const HAPPY_CHILDREN = [
59
+ { id: 'child-1', title: 'Design auth schema', type: 'architecture', priority: 'P1', complexity: 4, deps: [], files_modified: ['src/auth/schema.ts'] },
60
+ { id: 'child-2', title: 'Implement login endpoint', type: 'feature', priority: 'P1', complexity: 5, deps: ['child-1'], files_modified: ['src/auth/login.ts'] },
61
+ { id: 'child-3', title: 'Add signup endpoint', type: 'feature', priority: 'P2', complexity: 3, deps: ['child-1'], files_modified: ['src/auth/signup.ts'] },
62
+ ];
63
+
64
+ test('expandTask happy path: creates children, flips parent status, emits task_expanded', async () => {
65
+ const ctx = tmpDb();
66
+ try {
67
+ seedParent(ctx.db);
68
+ const result = await expandTask(ctx.db, {
69
+ id: 'parent-1',
70
+ strategy: 'llm',
71
+ llmClient: fakeLlm(HAPPY_CHILDREN),
72
+ });
73
+ assert.equal(result.parent_id, 'parent-1');
74
+ assert.equal(result.children.length, 3);
75
+
76
+ const parentAfter = ops.readTask(ctx.db, 'parent-1');
77
+ assert.equal(parentAfter.status, 'expanded');
78
+
79
+ for (const child of HAPPY_CHILDREN) {
80
+ const row = ops.readTask(ctx.db, child.id);
81
+ assert.ok(row, `child ${child.id} missing`);
82
+ assert.equal(row.parent_id, 'parent-1');
83
+ assert.equal(row.status, 'pending');
84
+ }
85
+
86
+ const events = ops.subscribeEventsSince(ctx.db, { since_id: 0, limit: 100 });
87
+ const expandedEvents = events.events.filter((e) => e.type === 'task_expanded');
88
+ assert.equal(expandedEvents.length, 1);
89
+ assert.equal(expandedEvents[0].task_id, 'parent-1');
90
+ const createdEvents = events.events.filter((e) => e.type === 'task_created');
91
+ assert.ok(createdEvents.length >= 4, 'parent + 3 children task_created events');
92
+ } finally {
93
+ cleanup(ctx);
94
+ }
95
+ });
96
+
97
+ test('expandTask: missing parent → TASK_NOT_FOUND', async () => {
98
+ const ctx = tmpDb();
99
+ try {
100
+ await assert.rejects(
101
+ expandTask(ctx.db, {
102
+ id: 'does-not-exist',
103
+ llmClient: fakeLlm(HAPPY_CHILDREN),
104
+ }),
105
+ (err) => err instanceof TaskExpandError && err.code === 'TASK_NOT_FOUND',
106
+ );
107
+ } finally {
108
+ cleanup(ctx);
109
+ }
110
+ });
111
+
112
+ test('expandTask: already-expanded parent → ALREADY_EXPANDED', async () => {
113
+ const ctx = tmpDb();
114
+ try {
115
+ seedParent(ctx.db);
116
+ // First expansion succeeds
117
+ await expandTask(ctx.db, {
118
+ id: 'parent-1',
119
+ llmClient: fakeLlm(HAPPY_CHILDREN),
120
+ });
121
+ // Second attempt must refuse
122
+ await assert.rejects(
123
+ expandTask(ctx.db, {
124
+ id: 'parent-1',
125
+ llmClient: fakeLlm(HAPPY_CHILDREN.map((c, i) => ({ ...c, id: `other-${i}` }))),
126
+ }),
127
+ (err) => err instanceof TaskExpandError && err.code === 'ALREADY_EXPANDED',
128
+ );
129
+ } finally {
130
+ cleanup(ctx);
131
+ }
132
+ });
133
+
134
+ test('expandTask: strategy="manual" → NOT_IMPLEMENTED', async () => {
135
+ const ctx = tmpDb();
136
+ try {
137
+ seedParent(ctx.db);
138
+ await assert.rejects(
139
+ expandTask(ctx.db, {
140
+ id: 'parent-1',
141
+ strategy: 'manual',
142
+ llmClient: fakeLlm(HAPPY_CHILDREN),
143
+ }),
144
+ (err) => err instanceof TaskExpandError && err.code === 'NOT_IMPLEMENTED',
145
+ );
146
+ } finally {
147
+ cleanup(ctx);
148
+ }
149
+ });
150
+
151
+ test('expandTask: missing llmClient → NO_LLM_CLIENT', async () => {
152
+ const ctx = tmpDb();
153
+ try {
154
+ seedParent(ctx.db);
155
+ await assert.rejects(
156
+ expandTask(ctx.db, { id: 'parent-1' }),
157
+ (err) => err instanceof TaskExpandError && err.code === 'NO_LLM_CLIENT',
158
+ );
159
+ } finally {
160
+ cleanup(ctx);
161
+ }
162
+ });
163
+
164
+ test('expandTask: LLM throws → LLM_CALL_FAILED', async () => {
165
+ const ctx = tmpDb();
166
+ try {
167
+ seedParent(ctx.db);
168
+ await assert.rejects(
169
+ expandTask(ctx.db, {
170
+ id: 'parent-1',
171
+ llmClient: fakeLlm([], { shouldFail: true }),
172
+ }),
173
+ (err) => err instanceof TaskExpandError && err.code === 'LLM_CALL_FAILED',
174
+ );
175
+ // parent status must be unchanged
176
+ assert.equal(ops.readTask(ctx.db, 'parent-1').status, 'pending');
177
+ } finally {
178
+ cleanup(ctx);
179
+ }
180
+ });
181
+
182
+ test('expandTask: invalid child shape → INVALID_OUTPUT, parent untouched', async () => {
183
+ const ctx = tmpDb();
184
+ try {
185
+ seedParent(ctx.db);
186
+ const bad = [{ id: 'child-1', title: 'x', type: 'feature', priority: 'P1' }]; // title too short
187
+ await assert.rejects(
188
+ expandTask(ctx.db, { id: 'parent-1', llmClient: fakeLlm(bad) }),
189
+ (err) => err instanceof TaskExpandError && err.code === 'INVALID_OUTPUT',
190
+ );
191
+ assert.equal(ops.readTask(ctx.db, 'parent-1').status, 'pending');
192
+ } finally {
193
+ cleanup(ctx);
194
+ }
195
+ });
196
+
197
+ test('expandTask: duplicate child id → INVALID_OUTPUT', async () => {
198
+ const ctx = tmpDb();
199
+ try {
200
+ seedParent(ctx.db);
201
+ const dup = [
202
+ { id: 'same', title: 'First child', type: 'feature', priority: 'P2' },
203
+ { id: 'same', title: 'Second child', type: 'feature', priority: 'P2' },
204
+ ];
205
+ await assert.rejects(
206
+ expandTask(ctx.db, { id: 'parent-1', llmClient: fakeLlm(dup) }),
207
+ (err) => err instanceof TaskExpandError && err.code === 'INVALID_OUTPUT',
208
+ );
209
+ } finally {
210
+ cleanup(ctx);
211
+ }
212
+ });
213
+
214
+ test('expandTask: tx rollback — child id collides with existing task → parent status unchanged, no children inserted', async () => {
215
+ const ctx = tmpDb();
216
+ try {
217
+ seedParent(ctx.db);
218
+ // Pre-create a task with the same id one of the children will try
219
+ ops.createTask(ctx.db, {
220
+ id: 'child-1',
221
+ title: 'Pre-existing task',
222
+ type: 'bugfix',
223
+ priority: 'P3',
224
+ });
225
+ await assert.rejects(
226
+ expandTask(ctx.db, { id: 'parent-1', llmClient: fakeLlm(HAPPY_CHILDREN) }),
227
+ /* any error is acceptable — the point is no partial state */
228
+ );
229
+ assert.equal(ops.readTask(ctx.db, 'parent-1').status, 'pending');
230
+ // child-2 / child-3 must NOT have been inserted
231
+ assert.equal(ops.readTask(ctx.db, 'child-2'), null);
232
+ assert.equal(ops.readTask(ctx.db, 'child-3'), null);
233
+ } finally {
234
+ cleanup(ctx);
235
+ }
236
+ });
237
+
238
+ test('expandTask: children inherit parent tag', async () => {
239
+ const ctx = tmpDb();
240
+ try {
241
+ seedParent(ctx.db, { tag: 'feat-auth' });
242
+ await expandTask(ctx.db, {
243
+ id: 'parent-1',
244
+ llmClient: fakeLlm(HAPPY_CHILDREN),
245
+ });
246
+ for (const child of HAPPY_CHILDREN) {
247
+ const row = ops.readTask(ctx.db, child.id);
248
+ assert.equal(row.tag, 'feat-auth');
249
+ }
250
+ } finally {
251
+ cleanup(ctx);
252
+ }
253
+ });
@@ -0,0 +1,72 @@
1
+ 'use strict';
2
+
3
+ // Phase 6.2 — Telemetry collector.
4
+ //
5
+ // Double-write model: state.db.telemetry (table, queryable via state-ops
6
+ // aggregations) + .ultra/telemetry/{YYYY-MM-DD}.jsonl (full payload, easy
7
+ // grep, no schema constraint). The table is the authoritative source for
8
+ // cost panel queries; the jsonl is for human ops + audit trails + future
9
+ // Phase 6.5 SDK-usage correlation.
10
+
11
+ const fs = require('node:fs');
12
+ const path = require('node:path');
13
+
14
+ const { computeCost } = require('./pricing.cjs');
15
+
16
+ function todayStamp() {
17
+ return new Date().toISOString().slice(0, 10);
18
+ }
19
+
20
+ function telemetryJsonlPath(rootDir) {
21
+ return path.join(rootDir || '.', '.ultra', 'telemetry', `${todayStamp()}.jsonl`);
22
+ }
23
+
24
+ function appendTelemetry(db, {
25
+ event_type,
26
+ tool_name = null,
27
+ session_id = null,
28
+ runtime = null,
29
+ tokens_input = null,
30
+ tokens_output = null,
31
+ cost_usd = null,
32
+ task_id = null,
33
+ payload = null,
34
+ rootDir = '.',
35
+ } = {}) {
36
+ if (!event_type) throw new Error('appendTelemetry: event_type required');
37
+
38
+ // Auto-derive cost when tokens + runtime known and caller didn't supply one.
39
+ let effectiveCost = cost_usd;
40
+ if (effectiveCost === null && runtime && tokens_input !== null && tokens_output !== null) {
41
+ effectiveCost = computeCost(runtime, payload && payload.model, tokens_input, tokens_output);
42
+ }
43
+
44
+ const now = new Date().toISOString();
45
+ const result = db.prepare(
46
+ 'INSERT INTO telemetry (session_id, event_type, tokens_input, tokens_output, tool_name, cost_usd, ts) VALUES (?, ?, ?, ?, ?, ?, ?)',
47
+ ).run(session_id, event_type, tokens_input, tokens_output, tool_name, effectiveCost, now);
48
+
49
+ const jsonl = telemetryJsonlPath(rootDir);
50
+ fs.mkdirSync(path.dirname(jsonl), { recursive: true });
51
+ fs.appendFileSync(jsonl, JSON.stringify({
52
+ id: Number(result.lastInsertRowid),
53
+ ts: now,
54
+ session_id,
55
+ event_type,
56
+ tool_name,
57
+ runtime,
58
+ tokens_input,
59
+ tokens_output,
60
+ cost_usd: effectiveCost,
61
+ task_id,
62
+ payload,
63
+ }) + '\n');
64
+
65
+ return { id: Number(result.lastInsertRowid), ts: now, cost_usd: effectiveCost };
66
+ }
67
+
68
+ module.exports = {
69
+ appendTelemetry,
70
+ telemetryJsonlPath,
71
+ todayStamp,
72
+ };
@@ -0,0 +1,131 @@
1
+ 'use strict';
2
+
3
+ // Phase 8A.2 — Dependency topology (Functional Core, no IO).
4
+ //
5
+ // computeWaves groups tasks into execution waves where every task in wave N
6
+ // has all its deps satisfied by waves < N. Cycles are surfaced as separate
7
+ // SCCs via Tarjan so each independent loop is reported on its own.
8
+ //
9
+ // Input: tasks = [{ id: string, deps: string[] }, ...]
10
+ // Output: { waves: string[][], cycles: string[][] }
11
+ // - waves[i] is the set of task ids runnable in wave i. Order within a
12
+ // wave is not meaningful — callers should treat each wave as a set.
13
+ // - cycles[j] holds the ids of a strongly-connected component (size >= 2,
14
+ // or size 1 with a self-dep). Empty when the graph is acyclic.
15
+ //
16
+ // Deps that point to ids not present in the input set are treated as
17
+ // already satisfied (the task was completed in a previous plan, or lives
18
+ // outside the current topo scope).
19
+
20
+ function computeWaves(tasks) {
21
+ if (!Array.isArray(tasks)) {
22
+ throw new TypeError('computeWaves: tasks must be an array');
23
+ }
24
+
25
+ const ids = new Set(tasks.map((t) => t.id));
26
+
27
+ // adj: prerequisite id → [dependent ids]
28
+ // inDeg: id → count of in-scope deps not yet satisfied
29
+ const adj = new Map();
30
+ const inDeg = new Map();
31
+
32
+ for (const t of tasks) {
33
+ if (!inDeg.has(t.id)) inDeg.set(t.id, 0);
34
+ }
35
+ for (const t of tasks) {
36
+ const deps = Array.isArray(t.deps) ? t.deps : [];
37
+ for (const d of deps) {
38
+ if (!ids.has(d)) continue;
39
+ if (!adj.has(d)) adj.set(d, []);
40
+ adj.get(d).push(t.id);
41
+ inDeg.set(t.id, (inDeg.get(t.id) || 0) + 1);
42
+ }
43
+ }
44
+
45
+ const waves = [];
46
+ const processed = new Set();
47
+ let current = tasks.map((t) => t.id).filter((id) => inDeg.get(id) === 0);
48
+
49
+ while (current.length > 0) {
50
+ waves.push(current);
51
+ for (const id of current) processed.add(id);
52
+ const next = [];
53
+ for (const id of current) {
54
+ const children = adj.get(id) || [];
55
+ for (const c of children) {
56
+ const newDeg = (inDeg.get(c) || 0) - 1;
57
+ inDeg.set(c, newDeg);
58
+ if (newDeg === 0 && !processed.has(c)) next.push(c);
59
+ }
60
+ }
61
+ current = next;
62
+ }
63
+
64
+ const residual = tasks.map((t) => t.id).filter((id) => !processed.has(id));
65
+ const cycles = residual.length === 0 ? [] : findSccs(residual, tasks);
66
+
67
+ return { waves, cycles };
68
+ }
69
+
70
+ function findSccs(residualIds, tasks) {
71
+ const inScope = new Set(residualIds);
72
+ const adj = new Map();
73
+ const selfLoops = new Set();
74
+
75
+ for (const t of tasks) {
76
+ if (!inScope.has(t.id)) continue;
77
+ const deps = Array.isArray(t.deps) ? t.deps : [];
78
+ for (const d of deps) {
79
+ if (!inScope.has(d)) continue;
80
+ if (d === t.id) { selfLoops.add(t.id); continue; }
81
+ if (!adj.has(d)) adj.set(d, []);
82
+ adj.get(d).push(t.id);
83
+ }
84
+ }
85
+
86
+ let index = 0;
87
+ const indices = new Map();
88
+ const lowlinks = new Map();
89
+ const onStack = new Set();
90
+ const stack = [];
91
+ const sccs = [];
92
+
93
+ function strongconnect(v) {
94
+ indices.set(v, index);
95
+ lowlinks.set(v, index);
96
+ index++;
97
+ stack.push(v);
98
+ onStack.add(v);
99
+
100
+ const succ = adj.get(v) || [];
101
+ for (const w of succ) {
102
+ if (!indices.has(w)) {
103
+ strongconnect(w);
104
+ lowlinks.set(v, Math.min(lowlinks.get(v), lowlinks.get(w)));
105
+ } else if (onStack.has(w)) {
106
+ lowlinks.set(v, Math.min(lowlinks.get(v), indices.get(w)));
107
+ }
108
+ }
109
+
110
+ if (lowlinks.get(v) === indices.get(v)) {
111
+ const scc = [];
112
+ let w;
113
+ do {
114
+ w = stack.pop();
115
+ onStack.delete(w);
116
+ scc.push(w);
117
+ } while (w !== v);
118
+ if (scc.length > 1 || selfLoops.has(v)) {
119
+ sccs.push(scc);
120
+ }
121
+ }
122
+ }
123
+
124
+ for (const v of residualIds) {
125
+ if (!indices.has(v)) strongconnect(v);
126
+ }
127
+
128
+ return sccs;
129
+ }
130
+
131
+ module.exports = { computeWaves };