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,314 @@
1
+ 'use strict';
2
+
3
+ const { test } = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const fs = require('node:fs');
6
+ const os = require('node:os');
7
+ const path = require('node:path');
8
+ const { execFileSync } = require('node:child_process');
9
+
10
+ const { initStateDb, closeStateDb } = require('../../mcp-server/lib/state-db.cjs');
11
+ const ops = require('../../mcp-server/lib/state-ops.cjs');
12
+ const runner = require('../session-runner.cjs');
13
+
14
+ // Short-lived child: keep the process alive until killed.
15
+ const LONG_SLEEP_CMD = process.execPath;
16
+ const LONG_SLEEP_ARGS = ['-e', 'setInterval(() => {}, 60000);'];
17
+
18
+ function mkRepo() {
19
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-runner-repo-'));
20
+ execFileSync('git', ['init', '-q'], { cwd: dir });
21
+ execFileSync('git', ['config', 'user.email', 'test@ubp.dev'], { cwd: dir });
22
+ execFileSync('git', ['config', 'user.name', 'ubp-test'], { cwd: dir });
23
+ fs.writeFileSync(path.join(dir, 'README.md'), '# test\n');
24
+ execFileSync('git', ['add', '-A'], { cwd: dir });
25
+ execFileSync('git', ['commit', '-q', '-m', 'seed'], { cwd: dir });
26
+ return dir;
27
+ }
28
+
29
+ function mkDb(repoRoot) {
30
+ const dbPath = path.join(repoRoot, '.ultra', 'state.db');
31
+ const { db } = initStateDb(dbPath);
32
+ return { db, dbPath };
33
+ }
34
+
35
+ function seedTask(db, id = 't-1') {
36
+ ops.createTask(db, { id, title: 'runner target', type: 'feature', priority: 'P1' });
37
+ return id;
38
+ }
39
+
40
+ function cleanup(repoRoot, db) {
41
+ try { closeStateDb(db); } catch (_) { /* ignore */ }
42
+ try { fs.rmSync(repoRoot, { recursive: true, force: true }); } catch (_) { /* ignore */ }
43
+ }
44
+
45
+ function isProcessAlive(pid) {
46
+ try { process.kill(pid, 0); return true; }
47
+ catch (_) { return false; }
48
+ }
49
+
50
+ test('spawnSession creates worktree, child process, and sessions row', () => {
51
+ const repoRoot = mkRepo();
52
+ const { db } = mkDb(repoRoot);
53
+ let handle;
54
+ try {
55
+ seedTask(db, 'r-1');
56
+ handle = runner.spawnSession({
57
+ db, repoRoot,
58
+ task_id: 'r-1', runtime: 'claude',
59
+ command: LONG_SLEEP_CMD, args: LONG_SLEEP_ARGS,
60
+ });
61
+ assert.match(handle.sid, /^sess-/);
62
+ assert.ok(fs.existsSync(handle.worktree_path));
63
+ assert.ok(fs.existsSync(handle.artifact_dir));
64
+ assert.ok(handle.pid);
65
+ assert.ok(isProcessAlive(handle.pid));
66
+
67
+ const row = ops.readSession(db, handle.sid);
68
+ assert.equal(row.task_id, 'r-1');
69
+ assert.equal(row.status, 'running');
70
+ assert.equal(row.pid, handle.pid);
71
+ } finally {
72
+ if (handle && handle.pid) { try { process.kill(handle.pid, 'SIGKILL'); } catch (_) { /* ignore */ } }
73
+ cleanup(repoRoot, db);
74
+ }
75
+ });
76
+
77
+ test('closeSession kills child, marks completed, removes worktree', async () => {
78
+ const repoRoot = mkRepo();
79
+ const { db } = mkDb(repoRoot);
80
+ let handle;
81
+ try {
82
+ seedTask(db, 'r-close');
83
+ handle = runner.spawnSession({
84
+ db, repoRoot,
85
+ task_id: 'r-close', runtime: 'claude',
86
+ command: LONG_SLEEP_CMD, args: LONG_SLEEP_ARGS,
87
+ });
88
+ const wt = handle.worktree_path;
89
+ runner.closeSession(
90
+ { db, repoRoot, sid: handle.sid },
91
+ { status: 'completed', kill_signal: 'SIGKILL' },
92
+ );
93
+
94
+ const row = ops.readSession(db, handle.sid);
95
+ assert.equal(row.status, 'completed');
96
+ // give the kernel a tick to reap the SIGKILL
97
+ await new Promise((r) => setTimeout(r, 50));
98
+ assert.ok(!isProcessAlive(handle.pid), `pid ${handle.pid} should be dead`);
99
+ assert.ok(!fs.existsSync(wt));
100
+ } finally {
101
+ cleanup(repoRoot, db);
102
+ }
103
+ });
104
+
105
+ test('spawnSession refuses second session without takeover (ADMISSION_DENIED)', () => {
106
+ const repoRoot = mkRepo();
107
+ const { db } = mkDb(repoRoot);
108
+ let first;
109
+ try {
110
+ seedTask(db, 'r-adm');
111
+ first = runner.spawnSession({
112
+ db, repoRoot,
113
+ task_id: 'r-adm', runtime: 'claude',
114
+ command: LONG_SLEEP_CMD, args: LONG_SLEEP_ARGS,
115
+ });
116
+ assert.throws(
117
+ () => runner.spawnSession({
118
+ db, repoRoot,
119
+ task_id: 'r-adm', runtime: 'codex',
120
+ command: LONG_SLEEP_CMD, args: LONG_SLEEP_ARGS,
121
+ }),
122
+ (err) => err instanceof runner.SessionRunnerError && err.code === 'ADMISSION_DENIED',
123
+ );
124
+ } finally {
125
+ if (first && first.pid) { try { process.kill(first.pid, 'SIGKILL'); } catch (_) { /* ignore */ } }
126
+ cleanup(repoRoot, db);
127
+ }
128
+ });
129
+
130
+ test('takeover=true crashes old session and spawns new one', () => {
131
+ const repoRoot = mkRepo();
132
+ const { db } = mkDb(repoRoot);
133
+ let first, second;
134
+ try {
135
+ seedTask(db, 'r-take');
136
+ first = runner.spawnSession({
137
+ db, repoRoot,
138
+ task_id: 'r-take', runtime: 'claude',
139
+ command: LONG_SLEEP_CMD, args: LONG_SLEEP_ARGS,
140
+ });
141
+ second = runner.spawnSession({
142
+ db, repoRoot,
143
+ task_id: 'r-take', runtime: 'codex',
144
+ command: LONG_SLEEP_CMD, args: LONG_SLEEP_ARGS,
145
+ takeover: true,
146
+ });
147
+ assert.notEqual(second.sid, first.sid);
148
+
149
+ const firstRow = ops.readSession(db, first.sid);
150
+ assert.equal(firstRow.status, 'crashed');
151
+ const secondRow = ops.readSession(db, second.sid);
152
+ assert.equal(secondRow.status, 'running');
153
+ assert.equal(secondRow.runtime, 'codex');
154
+ } finally {
155
+ for (const h of [first, second]) {
156
+ if (h && h.pid) { try { process.kill(h.pid, 'SIGKILL'); } catch (_) { /* ignore */ } }
157
+ }
158
+ cleanup(repoRoot, db);
159
+ }
160
+ });
161
+
162
+ test('attachHeartbeat updates heartbeat_at while running', async () => {
163
+ const repoRoot = mkRepo();
164
+ const { db } = mkDb(repoRoot);
165
+ let handle;
166
+ try {
167
+ seedTask(db, 'r-hb');
168
+ handle = runner.spawnSession({
169
+ db, repoRoot,
170
+ task_id: 'r-hb', runtime: 'claude',
171
+ command: LONG_SLEEP_CMD, args: LONG_SLEEP_ARGS,
172
+ });
173
+ const stop = runner.attachHeartbeat(db, handle.sid, { intervalMs: 20 });
174
+ await new Promise((r) => setTimeout(r, 80));
175
+ stop();
176
+
177
+ const row = ops.readSession(db, handle.sid);
178
+ assert.ok(row.heartbeat_at, 'heartbeat_at should be populated');
179
+ // lease extended beyond initial default of started_at + 1800s? compare against start
180
+ assert.ok(Date.parse(row.heartbeat_at) > 0);
181
+ } finally {
182
+ if (handle && handle.pid) { try { process.kill(handle.pid, 'SIGKILL'); } catch (_) { /* ignore */ } }
183
+ cleanup(repoRoot, db);
184
+ }
185
+ });
186
+
187
+ test('kill -9 child: reapOrphanSessions marks orphan after heartbeat grace expires', async () => {
188
+ const repoRoot = mkRepo();
189
+ const { db } = mkDb(repoRoot);
190
+ let handle;
191
+ try {
192
+ seedTask(db, 'r-orphan');
193
+ handle = runner.spawnSession({
194
+ db, repoRoot,
195
+ task_id: 'r-orphan', runtime: 'claude',
196
+ command: LONG_SLEEP_CMD, args: LONG_SLEEP_ARGS,
197
+ });
198
+ // force lease + heartbeat into the past so reaper has something to grab
199
+ ops.updateSession(db, handle.sid, {
200
+ lease_expires_at: new Date(Date.now() - 60 * 1000).toISOString(),
201
+ heartbeat_at: new Date(Date.now() - 60 * 1000).toISOString(),
202
+ });
203
+ process.kill(handle.pid, 'SIGKILL');
204
+
205
+ const reaped = ops.reapOrphanSessions(db, { graceSeconds: 30 });
206
+ assert.ok(reaped.reaped.includes(handle.sid));
207
+ const row = ops.readSession(db, handle.sid);
208
+ assert.equal(row.status, 'orphan');
209
+ } finally {
210
+ cleanup(repoRoot, db);
211
+ }
212
+ });
213
+
214
+ test('concurrent spawns on same task: only one succeeds (admission serialization)', async () => {
215
+ const repoRoot = mkRepo();
216
+ const { db } = mkDb(repoRoot);
217
+ let winner;
218
+ try {
219
+ seedTask(db, 'r-race');
220
+
221
+ const attempts = [0, 1, 2].map((i) =>
222
+ Promise.resolve().then(() => {
223
+ try {
224
+ return runner.spawnSession({
225
+ db, repoRoot,
226
+ task_id: 'r-race', runtime: ['claude', 'opencode', 'codex'][i],
227
+ command: LONG_SLEEP_CMD, args: LONG_SLEEP_ARGS,
228
+ });
229
+ } catch (err) {
230
+ return { error: err.code };
231
+ }
232
+ }),
233
+ );
234
+ const results = await Promise.all(attempts);
235
+ const winners = results.filter((r) => r && r.sid);
236
+ const denied = results.filter((r) => r && r.error === 'ADMISSION_DENIED');
237
+ assert.equal(winners.length, 1, `expected 1 winner, got ${winners.length}`);
238
+ assert.equal(denied.length, 2, `expected 2 denied, got ${denied.length}`);
239
+ winner = winners[0];
240
+ } finally {
241
+ if (winner && winner.pid) { try { process.kill(winner.pid, 'SIGKILL'); } catch (_) { /* ignore */ } }
242
+ cleanup(repoRoot, db);
243
+ }
244
+ });
245
+
246
+ test('spawnSession refuses tripped task (CIRCUIT_TRIPPED, takeover cannot override)', () => {
247
+ const repoRoot = mkRepo();
248
+ const { db } = mkDb(repoRoot);
249
+ try {
250
+ seedTask(db, 'r-cb');
251
+ for (let i = 0; i < 3; i += 1) ops.recordTaskFailure(db, 'r-cb', { reason: 'test' });
252
+ assert.equal(ops.isCircuitTripped(db, 'r-cb'), true);
253
+
254
+ const attempt = (takeover) => runner.spawnSession({
255
+ db, repoRoot,
256
+ task_id: 'r-cb', runtime: 'claude',
257
+ command: LONG_SLEEP_CMD, args: LONG_SLEEP_ARGS,
258
+ takeover,
259
+ });
260
+ assert.throws(() => attempt(false), (err) => err.code === 'CIRCUIT_TRIPPED');
261
+ assert.throws(() => attempt(true), (err) => err.code === 'CIRCUIT_TRIPPED');
262
+ } finally {
263
+ cleanup(repoRoot, db);
264
+ }
265
+ });
266
+
267
+ test('spawnSession works again after resetCircuitBreaker', () => {
268
+ const repoRoot = mkRepo();
269
+ const { db } = mkDb(repoRoot);
270
+ let handle;
271
+ try {
272
+ seedTask(db, 'r-cb-reset');
273
+ for (let i = 0; i < 3; i += 1) ops.recordTaskFailure(db, 'r-cb-reset', { reason: 'test' });
274
+ ops.resetCircuitBreaker(db, 'r-cb-reset');
275
+ handle = runner.spawnSession({
276
+ db, repoRoot,
277
+ task_id: 'r-cb-reset', runtime: 'claude',
278
+ command: LONG_SLEEP_CMD, args: LONG_SLEEP_ARGS,
279
+ });
280
+ assert.ok(handle.sid);
281
+ } finally {
282
+ if (handle && handle.pid) { try { process.kill(handle.pid, 'SIGKILL'); } catch (_) { /* ignore */ } }
283
+ cleanup(repoRoot, db);
284
+ }
285
+ });
286
+
287
+ test('two sessions on different tasks get independent worktrees', () => {
288
+ const repoRoot = mkRepo();
289
+ const { db } = mkDb(repoRoot);
290
+ let a, b;
291
+ try {
292
+ seedTask(db, 'r-multi-a');
293
+ seedTask(db, 'r-multi-b');
294
+ a = runner.spawnSession({
295
+ db, repoRoot,
296
+ task_id: 'r-multi-a', runtime: 'claude',
297
+ command: LONG_SLEEP_CMD, args: LONG_SLEEP_ARGS,
298
+ });
299
+ b = runner.spawnSession({
300
+ db, repoRoot,
301
+ task_id: 'r-multi-b', runtime: 'codex',
302
+ command: LONG_SLEEP_CMD, args: LONG_SLEEP_ARGS,
303
+ });
304
+ assert.notEqual(a.sid, b.sid);
305
+ assert.notEqual(a.worktree_path, b.worktree_path);
306
+ assert.notEqual(a.pid, b.pid);
307
+ assert.ok(isProcessAlive(a.pid) && isProcessAlive(b.pid));
308
+ } finally {
309
+ for (const h of [a, b]) {
310
+ if (h && h.pid) { try { process.kill(h.pid, 'SIGKILL'); } catch (_) { /* ignore */ } }
311
+ }
312
+ cleanup(repoRoot, db);
313
+ }
314
+ });
@@ -0,0 +1,147 @@
1
+ 'use strict';
2
+
3
+ // Phase 7.3 — Skill miner (heuristic, no LLM):
4
+ // • mineSession scans a session's events + task state for "solved
5
+ // something non-trivial" signals and writes skills/learned/
6
+ // <ts>_<sid>_unverified.md drafts.
7
+ // • Idempotent: same sid twice → only one draft per signal.
8
+
9
+ const { test } = require('node:test');
10
+ const assert = require('node:assert/strict');
11
+ const fs = require('node:fs');
12
+ const os = require('node:os');
13
+ const path = require('node:path');
14
+
15
+ const { initStateDb, closeStateDb } = require('../../mcp-server/lib/state-db.cjs');
16
+ const ops = require('../../mcp-server/lib/state-ops.cjs');
17
+ const skillMiner = require('../skill-miner.cjs');
18
+
19
+ function mkFixture() {
20
+ const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-skill-'));
21
+ const dbPath = path.join(repoRoot, '.ultra', 'state.db');
22
+ const { db } = initStateDb(dbPath);
23
+ const skillsRoot = path.join(repoRoot, 'skills');
24
+ fs.mkdirSync(skillsRoot, { recursive: true });
25
+ return { repoRoot, db, skillsRoot };
26
+ }
27
+
28
+ function teardown(repoRoot, db) {
29
+ try { closeStateDb(db); } catch (_) { /* ignore */ }
30
+ try { fs.rmSync(repoRoot, { recursive: true, force: true }); } catch (_) { /* ignore */ }
31
+ }
32
+
33
+ function seed(db, { sid, task_id, title = 'demo', tag = null }) {
34
+ ops.createTask(db, { id: task_id, title, type: 'feature', priority: 'P1', tag });
35
+ ops.createSession(db, {
36
+ sid, task_id, runtime: 'claude',
37
+ worktree_path: `/tmp/${sid}/wt`, artifact_dir: `/tmp/${sid}/art`,
38
+ });
39
+ }
40
+
41
+ test('mineSession: task_completed signal → generates draft', () => {
42
+ const { repoRoot, db, skillsRoot } = mkFixture();
43
+ try {
44
+ seed(db, { sid: 's-done', task_id: 't-done', title: 'ship auth flow' });
45
+ ops.patchTask(db, 't-done', { status: 'in_progress' });
46
+ ops.appendEvent(db, { type: 'task_completed', task_id: 't-done', session_id: 's-done', payload: { to: 'completed' } });
47
+ ops.patchTask(db, 't-done', { status: 'completed' });
48
+
49
+ const out = skillMiner.mineSession(db, { sid: 's-done', skillsRoot });
50
+ assert.equal(out.drafts.length, 1);
51
+ assert.match(out.drafts[0], /_unverified\.md$/);
52
+ const draft = fs.readFileSync(out.drafts[0], 'utf8');
53
+ assert.match(draft, /kind: task-completion/);
54
+ assert.match(draft, /unverified: true/);
55
+ assert.match(draft, /ship auth flow/);
56
+ } finally { teardown(repoRoot, db); }
57
+ });
58
+
59
+ test('mineSession: task_circuit_broken signal → debug-pattern draft', () => {
60
+ const { repoRoot, db, skillsRoot } = mkFixture();
61
+ try {
62
+ seed(db, { sid: 's-cb', task_id: 't-cb', title: 'flaky integration' });
63
+ ops.appendEvent(db, { type: 'task_circuit_broken', task_id: 't-cb', session_id: 's-cb', payload: { threshold: 3 } });
64
+ const out = skillMiner.mineSession(db, { sid: 's-cb', skillsRoot });
65
+ assert.equal(out.drafts.length, 1);
66
+ const draft = fs.readFileSync(out.drafts[0], 'utf8');
67
+ assert.match(draft, /kind: debug-pattern/);
68
+ } finally { teardown(repoRoot, db); }
69
+ });
70
+
71
+ test('mineSession: session_crashed signal → recovery-pattern draft', () => {
72
+ const { repoRoot, db, skillsRoot } = mkFixture();
73
+ try {
74
+ seed(db, { sid: 's-crash', task_id: 't-crash', title: 'crashing task' });
75
+ ops.appendEvent(db, { type: 'session_crashed', task_id: 't-crash', session_id: 's-crash', payload: { to: 'crashed' } });
76
+ const out = skillMiner.mineSession(db, { sid: 's-crash', skillsRoot });
77
+ assert.equal(out.drafts.length, 1);
78
+ const draft = fs.readFileSync(out.drafts[0], 'utf8');
79
+ assert.match(draft, /kind: recovery-pattern/);
80
+ } finally { teardown(repoRoot, db); }
81
+ });
82
+
83
+ test('mineSession: no trigger signals → no draft', () => {
84
+ const { repoRoot, db, skillsRoot } = mkFixture();
85
+ try {
86
+ seed(db, { sid: 's-quiet', task_id: 't-quiet', title: 'nothing happened' });
87
+ const out = skillMiner.mineSession(db, { sid: 's-quiet', skillsRoot });
88
+ assert.equal(out.drafts.length, 0);
89
+ } finally { teardown(repoRoot, db); }
90
+ });
91
+
92
+ test('mineSession: idempotent — running twice on same sid yields no new drafts', () => {
93
+ const { repoRoot, db, skillsRoot } = mkFixture();
94
+ try {
95
+ seed(db, { sid: 's-idem', task_id: 't-idem', title: 'shipped' });
96
+ ops.patchTask(db, 't-idem', { status: 'in_progress' });
97
+ ops.appendEvent(db, { type: 'task_completed', task_id: 't-idem', session_id: 's-idem', payload: { to: 'completed' } });
98
+ ops.patchTask(db, 't-idem', { status: 'completed' });
99
+
100
+ const first = skillMiner.mineSession(db, { sid: 's-idem', skillsRoot });
101
+ const second = skillMiner.mineSession(db, { sid: 's-idem', skillsRoot });
102
+ assert.equal(first.drafts.length, 1);
103
+ assert.equal(second.drafts.length, 0, 'second mining should see existing draft and skip');
104
+ const learned = fs.readdirSync(path.join(skillsRoot, 'learned'));
105
+ assert.equal(learned.length, 1);
106
+ } finally { teardown(repoRoot, db); }
107
+ });
108
+
109
+ test('mineSession: missing session → empty drafts array', () => {
110
+ const { repoRoot, db, skillsRoot } = mkFixture();
111
+ try {
112
+ const out = skillMiner.mineSession(db, { sid: 's-nope', skillsRoot });
113
+ assert.deepEqual(out.drafts, []);
114
+ } finally { teardown(repoRoot, db); }
115
+ });
116
+
117
+ test('AC flow: 5 mock sessions (3 with signal, 2 empty) → ≥3 drafts', () => {
118
+ const { repoRoot, db, skillsRoot } = mkFixture();
119
+ try {
120
+ // 3 with a trigger signal
121
+ for (const [i, kind] of [['ac1', 'completed'], ['ac2', 'crashed'], ['ac3', 'broken']]) {
122
+ seed(db, { sid: `s-${i}`, task_id: `t-${i}`, title: `task ${i}` });
123
+ if (kind === 'completed') {
124
+ ops.patchTask(db, `t-${i}`, { status: 'in_progress' });
125
+ ops.appendEvent(db, { type: 'task_completed', task_id: `t-${i}`, session_id: `s-${i}`, payload: {} });
126
+ ops.patchTask(db, `t-${i}`, { status: 'completed' });
127
+ } else if (kind === 'crashed') {
128
+ ops.appendEvent(db, { type: 'session_crashed', task_id: `t-${i}`, session_id: `s-${i}`, payload: {} });
129
+ } else if (kind === 'broken') {
130
+ ops.appendEvent(db, { type: 'task_circuit_broken', task_id: `t-${i}`, session_id: `s-${i}`, payload: { threshold: 3 } });
131
+ }
132
+ }
133
+ // 2 empty
134
+ for (const i of ['ac4', 'ac5']) {
135
+ seed(db, { sid: `s-${i}`, task_id: `t-${i}`, title: `task ${i}` });
136
+ }
137
+
138
+ let totalDrafts = 0;
139
+ for (const i of ['ac1', 'ac2', 'ac3', 'ac4', 'ac5']) {
140
+ const out = skillMiner.mineSession(db, { sid: `s-${i}`, skillsRoot });
141
+ totalDrafts += out.drafts.length;
142
+ }
143
+ assert.ok(totalDrafts >= 3, `expected ≥3 drafts across 5 sessions, got ${totalDrafts}`);
144
+ const learned = fs.readdirSync(path.join(skillsRoot, 'learned'));
145
+ assert.ok(learned.length >= 3);
146
+ } finally { teardown(repoRoot, db); }
147
+ });
@@ -0,0 +1,148 @@
1
+ 'use strict';
2
+
3
+ // Phase 8B.3 — N-concurrent git worktree management.
4
+ //
5
+ // Session-runner (Phase 4.5) already encapsulates `git worktree add/remove`
6
+ // for a single session. worktree-manager is the registry / batch layer:
7
+ // allocate N, track them via `git worktree list --porcelain`, release one
8
+ // or all (for crash recovery), detect filesystem orphans.
9
+ //
10
+ // AC (from PLAN §6 Phase 8B.3):
11
+ // - 3 concurrent slices → 3 independent worktrees, git worktree list
12
+ // shows them; main branch checkout untouched
13
+ // - 5 rapid allocations in one tick → no .git/config.lock contention
14
+ // (Node single-thread execFileSync is naturally serialized)
15
+ // - releaseAll cleans filesystem leftovers even when git forgot them
16
+
17
+ const { test } = require('node:test');
18
+ const assert = require('node:assert/strict');
19
+ const fs = require('node:fs');
20
+ const os = require('node:os');
21
+ const path = require('node:path');
22
+ const { execFileSync } = require('node:child_process');
23
+
24
+ const wm = require('../worktree-manager.cjs');
25
+
26
+ function mkRepo() {
27
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-wtmgr-'));
28
+ execFileSync('git', ['init', '-q'], { cwd: dir });
29
+ execFileSync('git', ['config', 'user.email', 'test@ubp.dev'], { cwd: dir });
30
+ execFileSync('git', ['config', 'user.name', 'ubp-test'], { cwd: dir });
31
+ fs.writeFileSync(path.join(dir, 'README.md'), '# seed\n');
32
+ execFileSync('git', ['add', '-A'], { cwd: dir });
33
+ execFileSync('git', ['commit', '-q', '-m', 'seed'], { cwd: dir });
34
+ return dir;
35
+ }
36
+
37
+ function cleanup(dir) {
38
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_) { /* best-effort */ }
39
+ }
40
+
41
+ test('allocate: creates worktree under .ultra/worktrees/<sid> and returns path', () => {
42
+ const repo = mkRepo();
43
+ try {
44
+ const { worktree_path } = wm.allocate({ repoRoot: repo, sid: 'sess-abc' });
45
+ assert.ok(worktree_path.endsWith('.ultra/worktrees/sess-abc'));
46
+ assert.ok(fs.existsSync(worktree_path), 'worktree dir should exist');
47
+ assert.ok(fs.existsSync(path.join(worktree_path, 'README.md')), 'seed file visible');
48
+ } finally { cleanup(repo); }
49
+ });
50
+
51
+ test('release: removes worktree dir and unregisters from git', () => {
52
+ const repo = mkRepo();
53
+ try {
54
+ const { worktree_path } = wm.allocate({ repoRoot: repo, sid: 'sess-rel' });
55
+ wm.release({ repoRoot: repo, worktree_path });
56
+ assert.equal(fs.existsSync(worktree_path), false, 'dir should be removed');
57
+ const active = wm.listActive(repo);
58
+ assert.equal(active.length, 0, 'no slices should remain');
59
+ } finally { cleanup(repo); }
60
+ });
61
+
62
+ test('3 concurrent allocates → 3 independent worktrees visible via listActive', () => {
63
+ const repo = mkRepo();
64
+ try {
65
+ const sids = ['sess-a', 'sess-b', 'sess-c'];
66
+ const paths = sids.map((sid) => wm.allocate({ repoRoot: repo, sid }).worktree_path);
67
+ for (const p of paths) assert.ok(fs.existsSync(p), `${p} missing`);
68
+ const active = wm.listActive(repo);
69
+ const activeSids = active.map((a) => a.sid).sort();
70
+ assert.deepEqual(activeSids, sids.slice().sort());
71
+ // Every entry has a distinct worktree_path
72
+ const uniquePaths = new Set(active.map((a) => a.worktree_path));
73
+ assert.equal(uniquePaths.size, 3);
74
+ } finally { cleanup(repo); }
75
+ });
76
+
77
+ test('5 rapid allocates in one tick → no .git/config.lock contention', () => {
78
+ const repo = mkRepo();
79
+ try {
80
+ const sids = ['s1', 's2', 's3', 's4', 's5'];
81
+ // Execute synchronously back-to-back; Node single-thread guarantees
82
+ // serialized git calls, so no file-lock race.
83
+ for (const sid of sids) {
84
+ wm.allocate({ repoRoot: repo, sid });
85
+ }
86
+ assert.equal(wm.listActive(repo).length, 5);
87
+ } finally { cleanup(repo); }
88
+ });
89
+
90
+ test('listActive: only returns worktrees under .ultra/worktrees/ (main excluded)', () => {
91
+ const repo = mkRepo();
92
+ try {
93
+ // Manually add a worktree OUTSIDE .ultra/worktrees — should be ignored.
94
+ const outsideDir = path.join(repo, 'other-wt');
95
+ execFileSync('git', ['worktree', 'add', '--detach', outsideDir, 'HEAD'], {
96
+ cwd: repo, stdio: 'pipe',
97
+ });
98
+ wm.allocate({ repoRoot: repo, sid: 'sess-domain' });
99
+ const active = wm.listActive(repo);
100
+ assert.equal(active.length, 1);
101
+ assert.equal(active[0].sid, 'sess-domain');
102
+ } finally { cleanup(repo); }
103
+ });
104
+
105
+ test('listActive: empty repo → []', () => {
106
+ const repo = mkRepo();
107
+ try {
108
+ assert.deepEqual(wm.listActive(repo), []);
109
+ } finally { cleanup(repo); }
110
+ });
111
+
112
+ test('releaseAll: clears all allocated worktrees + filesystem orphans', () => {
113
+ const repo = mkRepo();
114
+ try {
115
+ wm.allocate({ repoRoot: repo, sid: 'x1' });
116
+ wm.allocate({ repoRoot: repo, sid: 'x2' });
117
+ // Create an orphan dir: git doesn't know about it, but fs exists.
118
+ const orphanDir = path.join(repo, '.ultra', 'worktrees', 'orphan-dir');
119
+ fs.mkdirSync(orphanDir, { recursive: true });
120
+ fs.writeFileSync(path.join(orphanDir, 'junk.txt'), 'x');
121
+
122
+ const result = wm.releaseAll(repo);
123
+ assert.equal(result.cleaned, 2, 'should report 2 git-tracked cleanups');
124
+ assert.equal(wm.listActive(repo).length, 0);
125
+ assert.equal(fs.existsSync(orphanDir), false, 'orphan must be swept');
126
+ } finally { cleanup(repo); }
127
+ });
128
+
129
+ test('allocate: baseRef honored — worktree checked out at given ref', () => {
130
+ const repo = mkRepo();
131
+ try {
132
+ // Make a second commit so we have two distinct SHAs.
133
+ fs.writeFileSync(path.join(repo, 'second.md'), '# second\n');
134
+ execFileSync('git', ['add', '-A'], { cwd: repo });
135
+ execFileSync('git', ['commit', '-q', '-m', 'second'], { cwd: repo });
136
+ const firstSha = execFileSync('git', ['rev-parse', 'HEAD~1'], {
137
+ cwd: repo, stdio: ['ignore', 'pipe', 'pipe'],
138
+ }).toString().trim();
139
+
140
+ const { worktree_path } = wm.allocate({
141
+ repoRoot: repo, sid: 'sess-ref', baseRef: firstSha,
142
+ });
143
+ const wtSha = execFileSync('git', ['rev-parse', 'HEAD'], {
144
+ cwd: worktree_path, stdio: ['ignore', 'pipe', 'pipe'],
145
+ }).toString().trim();
146
+ assert.equal(wtSha, firstSha);
147
+ } finally { cleanup(repo); }
148
+ });