ultra-builder-pro-cli 0.13.0 → 0.15.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 (172) hide show
  1. package/.ultra-template/docs/research/README.md +11 -7
  2. package/.ultra-template/specs/architecture.md +36 -36
  3. package/.ultra-template/specs/discovery.md +46 -33
  4. package/.ultra-template/specs/product.md +38 -29
  5. package/.ultra-template/specs/research-distillate.md +48 -0
  6. package/.ultra-template/tasks/contexts/TEMPLATE.md +11 -28
  7. package/CHANGELOG.md +86 -1
  8. package/README.md +40 -26
  9. package/adapters/_shared/codex-assets.cjs +29 -165
  10. package/adapters/_shared/file-ops.cjs +6 -2
  11. package/adapters/_shared/runtime-assets.cjs +1 -0
  12. package/adapters/claude.js +3 -2
  13. package/adapters/codex.js +1 -0
  14. package/adapters/kimi.js +3 -2
  15. package/adapters/opencode.js +33 -122
  16. package/agents/review-coordinator.md +9 -6
  17. package/docs/AGENT-CONTEXT.md +21 -11
  18. package/docs/ARCHITECTURE.md +41 -13
  19. package/docs/PLAN.zh-CN.md +3 -2
  20. package/docs/ROADMAP.md +9 -5
  21. package/docs/RUNTIME-COMPAT-MATRIX.md +6 -6
  22. package/docs/STATE-DB-ACCESS-POLICY.md +27 -25
  23. package/docs/WORKFLOW-LIFECYCLE.md +266 -0
  24. package/hooks/README.md +6 -1
  25. package/hooks/active_task_context.py +22 -50
  26. package/hooks/context_spine.py +61 -332
  27. package/hooks/health_check.py +29 -18
  28. package/hooks/pre_stop_check.py +28 -33
  29. package/hooks/subagent_tracker.py +54 -85
  30. package/hooks/workflow_checkpoint.py +39 -47
  31. package/hooks/workflow_context.py +4 -9
  32. package/hooks/workflow_resume.py +48 -128
  33. package/mcp-server/breadcrumb.cjs +22 -0
  34. package/mcp-server/hook-event.cjs +34 -0
  35. package/mcp-server/lib/archive-journal.cjs +17 -5
  36. package/mcp-server/lib/baseline-workflow.cjs +158 -18
  37. package/mcp-server/lib/change-workflow.cjs +550 -85
  38. package/mcp-server/lib/context-spine.cjs +242 -24
  39. package/mcp-server/lib/doctor.cjs +33 -3
  40. package/mcp-server/lib/init-project.cjs +210 -16
  41. package/mcp-server/lib/plan-store.cjs +4 -1
  42. package/mcp-server/lib/prd-parser.cjs +14 -41
  43. package/mcp-server/lib/pricing.cjs +7 -10
  44. package/mcp-server/lib/project-breadcrumb.cjs +145 -0
  45. package/mcp-server/lib/projector.cjs +57 -3
  46. package/mcp-server/lib/spec-learning.cjs +79 -2
  47. package/mcp-server/lib/state-db.cjs +145 -8
  48. package/mcp-server/lib/state-ops.cjs +160 -9
  49. package/mcp-server/lib/task-expander.cjs +9 -38
  50. package/mcp-server/lib/workflow-state.cjs +2292 -0
  51. package/mcp-server/server.cjs +182 -50
  52. package/orchestrator/daemon.cjs +1 -2
  53. package/orchestrator/dispatch-rules.cjs +5 -31
  54. package/orchestrator/planner/plan-builder.cjs +21 -12
  55. package/package.json +14 -6
  56. package/skills/cc-collab/SKILL.md +2 -2
  57. package/skills/codex-collab/SKILL.md +2 -3
  58. package/skills/ultra-change/SKILL.md +53 -68
  59. package/skills/ultra-change/references/change-contract.md +36 -0
  60. package/skills/ultra-deliver/SKILL.md +69 -79
  61. package/skills/ultra-deliver/references/baseline-reconciliation.md +20 -0
  62. package/skills/ultra-dev/SKILL.md +54 -59
  63. package/skills/ultra-doctor/SKILL.md +40 -71
  64. package/skills/ultra-init/SKILL.md +96 -103
  65. package/skills/ultra-init/references/brownfield-adoption.md +2 -2
  66. package/skills/ultra-plan/SKILL.md +79 -75
  67. package/skills/ultra-plan/references/semantic-preflight.md +18 -0
  68. package/skills/ultra-research/SKILL.md +87 -60
  69. package/skills/ultra-research/references/00-problem-validation.md +36 -0
  70. package/skills/ultra-research/references/01-opportunity-discovery.md +33 -0
  71. package/skills/ultra-research/references/02-market-assessment.md +30 -0
  72. package/skills/ultra-research/references/03-competitive-landscape.md +31 -0
  73. package/skills/ultra-research/references/04-product-strategy.md +32 -0
  74. package/skills/ultra-research/references/05-assumptions-validation.md +31 -0
  75. package/skills/ultra-research/references/10-user-personas.md +29 -0
  76. package/skills/ultra-research/references/11-user-scenarios.md +28 -0
  77. package/skills/ultra-research/references/20-user-stories.md +30 -0
  78. package/skills/ultra-research/references/21-features-scope.md +29 -0
  79. package/skills/ultra-research/references/22-success-metrics.md +28 -0
  80. package/skills/ultra-research/references/30-architecture-context.md +30 -0
  81. package/skills/ultra-research/references/31-solution-strategy.md +28 -0
  82. package/skills/ultra-research/references/32-building-blocks.md +28 -0
  83. package/skills/ultra-research/references/40-deployment.md +29 -0
  84. package/skills/ultra-research/references/41-quality-risks.md +28 -0
  85. package/skills/ultra-research/references/99-synthesis.md +35 -0
  86. package/skills/ultra-review/SKILL.md +57 -73
  87. package/skills/ultra-review/references/review-modes.md +27 -0
  88. package/skills/ultra-review/references/unified-schema.md +25 -0
  89. package/skills/ultra-review/scripts/review_wait.py +40 -3
  90. package/skills/ultra-status/SKILL.md +41 -45
  91. package/skills/ultra-test/SKILL.md +48 -55
  92. package/skills/ultra-verify/SKILL.md +22 -31
  93. package/skills/ultra-verify/references/{confidence-system.md → evidence-status.md} +3 -3
  94. package/skills/ultra-verify/scripts/verify_wait.py +54 -54
  95. package/spec/cli-protocol.md +7 -2
  96. package/spec/mcp-tools.yaml +422 -73
  97. package/spec/schemas/baseline-reconciliation.v1.schema.json +73 -0
  98. package/spec/schemas/delivery-report.v1.schema.json +111 -0
  99. package/spec/schemas/mcp-tools.schema.json +1 -1
  100. package/spec/schemas/state-db.sql +82 -1
  101. package/spec/schemas/tasks.v4.5.schema.json +8 -1
  102. package/spec/schemas/test-report.v1.schema.json +194 -0
  103. package/ultra-tools/cli.cjs +1 -1
  104. package/ultra-tools/commands/migrate.cjs +3 -3
  105. package/ultra-tools/commands/status.cjs +213 -12
  106. package/.ultra-template/delivery-report.json +0 -9
  107. package/.ultra-template/test-report.json +0 -8
  108. package/adapters/_shared/tests/file-ops.test.cjs +0 -54
  109. package/adapters/_shared/tests/frontmatter.test.cjs +0 -47
  110. package/adapters/_shared/tests/handbook.test.cjs +0 -101
  111. package/adapters/_shared/tests/path-rewrite.test.cjs +0 -60
  112. package/adapters/_shared/tests/provenance.test.cjs +0 -133
  113. package/adapters/_shared/tests/resolve-target.test.cjs +0 -75
  114. package/adapters/_shared/tests/runtime-assets.test.cjs +0 -131
  115. package/adapters/_shared/tests/settings-merge.test.cjs +0 -85
  116. package/adapters/_shared/tests/validate.test.cjs +0 -30
  117. package/adapters/tests/claude.test.cjs +0 -137
  118. package/adapters/tests/codex-hook.test.cjs +0 -115
  119. package/adapters/tests/codex.test.cjs +0 -423
  120. package/adapters/tests/kimi-hook.test.cjs +0 -122
  121. package/adapters/tests/kimi.test.cjs +0 -259
  122. package/adapters/tests/opencode.test.cjs +0 -371
  123. package/mcp-server/lib/archive-journal.test.cjs +0 -35
  124. package/mcp-server/lib/baseline-workflow.test.cjs +0 -477
  125. package/mcp-server/lib/change-workflow.test.cjs +0 -971
  126. package/mcp-server/lib/context-spine.test.cjs +0 -93
  127. package/mcp-server/lib/doctor.test.cjs +0 -148
  128. package/mcp-server/lib/llm-client.cjs +0 -157
  129. package/mcp-server/lib/llm-client.test.cjs +0 -156
  130. package/mcp-server/lib/plan-store.test.cjs +0 -132
  131. package/mcp-server/lib/prd-parser.test.cjs +0 -171
  132. package/mcp-server/lib/runtime-state.test.cjs +0 -92
  133. package/mcp-server/lib/spec-learning.test.cjs +0 -67
  134. package/mcp-server/lib/state-db.test.cjs +0 -314
  135. package/mcp-server/lib/task-expander.test.cjs +0 -324
  136. package/mcp-server/lib/topo.test.cjs +0 -158
  137. package/mcp-server/tests/circuit-breaker.test.cjs +0 -125
  138. package/mcp-server/tests/commit-hash-flow.test.cjs +0 -117
  139. package/mcp-server/tests/concurrency-update.test.cjs +0 -96
  140. package/mcp-server/tests/concurrency.test.cjs +0 -112
  141. package/mcp-server/tests/db-ops.test.cjs +0 -121
  142. package/mcp-server/tests/init-project.test.cjs +0 -393
  143. package/mcp-server/tests/migrate.test.cjs +0 -232
  144. package/mcp-server/tests/projector.test.cjs +0 -162
  145. package/mcp-server/tests/server-contract.test.cjs +0 -1471
  146. package/mcp-server/tests/staleness.test.cjs +0 -190
  147. package/mcp-server/tests/state-db.test.cjs +0 -106
  148. package/mcp-server/tests/state-ops.test.cjs +0 -449
  149. package/mcp-server/tests/tagged-tasks.test.cjs +0 -163
  150. package/mcp-server/tests/telemetry.test.cjs +0 -219
  151. package/orchestrator/planner/expand-prompt.cjs +0 -62
  152. package/orchestrator/planner/expand-prompt.test.cjs +0 -52
  153. package/orchestrator/planner/plan-builder.test.cjs +0 -165
  154. package/orchestrator/planner/prd-prompt.cjs +0 -44
  155. package/orchestrator/planner/prd-prompt.test.cjs +0 -30
  156. package/orchestrator/tests/auto-merge.test.cjs +0 -216
  157. package/orchestrator/tests/daemon.test.cjs +0 -311
  158. package/orchestrator/tests/dispatch-rules.test.cjs +0 -124
  159. package/orchestrator/tests/parallel-orchestrator.test.cjs +0 -233
  160. package/orchestrator/tests/phase-8b-gate.test.cjs +0 -183
  161. package/orchestrator/tests/recovery.test.cjs +0 -190
  162. package/orchestrator/tests/session-runner.test.cjs +0 -314
  163. package/orchestrator/tests/skill-miner.test.cjs +0 -147
  164. package/orchestrator/tests/worktree-manager.test.cjs +0 -148
  165. package/skills/ultra-research/references/architecture.md +0 -38
  166. package/skills/ultra-research/references/problem-product.md +0 -39
  167. package/skills/ultra-research/references/quality-delivery.md +0 -34
  168. package/skills/ultra-research/references/synthesis.md +0 -38
  169. package/skills/ultra-research/references/users-scope.md +0 -40
  170. package/ultra-tools/legacy-memory.test.cjs +0 -80
  171. package/ultra-tools/status-command.test.cjs +0 -181
  172. package/ultra-tools/system-command.test.cjs +0 -309
@@ -1,216 +0,0 @@
1
- 'use strict';
2
-
3
- // Phase 8B.4 — Auto-merge back.
4
- //
5
- // autoMerge({ repoRoot, worktreePath, baseBranch, sid, task_id, db }):
6
- // • no changes (session_sha === base_sha) → { merged:false, reason:'no_changes' }
7
- // • clean merge → { merged:true } + merged_back event
8
- // • conflict → { merged:false, reason:'conflict', conflict_paths[] }
9
- // + merge_conflict event + merge --abort
10
- // closeSession({ autoMerge:true }) integration:
11
- // • autoMerge=true + conflict → worktree kept on disk (for human to resolve)
12
- // • autoMerge=false (default) → behavior unchanged (Phase 4.5 / 5 / 7 tests)
13
-
14
- const { test } = require('node:test');
15
- const assert = require('node:assert/strict');
16
- const fs = require('node:fs');
17
- const os = require('node:os');
18
- const path = require('node:path');
19
- const { execFileSync } = require('node:child_process');
20
-
21
- const { initStateDb, closeStateDb } = require('../../mcp-server/lib/state-db.cjs');
22
- const ops = require('../../mcp-server/lib/state-ops.cjs');
23
- const wtmgr = require('../worktree-manager.cjs');
24
- const autoMerge = require('../auto-merge.cjs');
25
- const runner = require('../session-runner.cjs');
26
-
27
- function mkRepo() {
28
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-automerge-'));
29
- execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: dir });
30
- execFileSync('git', ['config', 'user.email', 'test@ubp.dev'], { cwd: dir });
31
- execFileSync('git', ['config', 'user.name', 'ubp-test'], { cwd: dir });
32
- fs.writeFileSync(path.join(dir, 'seed.md'), '# seed\n');
33
- // The DB and worktree dirs live under .ultra/ — keep them out of git so
34
- // `git status` in conflict tests isn't polluted by untracked test artifacts.
35
- fs.writeFileSync(path.join(dir, '.gitignore'), '.ultra/\n');
36
- execFileSync('git', ['add', '-A'], { cwd: dir });
37
- execFileSync('git', ['commit', '-q', '-m', 'seed'], { cwd: dir });
38
- return dir;
39
- }
40
-
41
- function mkDb(repoRoot) {
42
- const dbPath = path.join(repoRoot, '.ultra', 'state.db');
43
- fs.mkdirSync(path.dirname(dbPath), { recursive: true });
44
- const { db } = initStateDb(dbPath);
45
- return db;
46
- }
47
-
48
- function commitInWorktree(wtPath, filename, content, msg = 'session change') {
49
- fs.writeFileSync(path.join(wtPath, filename), content);
50
- execFileSync('git', ['add', '-A'], { cwd: wtPath });
51
- execFileSync('git', ['-c', 'user.email=s@ubp.dev', '-c', 'user.name=s-ubp',
52
- 'commit', '-q', '-m', msg], { cwd: wtPath });
53
- return execFileSync('git', ['rev-parse', 'HEAD'], { cwd: wtPath, stdio: ['ignore', 'pipe', 'pipe'] })
54
- .toString().trim();
55
- }
56
-
57
- function cleanup(repoRoot, db) {
58
- try { if (db) closeStateDb(db); } catch (_) { /* best-effort */ }
59
- try { fs.rmSync(repoRoot, { recursive: true, force: true }); } catch (_) { /* best-effort */ }
60
- }
61
-
62
- // ─── autoMerge pure function ──────────────────────────────────────────────
63
-
64
- test('autoMerge: no session commits → merged:false reason:no_changes', () => {
65
- const repo = mkRepo();
66
- try {
67
- const { worktree_path } = wtmgr.allocate({ repoRoot: repo, sid: 's-noop' });
68
- const r = autoMerge.autoMerge({
69
- repoRoot: repo, worktreePath: worktree_path,
70
- baseBranch: 'main', sid: 's-noop',
71
- });
72
- assert.equal(r.merged, false);
73
- assert.equal(r.reason, 'no_changes');
74
- } finally { cleanup(repo); }
75
- });
76
-
77
- test('autoMerge: 3 slices with independent files → all merged back to main', () => {
78
- const repo = mkRepo();
79
- const db = mkDb(repo);
80
- try {
81
- for (const sid of ['s1', 's2', 's3']) {
82
- ops.createTask(db, { id: sid, title: sid, type: 'feature', priority: 'P2' });
83
- }
84
- const shas = {};
85
- for (const [sid, file] of [['s1', 'a.txt'], ['s2', 'b.txt'], ['s3', 'c.txt']]) {
86
- const { worktree_path } = wtmgr.allocate({ repoRoot: repo, sid });
87
- shas[sid] = commitInWorktree(worktree_path, file, `${file}\n`);
88
- const r = autoMerge.autoMerge({
89
- repoRoot: repo, worktreePath: worktree_path,
90
- baseBranch: 'main', sid, task_id: sid, db,
91
- });
92
- assert.equal(r.merged, true, `${sid} should merge`);
93
- assert.equal(r.session_sha, shas[sid]);
94
- }
95
- assert.ok(fs.existsSync(path.join(repo, 'a.txt')));
96
- assert.ok(fs.existsSync(path.join(repo, 'b.txt')));
97
- assert.ok(fs.existsSync(path.join(repo, 'c.txt')));
98
- const { events } = ops.subscribeEventsSince(db, 0);
99
- const merged = events.filter((e) => e.type === 'merged_back');
100
- assert.equal(merged.length, 3);
101
- } finally { cleanup(repo, db); }
102
- });
103
-
104
- test('autoMerge: 2 slices on same file → first merges, second conflict + event', () => {
105
- const repo = mkRepo();
106
- const db = mkDb(repo);
107
- try {
108
- ops.createTask(db, { id: 'cf1', title: 'cf1', type: 'feature', priority: 'P2' });
109
- ops.createTask(db, { id: 'cf2', title: 'cf2', type: 'feature', priority: 'P2' });
110
- const { worktree_path: w1 } = wtmgr.allocate({ repoRoot: repo, sid: 'cf1' });
111
- const { worktree_path: w2 } = wtmgr.allocate({ repoRoot: repo, sid: 'cf2' });
112
- commitInWorktree(w1, 'conflict.txt', 'version-A\n');
113
- commitInWorktree(w2, 'conflict.txt', 'version-B\n');
114
-
115
- const r1 = autoMerge.autoMerge({
116
- repoRoot: repo, worktreePath: w1, baseBranch: 'main', sid: 'cf1', task_id: 'cf1', db,
117
- });
118
- assert.equal(r1.merged, true);
119
- const r2 = autoMerge.autoMerge({
120
- repoRoot: repo, worktreePath: w2, baseBranch: 'main', sid: 'cf2', task_id: 'cf2', db,
121
- });
122
- assert.equal(r2.merged, false);
123
- assert.equal(r2.reason, 'conflict');
124
- assert.ok(Array.isArray(r2.conflict_paths));
125
- assert.ok(r2.conflict_paths.includes('conflict.txt'));
126
-
127
- // main HEAD rolled back to r1's merge commit, not mid-conflict
128
- const status = execFileSync('git', ['status', '--porcelain'], {
129
- cwd: repo, stdio: ['ignore', 'pipe', 'pipe'],
130
- }).toString();
131
- assert.equal(status.trim(), '', 'working tree must be clean after abort');
132
-
133
- const { events } = ops.subscribeEventsSince(db, 0);
134
- const conflict = events.find((e) => e.type === 'merge_conflict');
135
- assert.ok(conflict, 'merge_conflict event expected');
136
- assert.deepEqual(conflict.payload.conflict_paths, ['conflict.txt']);
137
- } finally { cleanup(repo, db); }
138
- });
139
-
140
- // ─── closeSession integration ─────────────────────────────────────────────
141
-
142
- test('closeSession autoMerge=true + clean merge → worktree removed', () => {
143
- const repo = mkRepo();
144
- const db = mkDb(repo);
145
- try {
146
- ops.createTask(db, { id: 'cs-clean', title: 'x', type: 'feature', priority: 'P2' });
147
- const handle = runner.spawnSession({
148
- db, repoRoot: repo,
149
- task_id: 'cs-clean', runtime: 'claude',
150
- command: process.execPath, args: ['-e', 'process.exit(0)'],
151
- });
152
- // Simulate agent commit inside worktree
153
- commitInWorktree(handle.worktree_path, 'new.txt', 'hi\n');
154
- // Wait for child to exit so closeSession can kill cleanly (it already did)
155
- if (handle.process) { try { handle.process.kill('SIGTERM'); } catch (_) { /* noop */ } }
156
-
157
- const result = runner.closeSession(
158
- { db, repoRoot: repo, sid: handle.sid },
159
- { autoMerge: true, mergeBaseBranch: 'main' },
160
- );
161
- assert.equal(result.merge && result.merge.merged, true);
162
- assert.equal(fs.existsSync(handle.worktree_path), false, 'clean merge → worktree removed');
163
- } finally { cleanup(repo, db); }
164
- });
165
-
166
- test('closeSession autoMerge=true + conflict → worktree kept', () => {
167
- const repo = mkRepo();
168
- const db = mkDb(repo);
169
- try {
170
- ops.createTask(db, { id: 'cs-cflict', title: 'x', type: 'feature', priority: 'P2' });
171
-
172
- // Spawn FIRST so the worktree forks at seed commit (before main diverges).
173
- const handle = runner.spawnSession({
174
- db, repoRoot: repo,
175
- task_id: 'cs-cflict', runtime: 'claude',
176
- command: process.execPath, args: ['-e', 'process.exit(0)'],
177
- });
178
- // Advance main with a conflicting version of shared.txt.
179
- fs.writeFileSync(path.join(repo, 'shared.txt'), 'main-version\n');
180
- execFileSync('git', ['add', '-A'], { cwd: repo });
181
- execFileSync('git', ['commit', '-q', '-m', 'main change'], { cwd: repo });
182
- // Session worktree forks off seed and writes its own version of the same file.
183
- commitInWorktree(handle.worktree_path, 'shared.txt', 'session-version\n');
184
-
185
- const result = runner.closeSession(
186
- { db, repoRoot: repo, sid: handle.sid },
187
- { autoMerge: true, mergeBaseBranch: 'main' },
188
- );
189
- assert.equal(result.merge && result.merge.merged, false);
190
- assert.equal(result.merge.reason, 'conflict');
191
- assert.ok(fs.existsSync(handle.worktree_path), 'conflict → worktree preserved for resolution');
192
- } finally { cleanup(repo, db); }
193
- });
194
-
195
- test('closeSession autoMerge=false (default) → no merge, legacy behavior', () => {
196
- const repo = mkRepo();
197
- const db = mkDb(repo);
198
- try {
199
- ops.createTask(db, { id: 'cs-off', title: 'x', type: 'feature', priority: 'P2' });
200
- const handle = runner.spawnSession({
201
- db, repoRoot: repo,
202
- task_id: 'cs-off', runtime: 'claude',
203
- command: process.execPath, args: ['-e', 'process.exit(0)'],
204
- });
205
- commitInWorktree(handle.worktree_path, 'n.txt', 'x\n');
206
-
207
- const result = runner.closeSession(
208
- { db, repoRoot: repo, sid: handle.sid },
209
- {}, // autoMerge not set
210
- );
211
- assert.equal(result.merge, undefined, 'no merge when opt-in disabled');
212
- assert.equal(fs.existsSync(handle.worktree_path), false, 'worktree removed normally');
213
- // main must still be at seed (no merge happened)
214
- assert.equal(fs.existsSync(path.join(repo, 'n.txt')), false);
215
- } finally { cleanup(repo, db); }
216
- });
@@ -1,311 +0,0 @@
1
- 'use strict';
2
-
3
- // Phase 5.4 — Orchestrator daemon:
4
- // • routeTask: pure function — complexity_hint + availableRuntimes → runtime
5
- // • runDaemon: poll loop that spawns pending tasks, respects admission +
6
- // circuit breaker, and shuts down gracefully via stop().
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
- const { execFileSync } = require('node:child_process');
14
-
15
- const { initStateDb, closeStateDb } = require('../../mcp-server/lib/state-db.cjs');
16
- const ops = require('../../mcp-server/lib/state-ops.cjs');
17
- const daemon = require('../daemon.cjs');
18
- const RETIRED_RUNTIME = ['gem', 'ini'].join('');
19
-
20
- const LONG_SLEEP_CMD = process.execPath;
21
- const LONG_SLEEP_ARGS = ['-e', 'setInterval(() => {}, 60000);'];
22
-
23
- function mkRepo() {
24
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-daemon-repo-'));
25
- execFileSync('git', ['init', '-q'], { cwd: dir });
26
- execFileSync('git', ['config', 'user.email', 'test@ubp.dev'], { cwd: dir });
27
- execFileSync('git', ['config', 'user.name', 'ubp-test'], { cwd: dir });
28
- fs.writeFileSync(path.join(dir, 'README.md'), '# test\n');
29
- execFileSync('git', ['add', '-A'], { cwd: dir });
30
- execFileSync('git', ['commit', '-q', '-m', 'seed'], { cwd: dir });
31
- return dir;
32
- }
33
-
34
- function mkDb(repoRoot) {
35
- const { db } = initStateDb(path.join(repoRoot, '.ultra', 'state.db'));
36
- return db;
37
- }
38
-
39
- function cleanup(repoRoot, db, handle) {
40
- if (handle) {
41
- try { handle.stop(); } catch (_) { /* ignore */ }
42
- }
43
- // Kill any stray children spawned by the daemon.
44
- try {
45
- const rows = db.prepare("SELECT pid FROM sessions WHERE pid IS NOT NULL AND status = 'running'").all();
46
- for (const r of rows) {
47
- try { process.kill(r.pid, 'SIGKILL'); } catch (_) { /* ignore */ }
48
- }
49
- } catch (_) { /* ignore */ }
50
- try { closeStateDb(db); } catch (_) { /* ignore */ }
51
- try { fs.rmSync(repoRoot, { recursive: true, force: true }); } catch (_) { /* ignore */ }
52
- }
53
-
54
- // ─── routeTask ────────────────────────────────────────────────────────────
55
-
56
- test('routeTask: haiku hint prefers cheap runtimes', () => {
57
- const runtime = daemon.routeTask(
58
- { id: 't', complexity_hint: 'haiku' },
59
- ['claude', 'opencode', 'codex'],
60
- );
61
- assert.equal(runtime, 'opencode');
62
- });
63
-
64
- test('routeTask: opus hint prefers strong runtimes', () => {
65
- const runtime = daemon.routeTask(
66
- { id: 't', complexity_hint: 'opus' },
67
- ['claude', 'opencode', 'codex'],
68
- );
69
- assert.ok(['claude', 'codex'].includes(runtime), `got ${runtime}`);
70
- });
71
-
72
- test('routeTask: sonnet hint middle-ground', () => {
73
- const runtime = daemon.routeTask(
74
- { id: 't', complexity_hint: 'sonnet' },
75
- ['claude', 'opencode', 'codex'],
76
- );
77
- assert.ok(['claude', 'codex', 'opencode'].includes(runtime));
78
- });
79
-
80
- test('routeTask: respects availability constraint', () => {
81
- const runtime = daemon.routeTask(
82
- { id: 't', complexity_hint: 'opus' },
83
- ['opencode'],
84
- );
85
- // Prefers claude/codex but those aren't available → fall back to any.
86
- assert.equal(runtime, 'opencode');
87
- });
88
-
89
- test('routeTask: no hint skips unsupported runtimes', () => {
90
- const runtime = daemon.routeTask({ id: 't' }, [RETIRED_RUNTIME, 'claude']);
91
- assert.equal(runtime, 'claude');
92
- });
93
-
94
- test('routeTask: no runtimes available → null', () => {
95
- assert.equal(daemon.routeTask({ id: 't' }, []), null);
96
- });
97
-
98
- // ─── runDaemon ────────────────────────────────────────────────────────────
99
-
100
- test('runDaemon spawns pending task within pollMs window', async () => {
101
- const repoRoot = mkRepo();
102
- const db = mkDb(repoRoot);
103
- let handle;
104
- try {
105
- ops.createTask(db, { id: 'd-1', title: 'pending target', type: 'feature', priority: 'P1' });
106
- handle = daemon.runDaemon({
107
- db, repoRoot,
108
- runtimes: ['claude'],
109
- pollMs: 50,
110
- command: LONG_SLEEP_CMD,
111
- commandArgs: LONG_SLEEP_ARGS,
112
- });
113
-
114
- // Wait up to 500ms for the daemon to pick up the task.
115
- const start = Date.now();
116
- let spawned = null;
117
- while (Date.now() - start < 500) {
118
- const sessions = db.prepare("SELECT * FROM sessions WHERE task_id = 'd-1'").all();
119
- if (sessions.length > 0) { spawned = sessions[0]; break; }
120
- await new Promise((r) => setTimeout(r, 25));
121
- }
122
- assert.ok(spawned, 'daemon should have spawned a session within 500ms');
123
- assert.equal(spawned.status, 'running');
124
- assert.equal(spawned.runtime, 'claude');
125
- } finally {
126
- cleanup(repoRoot, db, handle);
127
- }
128
- });
129
-
130
- test('runDaemon skips tripped tasks', async () => {
131
- const repoRoot = mkRepo();
132
- const db = mkDb(repoRoot);
133
- let handle;
134
- try {
135
- ops.createTask(db, { id: 'd-trip', title: 'tripped', type: 'feature', priority: 'P1' });
136
- for (let i = 0; i < 3; i += 1) ops.recordTaskFailure(db, 'd-trip', { reason: 'x' });
137
- assert.equal(ops.isCircuitTripped(db, 'd-trip'), true);
138
-
139
- handle = daemon.runDaemon({
140
- db, repoRoot,
141
- runtimes: ['claude'],
142
- pollMs: 50,
143
- command: LONG_SLEEP_CMD,
144
- commandArgs: LONG_SLEEP_ARGS,
145
- });
146
- await new Promise((r) => setTimeout(r, 300));
147
- const sessions = db.prepare("SELECT * FROM sessions WHERE task_id = 'd-trip'").all();
148
- assert.equal(sessions.length, 0, 'tripped task must not be spawned');
149
- } finally {
150
- cleanup(repoRoot, db, handle);
151
- }
152
- });
153
-
154
- test('runDaemon does not double-spawn same task', async () => {
155
- const repoRoot = mkRepo();
156
- const db = mkDb(repoRoot);
157
- let handle;
158
- try {
159
- ops.createTask(db, { id: 'd-once', title: 'single', type: 'feature', priority: 'P1' });
160
- handle = daemon.runDaemon({
161
- db, repoRoot,
162
- runtimes: ['claude'],
163
- pollMs: 30,
164
- command: LONG_SLEEP_CMD,
165
- commandArgs: LONG_SLEEP_ARGS,
166
- });
167
- await new Promise((r) => setTimeout(r, 400));
168
- const sessions = db.prepare("SELECT * FROM sessions WHERE task_id = 'd-once'").all();
169
- assert.equal(sessions.length, 1, 'daemon should not double-spawn');
170
- } finally {
171
- cleanup(repoRoot, db, handle);
172
- }
173
- });
174
-
175
- test('runDaemon.stop() halts polling; existing children stay alive', async () => {
176
- const repoRoot = mkRepo();
177
- const db = mkDb(repoRoot);
178
- let handle;
179
- try {
180
- ops.createTask(db, { id: 'd-stop', title: 'stop test', type: 'feature', priority: 'P1' });
181
- handle = daemon.runDaemon({
182
- db, repoRoot,
183
- runtimes: ['claude'],
184
- pollMs: 30,
185
- command: LONG_SLEEP_CMD,
186
- commandArgs: LONG_SLEEP_ARGS,
187
- });
188
- // Wait for spawn.
189
- await new Promise((r) => setTimeout(r, 200));
190
- const before = db.prepare("SELECT pid FROM sessions WHERE task_id = 'd-stop'").all();
191
- assert.equal(before.length, 1);
192
- const childPid = before[0].pid;
193
-
194
- handle.stop();
195
- // After stop: child still alive, no new spawn for a fresh task.
196
- ops.createTask(db, { id: 'd-after-stop', title: 'after stop', type: 'feature', priority: 'P1' });
197
- await new Promise((r) => setTimeout(r, 200));
198
- const afterStop = db.prepare("SELECT * FROM sessions WHERE task_id = 'd-after-stop'").all();
199
- assert.equal(afterStop.length, 0, 'no new spawns after stop');
200
-
201
- try { process.kill(childPid, 0); } catch (err) {
202
- assert.fail(`child ${childPid} should still be alive after daemon stop: ${err.message}`);
203
- }
204
- } finally {
205
- cleanup(repoRoot, db, handle);
206
- }
207
- });
208
-
209
- test('runDaemon branchScoped=true only spawns tasks matching cwd branch tag', async () => {
210
- const repoRoot = mkRepo();
211
- const db = mkDb(repoRoot);
212
- let handle;
213
- try {
214
- // Normalize to a known branch name so the test doesn't depend on
215
- // the dev machine's git init.defaultBranch config.
216
- execFileSync('git', ['checkout', '-q', '-B', 'main'], { cwd: repoRoot });
217
- ops.createTask(db, { id: 'd-branch-match', title: 'main task', type: 'feature', priority: 'P1', tag: 'main' });
218
- ops.createTask(db, { id: 'd-branch-other', title: 'other task', type: 'feature', priority: 'P1', tag: 'feat-other' });
219
- handle = daemon.runDaemon({
220
- db, repoRoot,
221
- runtimes: ['claude'],
222
- pollMs: 50,
223
- command: LONG_SLEEP_CMD,
224
- commandArgs: LONG_SLEEP_ARGS,
225
- branchScoped: true,
226
- });
227
- await new Promise((r) => setTimeout(r, 300));
228
- const match = db.prepare("SELECT * FROM sessions WHERE task_id = 'd-branch-match'").all();
229
- const other = db.prepare("SELECT * FROM sessions WHERE task_id = 'd-branch-other'").all();
230
- assert.equal(match.length, 1, 'current-branch task must spawn');
231
- assert.equal(other.length, 0, 'other-branch task must stay pending');
232
- } finally {
233
- cleanup(repoRoot, db, handle);
234
- }
235
- });
236
-
237
- test('runDaemon applies complexity_hint route', async () => {
238
- const repoRoot = mkRepo();
239
- const db = mkDb(repoRoot);
240
- let handle;
241
- try {
242
- ops.createTask(db, {
243
- id: 'd-opus', title: 'opus task', type: 'architecture', priority: 'P0',
244
- complexity_hint: 'opus',
245
- });
246
- handle = daemon.runDaemon({
247
- db, repoRoot,
248
- runtimes: ['claude', 'opencode'],
249
- pollMs: 50,
250
- command: LONG_SLEEP_CMD,
251
- commandArgs: LONG_SLEEP_ARGS,
252
- });
253
- await new Promise((r) => setTimeout(r, 300));
254
- const sessions = db.prepare("SELECT * FROM sessions WHERE task_id = 'd-opus'").all();
255
- assert.equal(sessions.length, 1);
256
- // opus should prefer claude over opencode.
257
- assert.equal(sessions[0].runtime, 'claude');
258
- } finally {
259
- cleanup(repoRoot, db, handle);
260
- }
261
- });
262
-
263
- test('maintainState consumes spec changes with a durable cursor', () => {
264
- const repoRoot = mkRepo();
265
- const db = mkDb(repoRoot);
266
- try {
267
- ops.createTask(db, {
268
- id: 'maintain-stale', title: 'stale target', type: 'feature', priority: 'P1',
269
- trace_to: '.ultra/specs/product.md#daily-change',
270
- });
271
- ops.appendEvent(db, {
272
- type: 'spec_changed', payload: { sections: ['.ultra/specs/product.md#daily-change'] },
273
- });
274
- const first = daemon.maintainState({ db, repoRoot });
275
- assert.equal(first.staleness.processed, 1);
276
- assert.equal(ops.readTask(db, 'maintain-stale').stale, true);
277
- const second = daemon.maintainState({ db, repoRoot });
278
- assert.equal(second.staleness.processed, 0);
279
- } finally {
280
- cleanup(repoRoot, db);
281
- }
282
- });
283
-
284
- test('runDaemon performs crash recovery before dispatch polling', async () => {
285
- const repoRoot = mkRepo();
286
- const db = mkDb(repoRoot);
287
- let handle;
288
- try {
289
- ops.createTask(db, { id: 'recover-boot', title: 'recover boot', type: 'bugfix', priority: 'P0' });
290
- ops.updateTaskStatus(db, 'recover-boot', 'in_progress');
291
- ops.createSession(db, {
292
- sid: 'dead-session', task_id: 'recover-boot', runtime: 'claude', pid: 999999,
293
- worktree_path: '/tmp/dead-worktree', artifact_dir: '/tmp/dead-artifact', lease_seconds: 1,
294
- });
295
- db.prepare(
296
- "UPDATE sessions SET heartbeat_at = '2000-01-01T00:00:00.000Z', lease_expires_at = '2000-01-01T00:00:00.000Z' WHERE sid = 'dead-session'",
297
- ).run();
298
-
299
- handle = daemon.runDaemon({
300
- db, repoRoot, runtimes: ['claude'], pollMs: 50,
301
- command: LONG_SLEEP_CMD, commandArgs: LONG_SLEEP_ARGS,
302
- });
303
- await new Promise((resolve) => setTimeout(resolve, 100));
304
- const session = ops.readSession(db, 'dead-session');
305
- assert.equal(session.status, 'crashed');
306
- const failure = db.prepare("SELECT * FROM circuit_breaker WHERE task_id = 'recover-boot'").get();
307
- assert.equal(failure.failure_count, 1);
308
- } finally {
309
- cleanup(repoRoot, db, handle);
310
- }
311
- });
@@ -1,124 +0,0 @@
1
- 'use strict';
2
-
3
- // Phase 8B.1 — Dispatch rules declarative table.
4
- //
5
- // evaluate(ctx, rules) → { rule_id, action, runtime }.
6
- // DEFAULT_RULES must reproduce Phase 5.4 routeTask behavior (ROUTE_PREFERENCES
7
- // by complexity_hint, fall back to first available) so daemon.cjs keeps green.
8
- // New dimensions: breaker_state / deps_ready / wave conflict / custom rules.
9
-
10
- const { test } = require('node:test');
11
- const assert = require('node:assert/strict');
12
-
13
- const { evaluate, DEFAULT_RULES, ROUTE_PREFERENCES } = require('../dispatch-rules.cjs');
14
- const RETIRED_RUNTIME = ['gem', 'ini'].join('');
15
-
16
- function baseCtx(overrides = {}) {
17
- return {
18
- task: { id: 't1' },
19
- deps_ready: true,
20
- available_runtimes: ['claude', 'opencode', 'codex'],
21
- breaker_state: 'ok',
22
- wave: null,
23
- ...overrides,
24
- };
25
- }
26
-
27
- // ─── spawn_agent path (preference routing, 6 cases mirror routeTask) ──────
28
-
29
- test('evaluate: opus hint + all available → claude (preference head)', () => {
30
- const d = evaluate(baseCtx({ task: { id: 't', complexity_hint: 'opus' } }));
31
- assert.equal(d.action, 'spawn_agent');
32
- assert.equal(d.runtime, 'claude');
33
- });
34
-
35
- test('evaluate: opus hint + only codex available → codex', () => {
36
- const d = evaluate(baseCtx({
37
- task: { id: 't', complexity_hint: 'opus' },
38
- available_runtimes: ['codex', 'opencode'],
39
- }));
40
- assert.equal(d.runtime, 'codex');
41
- });
42
-
43
- test('evaluate: opus hint + preference exhausted → first available fallback', () => {
44
- const d = evaluate(baseCtx({
45
- task: { id: 't', complexity_hint: 'opus' },
46
- available_runtimes: ['opencode'],
47
- }));
48
- assert.equal(d.action, 'spawn_agent');
49
- assert.equal(d.runtime, 'opencode');
50
- });
51
-
52
- test('evaluate: haiku hint + all available → opencode (cheapest preference)', () => {
53
- const d = evaluate(baseCtx({ task: { id: 't', complexity_hint: 'haiku' } }));
54
- assert.equal(d.runtime, 'opencode');
55
- });
56
-
57
- test('evaluate: sonnet hint + all available → claude', () => {
58
- const d = evaluate(baseCtx({ task: { id: 't', complexity_hint: 'sonnet' } }));
59
- assert.equal(d.runtime, 'claude');
60
- });
61
-
62
- test('evaluate: retired runtimes are discarded before fallback routing', () => {
63
- const d = evaluate(baseCtx({
64
- task: { id: 't' },
65
- available_runtimes: [RETIRED_RUNTIME, 'claude'],
66
- }));
67
- assert.equal(d.action, 'spawn_agent');
68
- assert.equal(d.runtime, 'claude');
69
- });
70
-
71
- // ─── block / defer paths (new in 8B.1) ────────────────────────────────────
72
-
73
- test('evaluate: empty runtimes → block', () => {
74
- const d = evaluate(baseCtx({ available_runtimes: [] }));
75
- assert.equal(d.action, 'block');
76
- assert.equal(d.runtime, null);
77
- });
78
-
79
- test('evaluate: breaker tripped → block (wins over spawn rules)', () => {
80
- const d = evaluate(baseCtx({ breaker_state: 'tripped' }));
81
- assert.equal(d.action, 'block');
82
- assert.equal(d.rule_id, 'breaker-blocked');
83
- });
84
-
85
- test('evaluate: deps_ready=false → defer', () => {
86
- const d = evaluate(baseCtx({ deps_ready: false }));
87
- assert.equal(d.action, 'defer');
88
- assert.equal(d.rule_id, 'deps-not-ready');
89
- });
90
-
91
- test('evaluate: serial wave already has running task → defer', () => {
92
- const d = evaluate(baseCtx({
93
- wave: { parallel: false, running_count: 1 },
94
- }));
95
- assert.equal(d.action, 'defer');
96
- assert.equal(d.rule_id, 'wave-conflict');
97
- });
98
-
99
- // ─── composability: custom rules override defaults ────────────────────────
100
-
101
- test('evaluate: custom rules cannot select an unsupported runtime', () => {
102
- const customRules = [
103
- ...DEFAULT_RULES,
104
- {
105
- id: 'force-retired-runtime',
106
- priority: 999,
107
- when: () => true,
108
- action: 'spawn_agent',
109
- resolve: () => RETIRED_RUNTIME,
110
- },
111
- ];
112
- const d = evaluate(baseCtx({ task: { id: 't', complexity_hint: 'opus' } }), customRules);
113
- assert.equal(d.action, 'block');
114
- assert.equal(d.runtime, null);
115
- assert.equal(d.rule_id, 'force-retired-runtime');
116
- });
117
-
118
- // ─── ROUTE_PREFERENCES shape guard (regression: don't drift from Phase 5.4) ─
119
-
120
- test('DEFAULT_RULES preserves ROUTE_PREFERENCES shape', () => {
121
- assert.deepEqual(Object.keys(ROUTE_PREFERENCES).sort(), ['haiku', 'opus', 'sonnet']);
122
- assert.equal(ROUTE_PREFERENCES.opus[0], 'claude');
123
- assert.equal(ROUTE_PREFERENCES.haiku[0], 'opencode');
124
- });