ultra-builder-pro-cli 0.14.0 → 0.16.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 (163) 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 +77 -1
  8. package/README.md +46 -24
  9. package/adapters/_shared/codex-assets.cjs +6 -2
  10. package/adapters/_shared/file-ops.cjs +6 -2
  11. package/adapters/_shared/handbook.cjs +2 -1
  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 +3 -2
  16. package/agents/review-coordinator.md +9 -6
  17. package/commands/ultra-change.md +1 -1
  18. package/commands/ultra-plan.md +1 -1
  19. package/commands/ultra-research.md +1 -1
  20. package/commands/ultra-think.md +2 -2
  21. package/docs/AGENT-CONTEXT.md +43 -19
  22. package/docs/ARCHITECTURE.md +54 -13
  23. package/docs/PLAN.zh-CN.md +4 -2
  24. package/docs/ROADMAP.md +10 -4
  25. package/docs/RUNTIME-COMPAT-MATRIX.md +4 -4
  26. package/docs/STATE-DB-ACCESS-POLICY.md +33 -29
  27. package/docs/USER-HANDBOOK-CONTRACT.md +8 -2
  28. package/docs/WORKFLOW-LIFECYCLE.md +342 -0
  29. package/hooks/README.md +6 -1
  30. package/hooks/health_check.py +12 -3
  31. package/hooks/subagent_tracker.py +52 -77
  32. package/mcp-server/hook-event.cjs +34 -0
  33. package/mcp-server/lib/archive-journal.cjs +17 -5
  34. package/mcp-server/lib/baseline-workflow.cjs +158 -18
  35. package/mcp-server/lib/change-workflow.cjs +598 -85
  36. package/mcp-server/lib/context-spine.cjs +305 -25
  37. package/mcp-server/lib/decision-dialogue.cjs +713 -0
  38. package/mcp-server/lib/doctor.cjs +42 -3
  39. package/mcp-server/lib/init-project.cjs +210 -16
  40. package/mcp-server/lib/plan-store.cjs +4 -1
  41. package/mcp-server/lib/pricing.cjs +7 -10
  42. package/mcp-server/lib/project-breadcrumb.cjs +15 -0
  43. package/mcp-server/lib/projector.cjs +57 -3
  44. package/mcp-server/lib/spec-learning.cjs +79 -2
  45. package/mcp-server/lib/state-db.cjs +168 -8
  46. package/mcp-server/lib/state-ops.cjs +160 -9
  47. package/mcp-server/lib/workflow-state.cjs +2305 -0
  48. package/mcp-server/server.cjs +215 -15
  49. package/orchestrator/daemon.cjs +1 -2
  50. package/orchestrator/dispatch-rules.cjs +5 -31
  51. package/orchestrator/planner/plan-builder.cjs +21 -12
  52. package/package.json +13 -3
  53. package/skills/ultra-change/SKILL.md +94 -80
  54. package/skills/ultra-change/references/change-contract.md +36 -0
  55. package/skills/ultra-deliver/SKILL.md +76 -79
  56. package/skills/ultra-deliver/references/baseline-reconciliation.md +20 -0
  57. package/skills/ultra-dev/SKILL.md +62 -59
  58. package/skills/ultra-doctor/SKILL.md +61 -82
  59. package/skills/ultra-init/SKILL.md +101 -103
  60. package/skills/ultra-init/references/brownfield-adoption.md +2 -2
  61. package/skills/ultra-plan/SKILL.md +99 -85
  62. package/skills/ultra-plan/references/semantic-preflight.md +18 -0
  63. package/skills/ultra-research/SKILL.md +102 -60
  64. package/skills/ultra-research/references/00-problem-validation.md +36 -0
  65. package/skills/ultra-research/references/01-opportunity-discovery.md +33 -0
  66. package/skills/ultra-research/references/02-market-assessment.md +30 -0
  67. package/skills/ultra-research/references/03-competitive-landscape.md +31 -0
  68. package/skills/ultra-research/references/04-product-strategy.md +32 -0
  69. package/skills/ultra-research/references/05-assumptions-validation.md +31 -0
  70. package/skills/ultra-research/references/10-user-personas.md +29 -0
  71. package/skills/ultra-research/references/11-user-scenarios.md +28 -0
  72. package/skills/ultra-research/references/20-user-stories.md +30 -0
  73. package/skills/ultra-research/references/21-features-scope.md +29 -0
  74. package/skills/ultra-research/references/22-success-metrics.md +28 -0
  75. package/skills/ultra-research/references/30-architecture-context.md +30 -0
  76. package/skills/ultra-research/references/31-solution-strategy.md +28 -0
  77. package/skills/ultra-research/references/32-building-blocks.md +28 -0
  78. package/skills/ultra-research/references/40-deployment.md +29 -0
  79. package/skills/ultra-research/references/41-quality-risks.md +28 -0
  80. package/skills/ultra-research/references/99-synthesis.md +35 -0
  81. package/skills/ultra-review/SKILL.md +63 -73
  82. package/skills/ultra-review/references/review-modes.md +27 -0
  83. package/skills/ultra-review/references/unified-schema.md +25 -0
  84. package/skills/ultra-review/scripts/review_wait.py +40 -3
  85. package/skills/ultra-status/SKILL.md +41 -41
  86. package/skills/ultra-test/SKILL.md +54 -55
  87. package/skills/ultra-think/SKILL.md +26 -12
  88. package/skills/ultra-think/references/decision-dialogue.md +130 -0
  89. package/skills/ultra-verify/SKILL.md +1 -1
  90. package/skills/ultra-verify/references/{confidence-system.md → evidence-status.md} +3 -3
  91. package/spec/cli-protocol.md +17 -3
  92. package/spec/mcp-tools.yaml +683 -64
  93. package/spec/schemas/baseline-reconciliation.v1.schema.json +73 -0
  94. package/spec/schemas/delivery-report.v1.schema.json +111 -0
  95. package/spec/schemas/mcp-tools.schema.json +1 -1
  96. package/spec/schemas/state-db.sql +140 -1
  97. package/spec/schemas/tasks.v4.5.schema.json +8 -1
  98. package/spec/schemas/test-report.v1.schema.json +194 -0
  99. package/ultra-tools/cli.cjs +1 -1
  100. package/ultra-tools/commands/migrate.cjs +3 -3
  101. package/ultra-tools/commands/status.cjs +228 -12
  102. package/.ultra-template/delivery-report.json +0 -9
  103. package/.ultra-template/test-report.json +0 -8
  104. package/adapters/_shared/tests/file-ops.test.cjs +0 -54
  105. package/adapters/_shared/tests/frontmatter.test.cjs +0 -47
  106. package/adapters/_shared/tests/handbook.test.cjs +0 -101
  107. package/adapters/_shared/tests/path-rewrite.test.cjs +0 -60
  108. package/adapters/_shared/tests/provenance.test.cjs +0 -133
  109. package/adapters/_shared/tests/resolve-target.test.cjs +0 -75
  110. package/adapters/_shared/tests/runtime-assets.test.cjs +0 -149
  111. package/adapters/_shared/tests/settings-merge.test.cjs +0 -85
  112. package/adapters/_shared/tests/validate.test.cjs +0 -30
  113. package/adapters/tests/claude.test.cjs +0 -138
  114. package/adapters/tests/codex-hook.test.cjs +0 -142
  115. package/adapters/tests/codex.test.cjs +0 -430
  116. package/adapters/tests/kimi-hook.test.cjs +0 -149
  117. package/adapters/tests/kimi.test.cjs +0 -259
  118. package/adapters/tests/opencode.test.cjs +0 -357
  119. package/mcp-server/lib/archive-journal.test.cjs +0 -35
  120. package/mcp-server/lib/baseline-workflow.test.cjs +0 -477
  121. package/mcp-server/lib/change-workflow.test.cjs +0 -971
  122. package/mcp-server/lib/context-spine.test.cjs +0 -93
  123. package/mcp-server/lib/doctor.test.cjs +0 -148
  124. package/mcp-server/lib/plan-store.test.cjs +0 -132
  125. package/mcp-server/lib/prd-parser.test.cjs +0 -82
  126. package/mcp-server/lib/project-breadcrumb.test.cjs +0 -112
  127. package/mcp-server/lib/runtime-state.test.cjs +0 -92
  128. package/mcp-server/lib/spec-learning.test.cjs +0 -67
  129. package/mcp-server/lib/state-db.test.cjs +0 -314
  130. package/mcp-server/lib/task-expander.test.cjs +0 -169
  131. package/mcp-server/lib/topo.test.cjs +0 -158
  132. package/mcp-server/tests/circuit-breaker.test.cjs +0 -125
  133. package/mcp-server/tests/commit-hash-flow.test.cjs +0 -117
  134. package/mcp-server/tests/concurrency-update.test.cjs +0 -96
  135. package/mcp-server/tests/concurrency.test.cjs +0 -112
  136. package/mcp-server/tests/db-ops.test.cjs +0 -121
  137. package/mcp-server/tests/init-project.test.cjs +0 -393
  138. package/mcp-server/tests/migrate.test.cjs +0 -232
  139. package/mcp-server/tests/projector.test.cjs +0 -162
  140. package/mcp-server/tests/server-contract.test.cjs +0 -1467
  141. package/mcp-server/tests/staleness.test.cjs +0 -190
  142. package/mcp-server/tests/state-db.test.cjs +0 -106
  143. package/mcp-server/tests/state-ops.test.cjs +0 -449
  144. package/mcp-server/tests/tagged-tasks.test.cjs +0 -163
  145. package/mcp-server/tests/telemetry.test.cjs +0 -219
  146. package/orchestrator/planner/plan-builder.test.cjs +0 -165
  147. package/orchestrator/tests/auto-merge.test.cjs +0 -216
  148. package/orchestrator/tests/daemon.test.cjs +0 -311
  149. package/orchestrator/tests/dispatch-rules.test.cjs +0 -124
  150. package/orchestrator/tests/parallel-orchestrator.test.cjs +0 -233
  151. package/orchestrator/tests/phase-8b-gate.test.cjs +0 -183
  152. package/orchestrator/tests/recovery.test.cjs +0 -190
  153. package/orchestrator/tests/session-runner.test.cjs +0 -314
  154. package/orchestrator/tests/skill-miner.test.cjs +0 -147
  155. package/orchestrator/tests/worktree-manager.test.cjs +0 -148
  156. package/skills/ultra-research/references/architecture.md +0 -38
  157. package/skills/ultra-research/references/problem-product.md +0 -39
  158. package/skills/ultra-research/references/quality-delivery.md +0 -34
  159. package/skills/ultra-research/references/synthesis.md +0 -38
  160. package/skills/ultra-research/references/users-scope.md +0 -40
  161. package/ultra-tools/legacy-memory.test.cjs +0 -80
  162. package/ultra-tools/status-command.test.cjs +0 -181
  163. package/ultra-tools/system-command.test.cjs +0 -309
@@ -1,219 +0,0 @@
1
- 'use strict';
2
-
3
- // Phase 6.2 — Telemetry collectors:
4
- // • appendTelemetry writes to state.db.telemetry AND .ultra/telemetry/
5
- // {YYYY-MM-DD}.jsonl (double-write; jsonl keeps full payload the table
6
- // can't hold).
7
- // • computeCost(runtime, model, ti, to) returns USD from 2026-04 pricing.
8
- // • aggregateTelemetryByRuntime / byTask / bySession drive /ultra-status.
9
-
10
- const { test } = require('node:test');
11
- const assert = require('node:assert/strict');
12
- const fs = require('node:fs');
13
- const os = require('node:os');
14
- const path = require('node:path');
15
-
16
- const { initStateDb, closeStateDb } = require('../lib/state-db.cjs');
17
- const ops = require('../lib/state-ops.cjs');
18
- const telemetry = require('../lib/telemetry.cjs');
19
- const pricing = require('../lib/pricing.cjs');
20
-
21
- function freshFixture() {
22
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-tele-'));
23
- const file = path.join(dir, '.ultra', 'state.db');
24
- const init = initStateDb(file);
25
- return { dir, db: init.db };
26
- }
27
-
28
- function teardown(dir, db) {
29
- try { closeStateDb(db); } catch (_) { /* ignore */ }
30
- try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_) { /* ignore */ }
31
- }
32
-
33
- function seedSession(db, { sid = 's-1', task_id = 't-1', runtime = 'claude' } = {}) {
34
- ops.createTask(db, { id: task_id, title: 'tele target', type: 'feature', priority: 'P1' });
35
- ops.createSession(db, {
36
- sid, task_id, runtime, pid: null,
37
- worktree_path: `/tmp/${sid}/wt`,
38
- artifact_dir: `/tmp/${sid}/art`,
39
- });
40
- return { sid, task_id, runtime };
41
- }
42
-
43
- // ─── pricing ──────────────────────────────────────────────────────────────
44
-
45
- test('computeCost: claude opus produces non-zero USD for realistic tokens', () => {
46
- const cost = pricing.computeCost('claude', 'claude-opus-4-7', 1000, 500);
47
- assert.ok(cost > 0 && cost < 1, `expected reasonable cost, got ${cost}`);
48
- });
49
-
50
- test('computeCost: unknown runtime → null', () => {
51
- assert.equal(pricing.computeCost('madeup', 'model-x', 100, 100), null);
52
- });
53
-
54
- test('computeCost: zero tokens → zero', () => {
55
- assert.equal(pricing.computeCost('claude', 'claude-sonnet-4-6', 0, 0), 0);
56
- });
57
-
58
- // ─── appendTelemetry ─────────────────────────────────────────────────────
59
-
60
- test('appendTelemetry writes to telemetry table', () => {
61
- const { dir, db } = freshFixture();
62
- try {
63
- seedSession(db, { sid: 's-t1' });
64
- const out = telemetry.appendTelemetry(db, {
65
- event_type: 'tool_call',
66
- tool_name: 'task.list',
67
- session_id: 's-t1',
68
- rootDir: dir,
69
- });
70
- assert.ok(out.id > 0);
71
- const row = db.prepare('SELECT * FROM telemetry WHERE id = ?').get(out.id);
72
- assert.equal(row.event_type, 'tool_call');
73
- assert.equal(row.tool_name, 'task.list');
74
- assert.equal(row.session_id, 's-t1');
75
- } finally { teardown(dir, db); }
76
- });
77
-
78
- test('appendTelemetry writes a jsonl line under .ultra/telemetry/YYYY-MM-DD.jsonl', () => {
79
- const { dir, db } = freshFixture();
80
- try {
81
- seedSession(db, { sid: 's-t2' });
82
- telemetry.appendTelemetry(db, {
83
- event_type: 'tool_call',
84
- tool_name: 'task.create',
85
- session_id: 's-t2',
86
- rootDir: dir,
87
- payload: { duration_ms: 42 },
88
- });
89
- const today = new Date().toISOString().slice(0, 10);
90
- const jsonl = path.join(dir, '.ultra', 'telemetry', `${today}.jsonl`);
91
- assert.ok(fs.existsSync(jsonl), `expected jsonl at ${jsonl}`);
92
- const lines = fs.readFileSync(jsonl, 'utf8').trim().split('\n');
93
- assert.equal(lines.length, 1);
94
- const parsed = JSON.parse(lines[0]);
95
- assert.equal(parsed.tool_name, 'task.create');
96
- assert.equal(parsed.payload.duration_ms, 42);
97
- } finally { teardown(dir, db); }
98
- });
99
-
100
- test('appendTelemetry auto-computes cost_usd when runtime + tokens supplied', () => {
101
- const { dir, db } = freshFixture();
102
- try {
103
- seedSession(db, { sid: 's-t3' });
104
- const out = telemetry.appendTelemetry(db, {
105
- event_type: 'token_usage',
106
- tool_name: 'session.close',
107
- session_id: 's-t3',
108
- runtime: 'claude',
109
- tokens_input: 2000,
110
- tokens_output: 500,
111
- rootDir: dir,
112
- payload: { model: 'claude-sonnet-4-6' },
113
- });
114
- const row = db.prepare('SELECT cost_usd FROM telemetry WHERE id = ?').get(out.id);
115
- assert.ok(row.cost_usd > 0, 'cost should be computed from tokens');
116
- } finally { teardown(dir, db); }
117
- });
118
-
119
- test('appendTelemetry tolerates null session_id (pre-session CLI calls)', () => {
120
- const { dir, db } = freshFixture();
121
- try {
122
- const out = telemetry.appendTelemetry(db, {
123
- event_type: 'tool_call',
124
- tool_name: 'cli.task.list',
125
- session_id: null,
126
- rootDir: dir,
127
- });
128
- const row = db.prepare('SELECT * FROM telemetry WHERE id = ?').get(out.id);
129
- assert.equal(row.session_id, null);
130
- } finally { teardown(dir, db); }
131
- });
132
-
133
- test('appendTelemetry event_type constraint: invalid value rejected', () => {
134
- const { dir, db } = freshFixture();
135
- try {
136
- assert.throws(
137
- () => telemetry.appendTelemetry(db, { event_type: 'bogus', rootDir: dir }),
138
- /CHECK|constraint|event_type/,
139
- );
140
- } finally { teardown(dir, db); }
141
- });
142
-
143
- test('appendTelemetry rejects unsupported runtime labels', () => {
144
- const { dir, db } = freshFixture();
145
- const retiredRuntime = ['gem', 'ini'].join('');
146
- try {
147
- assert.throws(
148
- () => telemetry.appendTelemetry(db, {
149
- event_type: 'tool_call', runtime: retiredRuntime, rootDir: dir,
150
- }),
151
- /unsupported runtime/,
152
- );
153
- assert.equal(db.prepare('SELECT COUNT(*) AS n FROM telemetry').get().n, 0);
154
- } finally { teardown(dir, db); }
155
- });
156
-
157
- // ─── aggregation ─────────────────────────────────────────────────────────
158
-
159
- test('aggregateTelemetryByRuntime: groups calls + sums tokens/cost', () => {
160
- const { dir, db } = freshFixture();
161
- try {
162
- seedSession(db, { sid: 's-a1', task_id: 't-a1', runtime: 'claude' });
163
- seedSession(db, { sid: 's-a2', task_id: 't-a2', runtime: 'codex' });
164
- telemetry.appendTelemetry(db, { event_type: 'token_usage', tool_name: 'x', session_id: 's-a1', runtime: 'claude', tokens_input: 1000, tokens_output: 200, rootDir: dir, payload: { model: 'claude-sonnet-4-6' } });
165
- telemetry.appendTelemetry(db, { event_type: 'token_usage', tool_name: 'y', session_id: 's-a1', runtime: 'claude', tokens_input: 500, tokens_output: 100, rootDir: dir, payload: { model: 'claude-sonnet-4-6' } });
166
- telemetry.appendTelemetry(db, { event_type: 'token_usage', tool_name: 'z', session_id: 's-a2', runtime: 'codex', tokens_input: 800, tokens_output: 300, rootDir: dir, payload: { model: 'gpt-5.4' } });
167
-
168
- const summary = ops.aggregateTelemetryByRuntime(db);
169
- const byRuntime = Object.fromEntries(summary.map((r) => [r.runtime, r]));
170
- assert.equal(byRuntime.claude.calls, 2);
171
- assert.equal(byRuntime.codex.calls, 1);
172
- assert.equal(byRuntime.claude.tokens_in, 1500);
173
- assert.equal(byRuntime.codex.tokens_in, 800);
174
- assert.ok(byRuntime.claude.cost_usd > 0);
175
- } finally { teardown(dir, db); }
176
- });
177
-
178
- test('aggregateTelemetryByTask: top N tasks by cost', () => {
179
- const { dir, db } = freshFixture();
180
- try {
181
- seedSession(db, { sid: 's-b1', task_id: 't-cheap', runtime: 'claude' });
182
- seedSession(db, { sid: 's-b2', task_id: 't-expensive', runtime: 'claude' });
183
- telemetry.appendTelemetry(db, { event_type: 'token_usage', tool_name: 'x', session_id: 's-b1', runtime: 'claude', tokens_input: 100, tokens_output: 50, rootDir: dir, payload: { model: 'claude-sonnet-4-6' } });
184
- telemetry.appendTelemetry(db, { event_type: 'token_usage', tool_name: 'y', session_id: 's-b2', runtime: 'claude', tokens_input: 10000, tokens_output: 5000, rootDir: dir, payload: { model: 'claude-opus-4-7' } });
185
-
186
- const top = ops.aggregateTelemetryByTask(db, { limit: 1 });
187
- assert.equal(top.length, 1);
188
- assert.equal(top[0].task_id, 't-expensive');
189
- assert.ok(top[0].cost_usd > 0);
190
- } finally { teardown(dir, db); }
191
- });
192
-
193
- test('aggregateTelemetryBySession: per-session roll-up', () => {
194
- const { dir, db } = freshFixture();
195
- try {
196
- seedSession(db, { sid: 's-c1', task_id: 't-c1', runtime: 'claude' });
197
- telemetry.appendTelemetry(db, { event_type: 'tool_call', tool_name: 'task.list', session_id: 's-c1', rootDir: dir });
198
- telemetry.appendTelemetry(db, { event_type: 'tool_call', tool_name: 'task.get', session_id: 's-c1', rootDir: dir });
199
- telemetry.appendTelemetry(db, { event_type: 'token_usage', tool_name: 'session.close', session_id: 's-c1', runtime: 'claude', tokens_input: 500, tokens_output: 100, rootDir: dir, payload: { model: 'claude-haiku-4-5' } });
200
-
201
- const summary = ops.aggregateTelemetryBySession(db, 's-c1');
202
- assert.equal(summary.tool_calls, 3);
203
- assert.equal(summary.tokens_in, 500);
204
- assert.equal(summary.tokens_out, 100);
205
- assert.ok(summary.cost_usd > 0);
206
- } finally { teardown(dir, db); }
207
- });
208
-
209
- test('aggregate filter: since cutoff excludes older rows', () => {
210
- const { dir, db } = freshFixture();
211
- try {
212
- seedSession(db, { sid: 's-d1', task_id: 't-d1', runtime: 'claude' });
213
- // Insert one row; then a cutoff in the future filters it out.
214
- telemetry.appendTelemetry(db, { event_type: 'tool_call', tool_name: 'x', session_id: 's-d1', rootDir: dir });
215
- const futureCutoff = new Date(Date.now() + 60 * 1000).toISOString();
216
- const summary = ops.aggregateTelemetryByRuntime(db, { since: futureCutoff });
217
- assert.equal(summary.length, 0);
218
- } finally { teardown(dir, db); }
219
- });
@@ -1,165 +0,0 @@
1
- 'use strict';
2
-
3
- const test = require('node:test');
4
- const assert = require('node:assert/strict');
5
-
6
- const { buildPlan, listWaveConflicts } = require('./plan-builder.cjs');
7
-
8
- function mk(id, deps = [], files = [], complexity = 3) {
9
- return { id, deps, files_modified: files, complexity };
10
- }
11
-
12
- test('buildPlan fixture 1: 5 tasks, no file overlap → 2 waves, no conflicts', () => {
13
- const tasks = [
14
- mk('a', [], ['src/a.ts'], 2),
15
- mk('b', ['a'], ['src/b.ts'], 3),
16
- mk('c', ['a'], ['src/c.ts'], 4),
17
- mk('d', ['b', 'c'], ['src/d.ts'], 5),
18
- mk('e', [], ['src/e.ts'], 2),
19
- ];
20
- const plan = buildPlan(tasks);
21
- assert.equal(plan.waves.length, 3);
22
- assert.equal(plan.conflict_surface.length, 0);
23
- // wave 1 should contain a+e, both parallel-safe
24
- assert.deepEqual(new Set(plan.waves[0].tasks), new Set(['a', 'e']));
25
- assert.equal(plan.waves[0].parallel, true);
26
- assert.deepEqual(plan.cycles, []);
27
- assert.ok(plan.estimated_cost_usd > 0);
28
- assert.ok(plan.estimated_duration_min > 0);
29
- });
30
-
31
- test('buildPlan fixture 2: same wave, shared file → conflict_surface + parallel=false', () => {
32
- const tasks = [
33
- mk('a', []),
34
- mk('b', ['a'], ['src/utils.ts']),
35
- mk('c', ['a'], ['src/utils.ts']),
36
- ];
37
- const plan = buildPlan(tasks);
38
- assert.equal(plan.waves.length, 2);
39
- const w2 = plan.waves[1];
40
- assert.equal(w2.parallel, false);
41
- assert.match(w2.reason, /shared files: src\/utils\.ts/);
42
-
43
- assert.equal(plan.conflict_surface.length, 1);
44
- const c = plan.conflict_surface[0];
45
- assert.deepEqual(c.files, ['src/utils.ts']);
46
- assert.deepEqual(new Set(c.tasks), new Set(['b', 'c']));
47
- assert.equal(c.recommend, 'sequentialize');
48
- });
49
-
50
- test('buildPlan fixture 3: cross-wave same file → NOT a conflict', () => {
51
- const tasks = [
52
- mk('a', [], ['src/shared.ts']),
53
- mk('b', ['a'], ['src/shared.ts']),
54
- ];
55
- const plan = buildPlan(tasks);
56
- assert.equal(plan.conflict_surface.length, 0);
57
- // wave 1 has only `a`, not parallel; wave 2 has only `b`, not parallel
58
- for (const w of plan.waves) {
59
- assert.equal(w.parallel, false);
60
- assert.ok(!w.reason);
61
- }
62
- });
63
-
64
- test('buildPlan fixture 4: empty tasks → empty plan, non-negative numbers', () => {
65
- const plan = buildPlan([]);
66
- assert.deepEqual(plan.waves, []);
67
- assert.deepEqual(plan.conflict_surface, []);
68
- assert.deepEqual(plan.ownership_forecast, {});
69
- assert.equal(plan.estimated_cost_usd, 0);
70
- assert.equal(plan.estimated_duration_min, 0);
71
- assert.deepEqual(plan.cycles, []);
72
- });
73
-
74
- test('buildPlan fixture 5: cost + duration math (parallel vs serial waves)', () => {
75
- // wave 1 (parallel): a complexity=2, b complexity=4 → max = 4 * 5 = 20 min
76
- // wave 2 (1 task, not parallel): c complexity=6 → 6 * 5 = 30 min
77
- // total duration = 50 min
78
- const tasks = [
79
- mk('a', [], ['src/a.ts'], 2),
80
- mk('b', [], ['src/b.ts'], 4),
81
- mk('c', ['a', 'b'], ['src/c.ts'], 6),
82
- ];
83
- const plan = buildPlan(tasks);
84
- assert.equal(plan.estimated_duration_min, 50);
85
- // cost: 3 tasks × sum(complexity)=12 × (5000*3e-6 + 2000*15e-6) per complexity unit
86
- // = 12 × (0.015 + 0.030) = 12 × 0.045 = 0.54 usd
87
- assert.ok(Math.abs(plan.estimated_cost_usd - 0.54) < 0.001);
88
- });
89
-
90
- test('buildPlan: serial wave (with conflict) sums complexities instead of max', () => {
91
- // both tasks in same wave share a file → wave is serial
92
- // wave 1: a(complexity=3), b(complexity=3) → (3+3) * 5 = 30 min
93
- const tasks = [
94
- mk('a', [], ['src/utils.ts'], 3),
95
- mk('b', [], ['src/utils.ts'], 3),
96
- ];
97
- const plan = buildPlan(tasks);
98
- assert.equal(plan.waves[0].parallel, false);
99
- assert.equal(plan.estimated_duration_min, 30);
100
- });
101
-
102
- test('buildPlan: ownership_forecast captures per-task files_modified verbatim', () => {
103
- const tasks = [
104
- mk('a', [], ['src/a.ts', 'src/a.util.ts']),
105
- mk('b', [], ['src/b.ts']),
106
- ];
107
- const plan = buildPlan(tasks);
108
- assert.deepEqual(plan.ownership_forecast, {
109
- a: ['src/a.ts', 'src/a.util.ts'],
110
- b: ['src/b.ts'],
111
- });
112
- });
113
-
114
- test('buildPlan: cycle in task graph surfaced in plan.cycles', () => {
115
- const tasks = [
116
- mk('x', ['y']),
117
- mk('y', ['x']),
118
- ];
119
- const plan = buildPlan(tasks);
120
- assert.deepEqual(plan.waves, []);
121
- assert.equal(plan.cycles.length, 1);
122
- assert.deepEqual(new Set(plan.cycles[0]), new Set(['x', 'y']));
123
- });
124
-
125
- test('buildPlan: missing complexity defaults to DEFAULT_COMPLEXITY for cost calc', () => {
126
- const tasks = [{ id: 'a', deps: [], files_modified: [] }];
127
- const plan = buildPlan(tasks);
128
- // complexity 3 → 15k in, 6k out → cost > 0
129
- assert.ok(plan.estimated_cost_usd > 0);
130
- assert.equal(plan.estimated_duration_min, 15); // 3 * 5 (single-task wave, not parallel)
131
- });
132
-
133
- test('buildPlan: non-array input → TypeError', () => {
134
- assert.throws(() => buildPlan(null), TypeError);
135
- assert.throws(() => buildPlan({}), TypeError);
136
- });
137
-
138
- test('listWaveConflicts: no overlap → empty', () => {
139
- const byId = new Map([
140
- ['a', { id: 'a', files_modified: ['x.ts'] }],
141
- ['b', { id: 'b', files_modified: ['y.ts'] }],
142
- ]);
143
- assert.deepEqual(listWaveConflicts(['a', 'b'], byId), []);
144
- });
145
-
146
- test('listWaveConflicts: pair overlap reported once', () => {
147
- const byId = new Map([
148
- ['a', { id: 'a', files_modified: ['shared.ts', 'x.ts'] }],
149
- ['b', { id: 'b', files_modified: ['shared.ts'] }],
150
- ]);
151
- const out = listWaveConflicts(['a', 'b'], byId);
152
- assert.equal(out.length, 1);
153
- assert.deepEqual(out[0].files, ['shared.ts']);
154
- assert.deepEqual(out[0].tasks, ['a', 'b']);
155
- });
156
-
157
- test('listWaveConflicts: three-way overlap yields C(3,2)=3 pairs', () => {
158
- const byId = new Map([
159
- ['a', { id: 'a', files_modified: ['x.ts'] }],
160
- ['b', { id: 'b', files_modified: ['x.ts'] }],
161
- ['c', { id: 'c', files_modified: ['x.ts'] }],
162
- ]);
163
- const out = listWaveConflicts(['a', 'b', 'c'], byId);
164
- assert.equal(out.length, 3);
165
- });
@@ -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
- });